commit df4e044d7475b438008d19a757bd8d639f27f21b Author: Suriya Date: Mon Jul 6 20:27:53 2026 +0530 initial commit: push everything diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0ae102 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Claude AI settings +.claude/ + diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..b95fa4d --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: web + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..781a159 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# nearle + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..b79e1e0 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,32 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +analyzer: + errors: + file_names: ignore + unused_field: ignore +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..fd764dc --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore + + diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..c2fcaaf --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,122 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" + id "com.google.gms.google-services" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader("UTF-8") { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty("flutter.versionCode") ?: "1" +def flutterVersionName = localProperties.getProperty("flutter.versionName") ?: "1.0" + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +def googleMapsApiKey = localProperties.getProperty("GOOGLE_MAPS_API_KEY") ?: "" + +android { + namespace = "com.nearle.partner" + compileSdk = 36 + + // Correct NDK version + ndkVersion "27.0.12077973" + + packagingOptions { + jniLibs { + useLegacyPackaging = true + } + // ❌ removed doNotStrip (this was breaking your AAB build) + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true + } + + kotlinOptions { + jvmTarget = "1.8" + } + + defaultConfig { + applicationId = "com.nearle.partner" + + minSdkVersion flutter.minSdkVersion + targetSdkVersion 36 + + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + + manifestPlaceholders = [ + GOOGLE_MAPS_API_KEY: googleMapsApiKey, + applicationName: "android.app.Application" + ] + + // Build only ARM64 + ndk { + abiFilters "arm64-v8a" + } + } + + signingConfigs { + release { + keyAlias keystoreProperties["keyAlias"] + keyPassword keystoreProperties["keyPassword"] + storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null + storePassword keystoreProperties["storePassword"] + } + } + + buildTypes { + debug { + minifyEnabled false + debuggable true + } + + release { + signingConfig signingConfigs.release + minifyEnabled true + shrinkResources true + + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + + // ❌ removed ndk { debugSymbolLevel } (this caused strip crash) + } + } +} + +dependencies { + implementation "androidx.core:core:1.10.0" + implementation "com.google.android.gms:play-services-location:21.0.1" + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5" +} + +flutter { + source = "../.." +} + +tasks.register("copyNotificationSounds", Copy) { + def audioDir = file("$projectDir/../../assets/audio") + from(audioDir) + include("*.mp3") + into("$projectDir/src/main/res/raw") + + rename { String fileName -> + fileName.replaceAll(/[^A-Za-z0-9_.]/, "_").toLowerCase() + } +} + +tasks.named("preBuild").configure { + dependsOn("copyNotificationSounds") +} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..1c02d6f --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.nearle" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.nearle" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } + + packaging { + jniLibs { + doNotStrip.add("**/*.so") + } + } +} + +flutter { + source = "../.." +} +// android/app/build.gradle.kts (keep this task) +tasks.register("copyNotificationSounds") { + from("$projectDir/../../assets/audio") { + include("*.mp3") + } + into("$projectDir/src/main/res/raw") + rename { it.replace(Regex("[^A-Za-z0-9_.]"), "_").lowercase() } +} +tasks.named("preBuild") { dependsOn("copyNotificationSounds") } \ No newline at end of file diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..21b0c59 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,163 @@ +{ + "project_info": { + "project_number": "140444764229", + "firebase_url": "https://nearle-gear-default-rtdb.firebaseio.com", + "project_id": "nearle-gear", + "storage_bucket": "nearle-gear.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:140444764229:android:a584c5e9127c3789283b2c", + "android_client_info": { + "package_name": "com.nearle.admin" + } + }, + "oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBkzz2Yua74Q9YpzGmUPFP94fmJQqNMIiU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "140444764229-m6l2v6eofrb9jgrno3qmsrjbtd4iccvd.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.nearle.gear" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:140444764229:android:a99eaae950fa5ada283b2c", + "android_client_info": { + "package_name": "com.nearle.bond" + } + }, + "oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBkzz2Yua74Q9YpzGmUPFP94fmJQqNMIiU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "140444764229-m6l2v6eofrb9jgrno3qmsrjbtd4iccvd.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.nearle.gear" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:140444764229:android:88df9627e2990ef4283b2c", + "android_client_info": { + "package_name": "com.nearle.gear" + } + }, + "oauth_client": [ + { + "client_id": "140444764229-5cllr5f8u28psf8ttc220f6h487u9vjq.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.nearle.gear", + "certificate_hash": "1d8115901ded2af41250ffbfe99b9239a1953c05" + } + }, + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBkzz2Yua74Q9YpzGmUPFP94fmJQqNMIiU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "140444764229-m6l2v6eofrb9jgrno3qmsrjbtd4iccvd.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.nearle.gear" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:140444764229:android:578383f5a1d3a05c283b2c", + "android_client_info": { + "package_name": "com.nearle.partner" + } + }, + "oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBkzz2Yua74Q9YpzGmUPFP94fmJQqNMIiU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "140444764229-m6l2v6eofrb9jgrno3qmsrjbtd4iccvd.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.nearle.gear" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} diff --git a/android/app/nearlerider-keystore.jks b/android/app/nearlerider-keystore.jks new file mode 100644 index 0000000..7f4d142 Binary files /dev/null and b/android/app/nearlerider-keystore.jks differ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..138d106 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,89 @@ +########################################## +## ✅ FLUTTER +########################################## +-keep class io.flutter.** { *; } +-dontwarn io.flutter.** + +########################################## +## ✅ FIREBASE (Safe) +########################################## +-keep class com.google.firebase.** { *; } +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.firebase.** +-dontwarn com.google.android.gms.** + +########################################## +## ✅ FCM Background Messaging +########################################## +-keep class com.google.firebase.messaging.FirebaseMessagingService { *; } +-keep class com.google.firebase.messaging.FirebaseMessaging { *; } +-keep class com.google.firebase.messaging.RemoteMessage { *; } + +########################################## +## ✅ Gson / JSON Models (Correct way) +########################################## +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; +} + +# Keep all model classes to prevent crashes +-keep class **.model.** { *; } +-keep class **.models.** { *; } + +########################################## +## ✅ Retrofit / OkHttp +########################################## +-dontwarn okhttp3.** +-dontwarn okio.** +-dontwarn retrofit2.** +-keep class okhttp3.** { *; } +-keep class okio.** { *; } +-keep interface retrofit2.** { *; } + +########################################## +## ✅ Glide / image loading (avoid BigPicture FCM crash) +########################################## +-dontwarn com.bumptech.glide.** +-keep class com.bumptech.glide.** { *; } + +########################################## +## ✅ Prevent crashes for reflection +########################################## +-keepattributes Signature +-keepattributes Annotation + +########################################## +## ✅ KEEP ENUMS (Firebase uses it) +########################################## +-keepclassmembers enum * { *; } + +########################################## +## ✅ Do NOT break Kotlin (Important) +########################################## +-keep class kotlin.** { *; } +-dontwarn kotlin.** + +########################################## +## ✅ GETX (State Management & Snackbars) +########################################## +# Keep all GetX classes and methods +-keep class get.** { *; } +-keep class * extends get.GetxController { *; } +-keepclassmembers class * extends get.GetxController { + ; + ; +} +# Keep GetX navigation and routing +-keep class get.Get { *; } +-keep class get.GetMaterialApp { *; } +-keep class get.GetNavigator { *; } +-keep class get.GetSnackbar { *; } +# Keep GetX observables and reactive variables +-keep class get.Rx* { *; } +-keep class get.Obs* { *; } +-dontwarn get.** + +########################################## +## ✅ Reduce unnecessary warnings +########################################## +-ignorewarnings diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..8ffe024 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..008b489 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/nearle/gear/MainActivity.kt b/android/app/src/main/kotlin/com/nearle/gear/MainActivity.kt new file mode 100644 index 0000000..ab9c266 --- /dev/null +++ b/android/app/src/main/kotlin/com/nearle/gear/MainActivity.kt @@ -0,0 +1,315 @@ +package com.nearle.partner + +import android.app.AlarmManager +import android.app.PendingIntent +import android.app.PictureInPictureParams +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.Rational +import androidx.core.view.WindowCompat +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.util.Calendar + +class MainActivity : FlutterActivity() { + private val channelName = "nearle/pip" + private val shiftEndChannelName = "nearle/shift_end" + + // PiP tracking for safe exit on resume + private val PREFS = "app_prefs" + private val KEY_WAS_IN_PIP = "was_in_pip" + private val handler = Handler(Looper.getMainLooper()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // No automatic PiP entry here – only from explicit Flutter calls + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Enable edge-to-edge for Android 15+ (SDK 35+) + if (Build.VERSION.SDK_INT >= 35) { + WindowCompat.setDecorFitsSystemWindows(window, false) + } + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "enterPip" -> { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val aspect = Rational(16, 9) + val params = PictureInPictureParams.Builder() + .setAspectRatio(aspect) + .build() + enterPictureInPictureMode(params) + } else { + @Suppress("DEPRECATION") + enterPictureInPictureMode() + } + result.success(true) + } catch (e: Exception) { + result.error("PIP_ERROR", e.message, null) + } + } + "moveToBack" -> { + try { + moveTaskToBack(true) + result.success(true) + } catch (e: Exception) { + result.error("MOVE_BACK_ERROR", e.message, null) + } + } + "hasActiveDeliveries" -> { + try { + val hasActive = checkActiveDeliveries() + result.success(hasActive) + } catch (e: Exception) { + result.error("CHECK_ACTIVE_ERROR", e.message, null) + } + } + "ensureExitPip" -> { + try { + ensureExitPip() + result.success(true) + } catch (e: Exception) { + result.error("ENSURE_EXIT_PIP_ERROR", e.message, null) + } + } + else -> result.notImplemented() + } + } + + // Shift end alarm channel + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, shiftEndChannelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "scheduleShiftEndAlarm" -> { + try { + val endTimeStr = call.argument("endTime") ?: "" + val startTimeStr = call.argument("startTime") ?: "" + if (endTimeStr.isNotEmpty()) { + scheduleShiftEndAlarm(endTimeStr, startTimeStr) + result.success(true) + } else { + result.error("INVALID_TIME", "End time is required", null) + } + } catch (e: Exception) { + result.error("ALARM_ERROR", e.message, null) + } + } + "cancelShiftEndAlarm" -> { + try { + cancelShiftEndAlarm() + result.success(true) + } catch (e: Exception) { + result.error("CANCEL_ERROR", e.message, null) + } + } + "handleShiftEnd" -> { + try { + // This is called by the BroadcastReceiver + // We need to call Flutter code to create break log + // Since we're in MainActivity, we can use the existing Flutter engine + // But the receiver might not have access to it, so we'll handle it differently + // The receiver will directly call the background service method + result.success(true) + } catch (e: Exception) { + result.error("HANDLE_ERROR", e.message, null) + } + } + else -> result.notImplemented() + } + } + } + + // Let Flutter-side explicitly control when to enter PiP (navigation only) + override fun onUserLeaveHint() { + super.onUserLeaveHint() + } + + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration? + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + // Persist PiP state so we can safely exit it on resume + val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_WAS_IN_PIP, isInPictureInPictureMode).apply() + } + + override fun onResume() { + super.onResume() + // When user brings app back, force full-screen (no PiP) + ensureExitPip() + } + + private fun ensureExitPip() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val wasInPip = prefs.getBoolean(KEY_WAS_IN_PIP, false) + + // Clear the persisted flag so we don't auto-re-enter PiP + // Note: Android doesn't provide a direct API to exit PiP programmatically, + // but when user opens app from launcher, it naturally returns to full-screen. + // We just ensure we don't auto-re-enter PiP on resume. + if (wasInPip) { + prefs.edit().putBoolean(KEY_WAS_IN_PIP, false).apply() + } + } + + private fun checkActiveDeliveries(): Boolean { + return try { + val prefs: SharedPreferences = getSharedPreferences( + "FlutterSharedPreferences", + Context.MODE_PRIVATE + ) + // Only check has_live_deliveries (verified by API) + val hasLive = prefs.getBoolean("flutter.has_live_deliveries", false) + hasLive + } catch (e: Exception) { + false + } + } + + private fun scheduleShiftEndAlarm(endTimeStr: String, startTimeStr: String) { + try { + Log.d("MainActivity", "📅 Scheduling shift end alarm - endTime: $endTimeStr, startTime: $startTimeStr") + + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(this, ShiftEndReceiver::class.java).apply { + action = "com.nearle.partner.SHIFT_END_ALARM" + } + val pendingIntent = PendingIntent.getBroadcast( + this, + 1001, // Unique request code + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Parse end time (format: HH:mm:ss or HH:mm) + val timeParts = endTimeStr.split(":") + if (timeParts.size < 2) { + Log.e("MainActivity", "❌ Invalid end time format: $endTimeStr") + return + } + + val endHour = timeParts[0].toIntOrNull() ?: 0 + val endMinute = timeParts[1].toIntOrNull() ?: 0 + + val calendar = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, endHour) + set(Calendar.MINUTE, endMinute) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + // Handle overnight shifts (if start > end) + if (startTimeStr.isNotEmpty()) { + val startParts = startTimeStr.split(":") + if (startParts.size >= 2) { + val startHour = startParts[0].toIntOrNull() ?: 0 + val startMinute = startParts[1].toIntOrNull() ?: 0 + + if (startHour > endHour || (startHour == endHour && startMinute > endMinute)) { + // Overnight shift - if current time is before end time, schedule for tomorrow + val now = Calendar.getInstance() + if (now.before(calendar)) { + calendar.add(Calendar.DAY_OF_MONTH, 1) + } + } + } + } + + // ✅ CRITICAL: If shift end time has already passed today, trigger immediately + val now = Calendar.getInstance() + Log.d("MainActivity", "Current time: ${now.time}, Shift end time: ${calendar.time}") + + if (calendar.before(now) || calendar.equals(now)) { + // Shift end time has passed - trigger break log immediately + Log.d("MainActivity", "⚡ Shift end time ($endTimeStr) has already passed - triggering break log immediately") + + // Trigger the receiver immediately + val immediateIntent = Intent(this, ShiftEndReceiver::class.java).apply { + action = "com.nearle.partner.SHIFT_END_ALARM" + } + sendBroadcast(immediateIntent) + Log.d("MainActivity", "✅ Sent immediate broadcast to ShiftEndReceiver") + + // Also schedule for tomorrow to handle next shift + calendar.add(Calendar.DAY_OF_MONTH, 1) + Log.d("MainActivity", "📅 Also scheduled shift end alarm for tomorrow: ${calendar.time}") + } + + // Schedule exact alarm (Android 12+ requires SCHEDULE_EXACT_ALARM permission) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + Log.d("MainActivity", "✅ Scheduled exact shift end alarm for: ${calendar.time} (${calendar.timeInMillis})") + } else { + // Fallback to inexact alarm + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + Log.w("MainActivity", "⚠️ Scheduled inexact shift end alarm (no permission) for: ${calendar.time}") + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + Log.d("MainActivity", "✅ Scheduled exact shift end alarm (Android M+) for: ${calendar.time}") + } else { + @Suppress("DEPRECATION") + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + Log.d("MainActivity", "✅ Scheduled exact shift end alarm (legacy) for: ${calendar.time}") + } + } + } catch (e: Exception) { + Log.e("MainActivity", "❌ Error scheduling shift end alarm: ${e.message}", e) + e.printStackTrace() + } + } + + private fun cancelShiftEndAlarm() { + try { + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(this, ShiftEndReceiver::class.java).apply { + action = "com.nearle.partner.SHIFT_END_ALARM" + } + val pendingIntent = PendingIntent.getBroadcast( + this, + 1001, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + alarmManager.cancel(pendingIntent) + Log.d("MainActivity", "Cancelled shift end alarm") + } catch (e: Exception) { + Log.e("MainActivity", "Error cancelling shift end alarm: ${e.message}", e) + } + } +} + diff --git a/android/app/src/main/kotlin/com/nearle/gear/ShiftEndReceiver.kt b/android/app/src/main/kotlin/com/nearle/gear/ShiftEndReceiver.kt new file mode 100644 index 0000000..2aa1260 --- /dev/null +++ b/android/app/src/main/kotlin/com/nearle/gear/ShiftEndReceiver.kt @@ -0,0 +1,278 @@ +package com.nearle.partner + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.PowerManager +import android.util.Log +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.* + +/** + * BroadcastReceiver that triggers when shift end alarm fires + * This works even when app is killed + * + * When app is killed, we directly call the API to create break log + * since we can't reliably start Flutter engine + */ +class ShiftEndReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.d("ShiftEndReceiver", "🔔 Shift end alarm triggered at ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Calendar.getInstance().time)}") + + // ✅ CRITICAL: Acquire WakeLock to keep device awake during API call + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ShiftEndReceiver::WakeLock") + wakeLock.acquire(60 * 1000L) // Hold for 60 seconds max + + // Execute API call in background thread + Thread { + try { + // Check if rider is still on duty + val prefs: SharedPreferences = context.getSharedPreferences( + "FlutterSharedPreferences", + Context.MODE_PRIVATE + ) + + // ✅ Try multiple key formats (Flutter SharedPreferences uses "flutter." prefix) + val onduty = prefs.getInt("flutter.onduty", 0) + Log.d("ShiftEndReceiver", "Checking onduty: $onduty") + + if (onduty != 1) { + Log.d("ShiftEndReceiver", "Rider already offline (onduty=$onduty), skipping break log creation") + return@Thread + } + + // Get required IDs - try both flutter.userid and flutter.userId + val userid = prefs.getInt("flutter.userid", 0).takeIf { it != 0 } + ?: prefs.getInt("flutter.userId", 0) + val partnerid = prefs.getInt("flutter.partnerid", 0).takeIf { it != 0 } + ?: prefs.getInt("flutter.partnerId", 0) + val shiftid = prefs.getInt("flutter.shiftid", 0).takeIf { it != 0 } + ?: prefs.getInt("flutter.shiftId", 0) + val logid = prefs.getInt("flutter.logid", 0).takeIf { it != 0 } + ?: prefs.getInt("flutter.logId", 0) + + Log.d("ShiftEndReceiver", "Retrieved IDs: userid=$userid, partnerid=$partnerid, shiftid=$shiftid, logid=$logid") + + if (userid == 0) { + Log.e("ShiftEndReceiver", "❌ Missing userid, cannot create break log. Available keys: ${prefs.all.keys}") + return@Thread + } + + // Get API base URL (check if live or dev) + // ✅ Match homepage logic: use createbreaklog (not createbreakriderlog) + val mainRoute = prefs.getString("flutter.mainRoute", "dev") ?: "dev" + val isLive = mainRoute == "live" + Log.d("ShiftEndReceiver", "API Route: $mainRoute (isLive=$isLive)") + val baseUrl = if (isLive) { + "https://jupiter.nearle.app/live/api/v2/partners/createbreaklog" + } else { + "https://jupiter.nearle.app/dev/api/v2/partners/createbreaklog" + } + + // Create break log payload + val now = Calendar.getInstance() + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.US) + val breakdate = dateFormat.format(now.time) + val breakstart = timeFormat.format(now.time) + val localBreakId = (System.currentTimeMillis() % 900).toInt() + 100 + + // ✅ Compact JSON (no extra whitespace) - matches Flutter format + val payload = """{"breakid":$localBreakId,"logid":$logid,"breakdate":"$breakdate","userid":$userid,"partnerid":$partnerid,"shiftid":$shiftid,"breakstart":"$breakstart","breakend":"","breakhours":0.0,"latitude":"0","longitude":"0"}""" + + Log.d("ShiftEndReceiver", "📤 Sending break log request to: $baseUrl") + Log.d("ShiftEndReceiver", "📦 Payload: $payload") + + // Make API call + val url = URL(baseUrl) + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Accept", "application/json") + connection.doOutput = true + connection.doInput = true + connection.useCaches = false + connection.connectTimeout = 15000 // Increased timeout + connection.readTimeout = 15000 + + Log.d("ShiftEndReceiver", "🔌 Connecting to API...") + + // Write payload + val outputStream = connection.outputStream + val writer = OutputStreamWriter(outputStream, "UTF-8") + writer.write(payload) + writer.flush() + writer.close() + + Log.d("ShiftEndReceiver", "📨 Request sent, waiting for response...") + + val responseCode = connection.responseCode + Log.d("ShiftEndReceiver", "📥 Break log API response: HTTP $responseCode") + + // Read response body for debugging + try { + val responseStream = if (responseCode in 200..299) { + connection.inputStream + } else { + connection.errorStream + } + if (responseStream != null) { + val responseBody = responseStream.bufferedReader().use { it.readText() } + Log.d("ShiftEndReceiver", "📄 Response body: $responseBody") + } + } catch (e: Exception) { + Log.w("ShiftEndReceiver", "Could not read response body: ${e.message}") + } + + if (responseCode in 200..299) { + Log.d("ShiftEndReceiver", "✅ Break log created successfully (HTTP $responseCode)") + + // Set offline locally + prefs.edit() + .putInt("flutter.onduty", 0) + .putBoolean("flutter.online", false) + .apply() + + // Also update rider log to set onduty=0 + val updateUrl = if (isLive) { + "https://jupiter.nearle.app/live/api/v2/partners/updateriderlog" + } else { + "https://jupiter.nearle.app/dev/api/v2/partners/updateriderlog" + } + + // ✅ Compact JSON (no extra whitespace) + val updatePayload = """{"userid":$userid,"onduty":0,"latitude":"0","longitude":"0"}""" + + Log.d("ShiftEndReceiver", "📤 Updating rider status to offline") + + try { + val updateConnection = URL(updateUrl).openConnection() as HttpURLConnection + updateConnection.requestMethod = "POST" + updateConnection.setRequestProperty("Content-Type", "application/json") + updateConnection.doOutput = true + updateConnection.connectTimeout = 5000 + updateConnection.readTimeout = 5000 + + val updateWriter = OutputStreamWriter(updateConnection.outputStream, "UTF-8") + updateWriter.write(updatePayload) + updateWriter.flush() + updateWriter.close() + + if (updateConnection.responseCode in 200..299) { + Log.d("ShiftEndReceiver", "✅ Rider status updated to Offline") + } + updateConnection.disconnect() + } catch (e: Exception) { + Log.e("ShiftEndReceiver", "Error updating rider status: ${e.message}") + } + } else { + // Read error response for debugging + try { + val errorStream = connection.errorStream + if (errorStream != null) { + val errorResponse = errorStream.bufferedReader().use { it.readText() } + Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode\nError: $errorResponse") + } else { + Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode") + } + } catch (e: Exception) { + Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode (Error reading response: ${e.message})") + } + } + + connection.disconnect() + + // ✅ CRITICAL: Reschedule alarm for tomorrow (so it works every day automatically) + // This ensures the alarm fires every day at shift end time even if app is killed + try { + val endTimeStr = prefs.getString("flutter.endtime", "") ?: "" + val startTimeStr = prefs.getString("flutter.starttime", "") ?: "" + Log.d("ShiftEndReceiver", "Rescheduling alarm - endTime: $endTimeStr, startTime: $startTimeStr") + + if (endTimeStr.isNotEmpty()) { + // Schedule alarm for tomorrow at the same time + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, ShiftEndReceiver::class.java).apply { + action = "com.nearle.partner.SHIFT_END_ALARM" + } + val pendingIntent = PendingIntent.getBroadcast( + context, + 1001, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val timeParts = endTimeStr.split(":") + if (timeParts.size >= 2) { + val endHour = timeParts[0].toIntOrNull() ?: 0 + val endMinute = timeParts[1].toIntOrNull() ?: 0 + + val calendar = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) // Tomorrow + set(Calendar.HOUR_OF_DAY, endHour) + set(Calendar.MINUTE, endMinute) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}") + } else { + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + } + } else { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + } else { + @Suppress("DEPRECATION") + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + } + Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}") + } + } + } + } catch (e: Exception) { + Log.e("ShiftEndReceiver", "Error rescheduling alarm: ${e.message}", e) + } + } catch (e: Exception) { + Log.e("ShiftEndReceiver", "❌ Error handling shift end: ${e.message}", e) + e.printStackTrace() + } finally { + // ✅ CRITICAL: Release WakeLock in finally block + try { + if (wakeLock.isHeld) { + wakeLock.release() + Log.d("ShiftEndReceiver", "🔓 WakeLock released") + } + } catch (e: Exception) { + Log.e("ShiftEndReceiver", "Error releasing WakeLock: ${e.message}") + } + } + }.start() + } +} diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000..f179929 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..f179929 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 0000000..0b6728c Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..0b6728c Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png new file mode 100644 index 0000000..f179929 Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png new file mode 100644 index 0000000..0b6728c Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png new file mode 100644 index 0000000..fda7bb7 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png new file mode 100644 index 0000000..9aeb9a9 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png new file mode 100644 index 0000000..388af23 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..8e21404 Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 0000000..fda7bb7 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..fda7bb7 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 0000000..9aeb9a9 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..9aeb9a9 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 0000000..388af23 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..388af23 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..8e21404 Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..ab854da Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..03904e4 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..83c5ed6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..bf1009b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..2624d78 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/raw/alert_1.mp3 b/android/app/src/main/res/raw/alert_1.mp3 new file mode 100644 index 0000000..f7ff7c6 Binary files /dev/null and b/android/app/src/main/res/raw/alert_1.mp3 differ diff --git a/android/app/src/main/res/raw/alert_10.mp3 b/android/app/src/main/res/raw/alert_10.mp3 new file mode 100644 index 0000000..1b3504e Binary files /dev/null and b/android/app/src/main/res/raw/alert_10.mp3 differ diff --git a/android/app/src/main/res/raw/alert_2.mp3 b/android/app/src/main/res/raw/alert_2.mp3 new file mode 100644 index 0000000..de379f9 Binary files /dev/null and b/android/app/src/main/res/raw/alert_2.mp3 differ diff --git a/android/app/src/main/res/raw/alert_3.mp3 b/android/app/src/main/res/raw/alert_3.mp3 new file mode 100644 index 0000000..5193f6d Binary files /dev/null and b/android/app/src/main/res/raw/alert_3.mp3 differ diff --git a/android/app/src/main/res/raw/alert_4.mp3 b/android/app/src/main/res/raw/alert_4.mp3 new file mode 100644 index 0000000..fab7581 Binary files /dev/null and b/android/app/src/main/res/raw/alert_4.mp3 differ diff --git a/android/app/src/main/res/raw/alert_5.mp3 b/android/app/src/main/res/raw/alert_5.mp3 new file mode 100644 index 0000000..1b3504e Binary files /dev/null and b/android/app/src/main/res/raw/alert_5.mp3 differ diff --git a/android/app/src/main/res/raw/alert_6.mp3 b/android/app/src/main/res/raw/alert_6.mp3 new file mode 100644 index 0000000..ef49e86 Binary files /dev/null and b/android/app/src/main/res/raw/alert_6.mp3 differ diff --git a/android/app/src/main/res/raw/alert_7.mp3 b/android/app/src/main/res/raw/alert_7.mp3 new file mode 100644 index 0000000..5c63693 Binary files /dev/null and b/android/app/src/main/res/raw/alert_7.mp3 differ diff --git a/android/app/src/main/res/raw/alert_8.mp3 b/android/app/src/main/res/raw/alert_8.mp3 new file mode 100644 index 0000000..ca6b745 Binary files /dev/null and b/android/app/src/main/res/raw/alert_8.mp3 differ diff --git a/android/app/src/main/res/raw/alert_9.mp3 b/android/app/src/main/res/raw/alert_9.mp3 new file mode 100644 index 0000000..3de4c9c Binary files /dev/null and b/android/app/src/main/res/raw/alert_9.mp3 differ diff --git a/android/app/src/main/res/raw/destination.mp3 b/android/app/src/main/res/raw/destination.mp3 new file mode 100644 index 0000000..874e9ce Binary files /dev/null and b/android/app/src/main/res/raw/destination.mp3 differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..1b8e45f --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..da32b80 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..8012d02 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..09e68d1 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..8ffe024 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..1f88145 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..475a628 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..dcc7e10 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/key.properties b/android/key.properties new file mode 100644 index 0000000..debb43c --- /dev/null +++ b/android/key.properties @@ -0,0 +1,4 @@ +storePassword=123456789 +keyPassword=123456789 +keyAlias= nearle +storeFile=nearlerider-keystore.jks \ No newline at end of file diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..5643f9c --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.google.gms.google-services") version "4.4.2" apply false +} + +include(":app") diff --git a/assets/audio/alert-1.mp3 b/assets/audio/alert-1.mp3 new file mode 100644 index 0000000..f7ff7c6 Binary files /dev/null and b/assets/audio/alert-1.mp3 differ diff --git a/assets/audio/alert-10.mp3 b/assets/audio/alert-10.mp3 new file mode 100644 index 0000000..1b3504e Binary files /dev/null and b/assets/audio/alert-10.mp3 differ diff --git a/assets/audio/alert-2.mp3 b/assets/audio/alert-2.mp3 new file mode 100644 index 0000000..de379f9 Binary files /dev/null and b/assets/audio/alert-2.mp3 differ diff --git a/assets/audio/alert-3.mp3 b/assets/audio/alert-3.mp3 new file mode 100644 index 0000000..5193f6d Binary files /dev/null and b/assets/audio/alert-3.mp3 differ diff --git a/assets/audio/alert-4.mp3 b/assets/audio/alert-4.mp3 new file mode 100644 index 0000000..fab7581 Binary files /dev/null and b/assets/audio/alert-4.mp3 differ diff --git a/assets/audio/alert-5.mp3 b/assets/audio/alert-5.mp3 new file mode 100644 index 0000000..1b3504e Binary files /dev/null and b/assets/audio/alert-5.mp3 differ diff --git a/assets/audio/alert-6.mp3 b/assets/audio/alert-6.mp3 new file mode 100644 index 0000000..ef49e86 Binary files /dev/null and b/assets/audio/alert-6.mp3 differ diff --git a/assets/audio/alert-7.mp3 b/assets/audio/alert-7.mp3 new file mode 100644 index 0000000..5c63693 Binary files /dev/null and b/assets/audio/alert-7.mp3 differ diff --git a/assets/audio/alert-8.mp3 b/assets/audio/alert-8.mp3 new file mode 100644 index 0000000..ca6b745 Binary files /dev/null and b/assets/audio/alert-8.mp3 differ diff --git a/assets/audio/alert-9.mp3 b/assets/audio/alert-9.mp3 new file mode 100644 index 0000000..3de4c9c Binary files /dev/null and b/assets/audio/alert-9.mp3 differ diff --git a/assets/audio/destination.mp3 b/assets/audio/destination.mp3 new file mode 100644 index 0000000..874e9ce Binary files /dev/null and b/assets/audio/destination.mp3 differ diff --git a/assets/fonts/ProximaNova/proximanova_bold.otf b/assets/fonts/ProximaNova/proximanova_bold.otf new file mode 100644 index 0000000..1ea7753 Binary files /dev/null and b/assets/fonts/ProximaNova/proximanova_bold.otf differ diff --git a/assets/fonts/ProximaNova/proximanova_regular.ttf b/assets/fonts/ProximaNova/proximanova_regular.ttf new file mode 100644 index 0000000..7d00247 Binary files /dev/null and b/assets/fonts/ProximaNova/proximanova_regular.ttf differ diff --git a/assets/images/CreateMpin.png b/assets/images/CreateMpin.png new file mode 100644 index 0000000..b7388db Binary files /dev/null and b/assets/images/CreateMpin.png differ diff --git a/assets/images/Gps.png b/assets/images/Gps.png new file mode 100644 index 0000000..4fa1988 Binary files /dev/null and b/assets/images/Gps.png differ diff --git a/assets/images/Mpin.png b/assets/images/Mpin.png new file mode 100644 index 0000000..73cc9b5 Binary files /dev/null and b/assets/images/Mpin.png differ diff --git a/assets/images/Nearle Bike.png b/assets/images/Nearle Bike.png new file mode 100644 index 0000000..513f937 Binary files /dev/null and b/assets/images/Nearle Bike.png differ diff --git a/assets/images/cancel.png b/assets/images/cancel.png new file mode 100644 index 0000000..270d7d7 Binary files /dev/null and b/assets/images/cancel.png differ diff --git a/assets/images/customermap.png b/assets/images/customermap.png new file mode 100644 index 0000000..4c00758 Binary files /dev/null and b/assets/images/customermap.png differ diff --git a/assets/images/deliveryicon.png b/assets/images/deliveryicon.png new file mode 100644 index 0000000..84d2dee Binary files /dev/null and b/assets/images/deliveryicon.png differ diff --git a/assets/images/gps-navigation.png b/assets/images/gps-navigation.png new file mode 100644 index 0000000..b7ce2a4 Binary files /dev/null and b/assets/images/gps-navigation.png differ diff --git a/assets/images/homeicon.png b/assets/images/homeicon.png new file mode 100644 index 0000000..418690e Binary files /dev/null and b/assets/images/homeicon.png differ diff --git a/assets/images/in.png b/assets/images/in.png new file mode 100644 index 0000000..29bc687 Binary files /dev/null and b/assets/images/in.png differ diff --git a/assets/images/information-point.png b/assets/images/information-point.png new file mode 100644 index 0000000..5dfaab2 Binary files /dev/null and b/assets/images/information-point.png differ diff --git a/assets/images/intro1.png b/assets/images/intro1.png new file mode 100644 index 0000000..5ff7fcb Binary files /dev/null and b/assets/images/intro1.png differ diff --git a/assets/images/intro2.png b/assets/images/intro2.png new file mode 100644 index 0000000..f19c5c4 Binary files /dev/null and b/assets/images/intro2.png differ diff --git a/assets/images/intro3.png b/assets/images/intro3.png new file mode 100644 index 0000000..8b04114 Binary files /dev/null and b/assets/images/intro3.png differ diff --git a/assets/images/launcher_icon.png b/assets/images/launcher_icon.png new file mode 100644 index 0000000..4a7722a Binary files /dev/null and b/assets/images/launcher_icon.png differ diff --git a/assets/images/map.png b/assets/images/map.png new file mode 100644 index 0000000..29b5059 Binary files /dev/null and b/assets/images/map.png differ diff --git a/assets/images/nearlelauncher.png b/assets/images/nearlelauncher.png new file mode 100644 index 0000000..b72573c Binary files /dev/null and b/assets/images/nearlelauncher.png differ diff --git a/assets/images/nearleplaystore1.png b/assets/images/nearleplaystore1.png new file mode 100644 index 0000000..f986c2b Binary files /dev/null and b/assets/images/nearleplaystore1.png differ diff --git a/assets/images/nearlesplash2.png b/assets/images/nearlesplash2.png new file mode 100644 index 0000000..bae1eb2 Binary files /dev/null and b/assets/images/nearlesplash2.png differ diff --git a/assets/images/onlinebottom.png b/assets/images/onlinebottom.png new file mode 100644 index 0000000..7fb6511 Binary files /dev/null and b/assets/images/onlinebottom.png differ diff --git a/assets/images/onlineoffline.png b/assets/images/onlineoffline.png new file mode 100644 index 0000000..c1c644f Binary files /dev/null and b/assets/images/onlineoffline.png differ diff --git a/assets/images/orderssample.png b/assets/images/orderssample.png new file mode 100644 index 0000000..2d96571 Binary files /dev/null and b/assets/images/orderssample.png differ diff --git a/assets/images/pending.png b/assets/images/pending.png new file mode 100644 index 0000000..9d62a1d Binary files /dev/null and b/assets/images/pending.png differ diff --git a/assets/images/phone-call .png b/assets/images/phone-call .png new file mode 100644 index 0000000..147fe27 Binary files /dev/null and b/assets/images/phone-call .png differ diff --git a/assets/images/profileicon.png b/assets/images/profileicon.png new file mode 100644 index 0000000..7957b07 Binary files /dev/null and b/assets/images/profileicon.png differ diff --git a/assets/images/selcart.png b/assets/images/selcart.png new file mode 100644 index 0000000..eb96fcd Binary files /dev/null and b/assets/images/selcart.png differ diff --git a/assets/images/selecteddelivery.png b/assets/images/selecteddelivery.png new file mode 100644 index 0000000..c9e3fa1 Binary files /dev/null and b/assets/images/selecteddelivery.png differ diff --git a/assets/images/selectedprofile.png b/assets/images/selectedprofile.png new file mode 100644 index 0000000..195001f Binary files /dev/null and b/assets/images/selectedprofile.png differ diff --git a/assets/images/selectedsummary.png b/assets/images/selectedsummary.png new file mode 100644 index 0000000..dd53eee Binary files /dev/null and b/assets/images/selectedsummary.png differ diff --git a/assets/images/selecthome.png b/assets/images/selecthome.png new file mode 100644 index 0000000..41145b1 Binary files /dev/null and b/assets/images/selecthome.png differ diff --git a/assets/images/shoppingbag.png b/assets/images/shoppingbag.png new file mode 100644 index 0000000..be56a7e Binary files /dev/null and b/assets/images/shoppingbag.png differ diff --git a/assets/images/signin_banner.png b/assets/images/signin_banner.png new file mode 100644 index 0000000..e720652 Binary files /dev/null and b/assets/images/signin_banner.png differ diff --git a/assets/images/splashimg.png b/assets/images/splashimg.png new file mode 100644 index 0000000..4bbce42 Binary files /dev/null and b/assets/images/splashimg.png differ diff --git a/assets/images/summary.png b/assets/images/summary.png new file mode 100644 index 0000000..fae3006 Binary files /dev/null and b/assets/images/summary.png differ diff --git a/assets/images/today.png b/assets/images/today.png new file mode 100644 index 0000000..86cfb83 Binary files /dev/null and b/assets/images/today.png differ diff --git a/assets/images/total.png b/assets/images/total.png new file mode 100644 index 0000000..3150d7c Binary files /dev/null and b/assets/images/total.png differ diff --git a/assets/images/totalorders.png b/assets/images/totalorders.png new file mode 100644 index 0000000..27e0ceb Binary files /dev/null and b/assets/images/totalorders.png differ diff --git a/assets/images/unslccart.png b/assets/images/unslccart.png new file mode 100644 index 0000000..9a0179f Binary files /dev/null and b/assets/images/unslccart.png differ diff --git a/assets/images/update.png b/assets/images/update.png new file mode 100644 index 0000000..b3706e8 Binary files /dev/null and b/assets/images/update.png differ diff --git a/assets/images/verify.png b/assets/images/verify.png new file mode 100644 index 0000000..c62be81 Binary files /dev/null and b/assets/images/verify.png differ diff --git a/assets/images/week.png b/assets/images/week.png new file mode 100644 index 0000000..c943c55 Binary files /dev/null and b/assets/images/week.png differ diff --git a/assets/images/white.png b/assets/images/white.png new file mode 100644 index 0000000..fb9365e Binary files /dev/null and b/assets/images/white.png differ diff --git a/assets/lotties/Error Occurred!.json b/assets/lotties/Error Occurred!.json new file mode 100644 index 0000000..9ae1360 --- /dev/null +++ b/assets/lotties/Error Occurred!.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":30,"ip":0,"op":162,"w":72,"h":72,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 2","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":135,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":35,"s":[45.812,23.438,0],"to":[0.458,0.417,0],"ti":[-0.458,-0.417,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":47,"s":[48.562,25.938,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":120,"s":[48.562,25.938,0],"to":[-0.458,-0.417,0],"ti":[0.458,0.417,0]},{"t":131,"s":[45.812,23.438,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-5.938,1.562,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":3,"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":35,"s":[34.224,5.708]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":47,"s":[5.224,5.708]},{"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":120,"s":[5.224,5.708]},{"t":131,"s":[34.224,5.708]}],"ix":2},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":35,"s":[0,0],"to":[-2.333,0.333],"ti":[2.333,-0.333]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":47,"s":[-14,2],"to":[0,0],"ti":[0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":120,"s":[-14,2],"to":[2.333,-0.333],"ti":[-2.333,0.333]},{"t":131,"s":[0,0]}],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[32]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":47,"s":[32]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":120,"s":[32]},{"t":131,"s":[32]}],"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-5.969,1.696],"ix":2},"a":{"a":0,"k":[-17.143,-0.033],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":480,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 1","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":45,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":35,"s":[21.812,23.188,0],"to":[0.083,0,0],"ti":[-0.083,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":47,"s":[22.312,23.188,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":120,"s":[22.312,23.188,0],"to":[-0.083,0,0],"ti":[0.083,0,0]},{"t":131,"s":[21.812,23.188,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-5.938,1.562,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":3,"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":35,"s":[34.224,5.708]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":47,"s":[5.224,5.708]},{"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":120,"s":[5.224,5.708]},{"t":131,"s":[34.224,5.708]}],"ix":2},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":35,"s":[0,0],"to":[-2.333,0.333],"ti":[2.333,-0.333]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":47,"s":[-14,2],"to":[0,0],"ti":[0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":120,"s":[-14,2],"to":[2.333,-0.333],"ti":[-2.333,0.333]},{"t":131,"s":[0,0]}],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[32]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":47,"s":[32]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":120,"s":[32]},{"t":131,"s":[32]}],"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-5.969,1.446],"ix":2},"a":{"a":0,"k":[-17.143,-0.033],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":480,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"mouth/Payment failed Outlines","parent":4,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":44,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":120,"s":[100]},{"t":123,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[34.5,39.5,0],"ix":2,"l":2},"a":{"a":0,"k":[13.5,5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.753,0.444],[0.51,0.717],[1.924,1.132],[2.749,0.012],[2.391,-1.379],[1.31,-1.81],[-0.749,-0.45],[-0.533,0.699],[-1.454,0.839],[-2.2,-0.009],[-1.904,-1.119],[-1.007,-1.344]],"o":[[0.753,-0.444],[-1.295,-1.821],[-2.379,-1.399],[-2.749,-0.012],[-1.933,1.114],[-0.516,0.712],[0.749,0.451],[1.018,-1.335],[1.913,-1.103],[2.199,0.01],[1.447,0.852],[0.527,0.704]],"v":[[12.23,4.176],[12.736,2.029],[7.857,-2.454],[0.031,-4.607],[-7.814,-2.521],[-12.729,1.919],[-12.242,4.07],[-10.001,3.562],[-6.258,0.266],[0.018,-1.404],[6.279,0.32],[9.993,3.648]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[13.496,4.869],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":44,"op":123,"st":44,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Circle/Payment failed Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[36,36,0],"ix":2,"l":2},"a":{"a":0,"k":[34.5,34.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.571,0.571,0.571],"y":[1,1,1]},"o":{"x":[0.192,0.192,0.192],"y":[0,0,0]},"t":47,"s":[100,100,100]},{"i":{"x":[0.677,0.677,0.677],"y":[1,1,1]},"o":{"x":[0.324,0.324,0.324],"y":[0,0,0]},"t":83,"s":[86,86,100]},{"t":120,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-18.778,0],[0,-18.778],[18.778,0],[0,18.778]],"o":[[18.778,0],[0,18.778],[-18.778,0],[0,-18.778]],"v":[[0,-34],[34,0],[0,34],[-34,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.921568687289,0.341176470588,0.341176470588,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[34.25,34.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":480,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lotties/delivery done.json b/assets/lotties/delivery done.json new file mode 100644 index 0000000..2879fdd --- /dev/null +++ b/assets/lotties/delivery done.json @@ -0,0 +1 @@ +{"v":"5.5.7","meta":{"g":"LottieFiles AE 0.1.20","a":"Inktwin","k":"Successful animation, check, success, congrats, cangratulation","d":"Successful animation","tc":"dark"},"fr":24,"ip":0,"op":48,"w":200,"h":200,"nm":"Success","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 4","parent":4,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[100]},{"t":70,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-5.051,60.949,0],"ix":2},"a":{"a":0,"k":[77,2,0],"ix":1},"s":{"a":0,"k":[55.682,55.682,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0]],"o":[[0,0]],"v":[[955.194,251.637]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":64,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 2","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-236,0],[0,169.697],[223.622,0],[0,0],[0,0],[0,0],[0,0]],"o":[[232.172,0],[0,-178.341],[-228.293,0],[0,0],[0,0],[0,0],[0,0]],"v":[[54.299,326.269],[430.045,-2.319],[81.56,-379.819],[-238.557,-171.999],[-56,6],[30,92],[210,-88]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":64,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.851],"y":[0]},"t":48,"s":[83]},{"t":69,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.851],"y":[0]},"t":49,"s":[100]},{"t":70,"s":[0]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":3,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":48,"op":973,"st":13,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 2","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-5.051,60.949,0],"ix":2},"a":{"a":0,"k":[77,2,0],"ix":1},"s":{"a":0,"k":[55.682,55.682,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-56,6],[30,92],[210,-88]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":64,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[0]},{"t":24,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":49,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[101.49999999999999,92.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[25,25,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-8,-78],[-8,-254]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[0]},{"t":23,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"t":21,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"rp","c":{"a":0,"k":10,"ix":1},"o":{"a":0,"k":0,"ix":2},"m":1,"ix":3,"tr":{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[-9,17],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":36,"ix":4},"so":{"a":0,"k":100,"ix":5},"eo":{"a":0,"k":100,"ix":6},"nm":"Transform"},"nm":"Repeater 1","mn":"ADBE Vector Filter - Repeater","hd":false}],"ip":-3,"op":957,"st":-3,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":56,"s":[100]},{"t":64,"s":[0]}],"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[45]},{"t":12,"s":[0]}],"ix":10,"x":"var $bm_rt;\nvar amp, freq, decay, n, n, t, t, v;\namp = 0.06;\nfreq = 2;\ndecay = 4;\n$bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = t = 0;\n} else {\n $bm_rt = t = $bm_sub(time, key(n).time);\n}\nif (n > 0) {\n v = velocityAtTime($bm_sub(key(n).time, $bm_div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_sum(value, $bm_div($bm_mul($bm_mul(v, amp), Math.sin($bm_mul($bm_mul($bm_mul(freq, t), 2), Math.PI))), Math.exp($bm_mul(decay, t))));\n} else {\n $bm_rt = value;\n}"},"p":{"a":0,"k":[100,100,0],"ix":2},"a":{"a":0,"k":[-5.051,60.949,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,-15.667]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[0,0,100]},{"t":12,"s":[25,25,100]}],"ix":6,"x":"var $bm_rt;\nvar amp, freq, decay, n, n, t, t, v;\namp = 0.06;\nfreq = 2;\ndecay = 4;\n$bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = t = 0;\n} else {\n $bm_rt = t = $bm_sub(time, key(n).time);\n}\nif (n > 0) {\n v = velocityAtTime($bm_sub(key(n).time, $bm_div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_sum(value, $bm_div($bm_mul($bm_mul(v, amp), Math.sin($bm_mul($bm_mul($bm_mul(freq, t), 2), Math.PI))), Math.exp($bm_mul(decay, t))));\n} else {\n $bm_rt = value;\n}"}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"st","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":8,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[393.898,393.898],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":8,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-5.051,44.949],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":960,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lotties/result page succes.json b/assets/lotties/result page succes.json new file mode 100644 index 0000000..4e78511 --- /dev/null +++ b/assets/lotties/result page succes.json @@ -0,0 +1 @@ +{"nm":"result page success motion","ddd":0,"h":720,"w":720,"meta":{"g":"@lottiefiles/toolkit-js 0.66.1","tc":"#ffffff"},"layers":[{"ty":4,"nm":"Layer 5","sr":1,"st":0,"op":120,"ip":0,"ln":"292","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[136,80,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[360,364,0],"x":"var $bm_rt;\nvar $bm_rt;\nvar eff, amp, freq, decay, n, n, t, t, v;\ntry {\n eff = effect('Elastic Controller');\n amp = div(eff(1), 250);\n freq = div(eff(2), 30);\n decay = div(eff(3), 10);\n $bm_rt = $bm_rt = n = 0;\n if ($bm_sum(numKeys, 0)) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if ($bm_sum(key(n).time, time)) {\n n--;\n }\n }\n if ($bm_sum(n, 0)) {\n $bm_rt = $bm_rt = t = 0;\n } else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n }\n if ($bm_sum(n, 0)) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n } else {\n $bm_rt = $bm_rt = value;\n }\n} catch (e) {\n $bm_rt = $bm_rt = value = value;\n}"},"r":{"a":0,"k":0,"x":"var $bm_rt;\nvar $bm_rt;\nvar eff, amp, freq, decay, n, n, t, t, v;\ntry {\n eff = effect('Elastic Controller');\n amp = div(eff(1), 200);\n freq = div(eff(2), 30);\n decay = div(eff(3), 10);\n $bm_rt = $bm_rt = n = 0;\n if ($bm_sum(numKeys, 0)) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if ($bm_sum(key(n).time, time)) {\n n--;\n }\n }\n if ($bm_sum(n, 0)) {\n $bm_rt = $bm_rt = t = 0;\n } else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n }\n if ($bm_sum(n, 0)) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n } else {\n $bm_rt = $bm_rt = value;\n }\n} catch (e) {\n $bm_rt = $bm_rt = value = value;\n}"},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"gr","nm":"Group 1","it":[{"ty":"gr","nm":"Group 1","it":[{"ty":"gr","nm":"Group 2","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[84.762,84.375],[121.762,121.375],[197.962,45.175]]}}},{"ty":"tm","nm":"Trim Paths 1","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":51.818},{"s":[0],"t":53.181}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":10.908},{"o":{"x":0.167,"y":0.167},"i":{"x":0.667,"y":1},"s":[100],"t":51.818},{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[0],"t":53.181},{"s":[100],"t":60}]},"m":1},{"ty":"st","nm":"描边 1","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":20},"c":{"a":0,"k":[1,1,1]}},{"ty":"tr","a":{"a":0,"k":[137.47,86.757]},"s":{"a":0,"k":[120,120]},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.856},"s":[138.32,85.641],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":1,"y":1},"s":[186.856,50],"t":10.908},{"h":1,"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[186.856,50],"t":47.726},{"o":{"x":0,"y":0},"i":{"x":0.833,"y":1},"s":[135,142],"t":53.181},{"s":[135,90],"t":60}],"x":"var $bm_rt;\nvar $bm_rt;\nvar eff, amp, freq, decay, n, n, t, t, v;\ntry {\n eff = effect('Elastic Controller 3');\n amp = div(eff(1), 200);\n freq = div(eff(2), 30);\n decay = div(eff(3), 10);\n $bm_rt = $bm_rt = n = 0;\n if ($bm_sum(numKeys, 0)) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if ($bm_sum(key(n).time, time)) {\n n--;\n }\n }\n if ($bm_sum(n, 0)) {\n $bm_rt = $bm_rt = t = 0;\n } else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n }\n if ($bm_sum(n, 0)) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n } else {\n $bm_rt = $bm_rt = value;\n }\n} catch (e) {\n $bm_rt = $bm_rt = value = value;\n}"},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","nm":"椭圆 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[67.797,0],[0,-67.797],[-67.797,0],[0,67.797]],"o":[[-67.797,0],[0,67.797],[67.797,0],[0,-67.797]],"v":[[-104,15.242],[-226.758,138],[-104,260.758],[18.758,138]]}}},{"ty":"fl","nm":"填充 1","c":{"a":0,"k":[0.149,0.6667,0.2627]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[-105.582,142.903]},"s":{"a":0,"k":[142.01,142.01]},"p":{"a":0,"k":[137.419,87.526]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tr","a":{"a":0,"k":[134.519,89.974]},"s":{"a":1,"k":[{"s":[100,100],"i":{"x":[0.09,0.09],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":4.092},{"s":[-100,100],"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.79,0.79],"y":[0,0]},"t":31.363},{"s":[100,100],"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":57.273}]},"p":{"a":0,"k":[134.519,89.974]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tr","a":{"a":0,"k":[134.488,89.974]},"s":{"a":1,"k":[{"s":[100,100],"i":{"x":[0.833,0.833],"y":[0.833,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":9.547},{"s":[80,100],"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":15},{"s":[80,100],"i":{"x":[0.833,0.833],"y":[0.833,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":45},{"s":[100,100],"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":50.457}],"x":"var $bm_rt;\nvar $bm_rt;\nvar eff, amp, freq, decay, n, n, t, t, v;\ntry {\n eff = effect('Elastic Controller 2');\n amp = div(eff(1), 200);\n freq = div(eff(2), 30);\n decay = div(eff(3), 10);\n $bm_rt = $bm_rt = n = 0;\n if ($bm_sum(numKeys, 0)) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if ($bm_sum(key(n).time, time)) {\n n--;\n }\n }\n if ($bm_sum(n, 0)) {\n $bm_rt = $bm_rt = t = 0;\n } else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n }\n if ($bm_sum(n, 0)) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n } else {\n $bm_rt = $bm_rt = value;\n }\n} catch (e) {\n $bm_rt = $bm_rt = value = value;\n}"},"p":{"a":0,"k":[134.488,89.974]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tr","a":{"a":0,"k":[134.519,89.962]},"s":{"a":1,"k":[{"s":[100,100],"i":{"x":[0.833,0.833],"y":[1,0.833]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":9.547},{"s":[100,80],"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":15},{"s":[100,80],"i":{"x":[0.833,0.833],"y":[1,0.833]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":50.457},{"s":[100,100],"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":55.908}],"x":"var $bm_rt;\nvar $bm_rt;\nvar eff, amp, freq, decay, n, n, t, t, v;\ntry {\n eff = effect('Elastic Controller 2');\n amp = div(eff(1), 200);\n freq = div(eff(2), 30);\n decay = div(eff(3), 10);\n $bm_rt = $bm_rt = n = 0;\n if ($bm_sum(numKeys, 0)) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if ($bm_sum(key(n).time, time)) {\n n--;\n }\n }\n if ($bm_sum(n, 0)) {\n $bm_rt = $bm_rt = t = 0;\n } else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n }\n if ($bm_sum(n, 0)) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n } else {\n $bm_rt = $bm_rt = value;\n }\n} catch (e) {\n $bm_rt = $bm_rt = value = value;\n}"},"p":{"a":0,"k":[134.519,89.962]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":1},{"ty":0,"nm":"confetti","sr":1,"st":0,"op":180,"ip":0,"ln":"342","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[960,960]},"s":{"a":0,"k":[54.167,54.167,97.015]},"p":{"a":0,"k":[360,360]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"w":1920,"h":1920,"refId":"1","ind":2}],"v":"5.7.0","fr":60,"op":120,"ip":0,"assets":[{"nm":"confetti","id":"1","layers":[{"ty":4,"nm":"Element 11","sr":1,"st":9,"op":1809,"ip":9,"ln":"330","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[88.379,84.132,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.174,"y":0},"i":{"x":0.33,"y":1},"s":[957.368,957.581,0],"t":11.83,"ti":[34.333,-69,0],"to":[-154.333,61,0]},{"s":[774.545,1455.048,0],"t":34.338}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[-17.539,13.457],[-12.743,-16.607],[0,0]],"o":[[-12.743,-16.608],[17.539,-13.458],[0,0],[0,0]],"v":[[-25.635,34.132],[-16.894,-20.673],[38.306,-14.931],[38.379,-14.837]]}}},{"ty":"st","nm":"Stroke 1","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":20},"c":{"a":0,"k":[0.255,0.686,0.502]}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[88.378,84.131]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":1},{"ty":4,"nm":"Element 10","sr":1,"st":9,"op":1809,"ip":9,"ln":"329","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[33.514,41.664,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.229,"y":0},"i":{"x":0.33,"y":1},"s":[960,960,0],"t":11.83,"ti":[-71.667,-48,0],"to":[59.019,77.842,0]},{"s":[1314.113,1427.051,0],"t":37.154}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[3.281,-1.895],[0,0],[1.894,3.281],[0,0],[-3.281,1.894],[0,0],[-1.894,-3.282],[0,0]],"o":[[0,0],[-3.281,1.894],[0,0],[-1.894,-3.283],[0,0],[3.281,-1.895],[0,0],[1.894,3.281]],"v":[[28.859,29.055],[10.732,39.52],[1.361,37.009],[-31.37,-19.681],[-28.859,-29.053],[-10.732,-39.519],[-1.361,-37.008],[31.37,19.683]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.275,0.514,0.961]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[33.514,41.664]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":2},{"ty":4,"nm":"Element 9","sr":1,"st":9,"op":1809,"ip":9,"ln":"328","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[60.548,78.82,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.189,"y":0},"i":{"x":0.33,"y":1},"s":[961.262,961.213,0],"t":11.83,"ti":[-62.333,-30.333,0],"to":[70.333,-5.667,0]},{"s":[1420.768,1239.936,0],"t":35.043}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[2.886,-21.917],[20.753,2.734],[0.039,0.005]],"o":[[20.754,2.733],[-2.886,21.918],[-0.04,-0.005],[0,0]],"v":[[-12.409,-41.32],[20.162,3.615],[-22.93,38.586],[-23.048,38.571]]}}},{"ty":"st","nm":"Stroke 1","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":15},"c":{"a":0,"k":[0.9725,0.149,0.5059]}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[60.548,78.82]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":3},{"ty":4,"nm":"Element 8","sr":1,"st":9,"op":1809,"ip":9,"ln":"327","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[47.326,47.325,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.263,"y":0},"i":{"x":0.33,"y":1},"s":[959.692,959.692,0],"t":11.83,"ti":[-98.333,51.667,0],"to":[46.333,-81.667,0]},{"s":[1397.413,873.271,0],"t":52.632}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[-11.644,-3.948],[0,0],[-3.912,2.92],[0,0],[0.157,-12.294],[0,0],[-3.985,-2.819],[0,0],[11.74,-3.65],[0,0],[1.449,-4.662],[0,0],[7.098,10.038],[0,0],[4.882,-0.063],[0,0],[-7.354,9.853],[0,0],[1.569,4.624],[0,0]],"o":[[0,0],[4.623,1.568],[0,0],[9.854,-7.353],[0,0],[-0.062,4.882],[0,0],[10.038,7.099],[0,0],[-4.662,1.449],[0,0],[-3.649,11.74],[0,0],[-2.82,-3.986],[0,0],[-12.293,0.156],[0,0],[2.92,-3.913],[0,0],[-3.948,-11.644]],"v":[[-17.558,-36.37],[-13.481,-34.987],[0.154,-37.147],[3.606,-39.722],[27.31,-27.644],[27.255,-23.339],[33.522,-11.038],[37.038,-8.552],[32.877,17.724],[28.763,19.002],[19.003,28.764],[17.724,32.876],[-8.551,37.037],[-11.037,33.522],[-23.338,27.255],[-27.644,27.31],[-39.721,3.606],[-37.146,0.155],[-34.987,-13.481],[-36.37,-17.558]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[1,0.737,0.196]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[47.325,47.325]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":4},{"ty":4,"nm":"Element 7","sr":1,"st":9,"op":1809,"ip":9,"ln":"326","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[77.856,74.774,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.229,"y":0},"i":{"x":0.33,"y":1},"s":[958.09,958.244,0],"t":11.83,"ti":[-64.333,76,0],"to":[18.333,-110,0]},{"s":[1316.148,645.839,0],"t":37.154}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[-12.731,9.768],[-9.249,-12.055],[-0.018,-0.022]],"o":[[-9.249,-12.055],[12.73,-9.768],[0.018,0.023],[0,0]],"v":[[-18.607,24.774],[-12.262,-15.006],[27.805,-10.838],[27.857,-10.77]]}}},{"ty":"st","nm":"Stroke 1","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":20},"c":{"a":0,"k":[0.9725,0.149,0.5059]}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[77.856,74.774]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":5},{"ty":4,"nm":"Element 6","sr":1,"st":9,"op":1809,"ip":9,"ln":"325","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[47.326,47.325,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.189,"y":0},"i":{"x":0.33,"y":1},"s":[959.693,959.693,0],"t":11.83,"ti":[110,-64.333,0],"to":[-138,24.333,0]},{"s":[529.471,1363.811,0],"t":35.043}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[-11.644,-3.948],[0,0],[-3.913,2.92],[0,0],[0.157,-12.294],[0,0],[-3.986,-2.819],[0,0],[11.74,-3.65],[0,0],[1.449,-4.662],[0,0],[7.099,10.038],[0,0],[4.881,-0.062],[0,0],[-7.353,9.853],[0,0],[1.568,4.624],[0,0]],"o":[[0,0],[4.623,1.567],[0,0],[9.853,-7.352],[0,0],[-0.063,4.882],[0,0],[10.038,7.1],[0,0],[-4.662,1.449],[0,0],[-3.649,11.741],[0,0],[-2.819,-3.987],[0,0],[-12.293,0.157],[0,0],[2.92,-3.913],[0,0],[-3.948,-11.644]],"v":[[-17.558,-36.37],[-13.481,-34.987],[0.154,-37.147],[3.605,-39.723],[27.31,-27.644],[27.255,-23.339],[33.521,-11.038],[37.037,-8.553],[32.875,17.724],[28.763,19.002],[19.003,28.764],[17.724,32.875],[-8.552,37.037],[-11.039,33.522],[-23.339,27.254],[-27.645,27.31],[-39.722,3.606],[-37.146,0.154],[-34.987,-13.481],[-36.37,-17.558]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[1,0.7373,0.1961]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[47.325,47.325]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":6},{"ty":4,"nm":"Element 5","sr":1,"st":9,"op":1809,"ip":9,"ln":"324","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[30.771,30.771,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.283,"y":0},"i":{"x":0.33,"y":1},"s":[960,960,0],"t":11.83,"ti":[124.333,-150,0],"to":[-178.333,3,0]},{"s":[517.297,1177.159,0],"t":54.039}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-16.856],[16.856,0],[0,16.856],[-16.857,0]],"o":[[0,16.856],[-16.857,0],[0,-16.856],[16.856,0]],"v":[[30.521,0],[0,30.521],[-30.521,0],[0,-30.521]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.9725,0.149,0.5059]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[30.771,30.771]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":7},{"ty":4,"nm":"Element 4","sr":1,"st":9,"op":1809,"ip":9,"ln":"323","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[42,25.144,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.273,"y":0},"i":{"x":0.33,"y":1},"s":[960,960,0],"t":11.83,"ti":[140.667,20.667,0],"to":[-94.667,-86.667,0]},{"s":[446.624,934.727,0],"t":53.334}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[-0.829,-3.697],[0,0],[3.698,-0.828],[0,0],[0.83,3.697],[0,0],[-3.697,0.828],[0,0]],"o":[[0,0],[0.829,3.697],[0,0],[-3.697,0.828],[0,0],[-0.828,-3.697],[0,0],[3.697,-0.828]],"v":[[36.343,-18.872],[40.921,1.552],[35.727,9.747],[-28.148,24.066],[-36.344,18.872],[-40.922,-1.553],[-35.728,-9.747],[28.148,-24.066]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.3137,0.8157,0.3608]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[42,25.144]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":8},{"ty":4,"nm":"Element 3","sr":1,"st":9,"op":1809,"ip":9,"ln":"322","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[47.326,47.325,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.174,"y":0},"i":{"x":0.33,"y":1},"s":[959.693,959.692,0],"t":11.83,"ti":[119.667,22.5,0],"to":[-113.667,-146,0]},{"s":[529.471,736.071,0],"t":47.707}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[-11.644,-3.948],[0,0],[-3.913,2.92],[0,0],[0.157,-12.294],[0,0],[-3.986,-2.819],[0,0],[11.74,-3.65],[0,0],[1.449,-4.662],[0,0],[7.099,10.038],[0,0],[4.881,-0.063],[0,0],[-7.353,9.854],[0,0],[1.568,4.624],[0,0]],"o":[[0,0],[4.623,1.568],[0,0],[9.853,-7.353],[0,0],[-0.063,4.882],[0,0],[10.038,7.099],[0,0],[-4.662,1.449],[0,0],[-3.649,11.74],[0,0],[-2.819,-3.986],[0,0],[-12.293,0.157],[0,0],[2.92,-3.912],[0,0],[-3.948,-11.644]],"v":[[-17.558,-36.37],[-13.481,-34.987],[0.154,-37.147],[3.605,-39.722],[27.31,-27.644],[27.255,-23.339],[33.521,-11.038],[37.037,-8.552],[32.875,17.724],[28.763,19.002],[19.003,28.764],[17.724,32.876],[-8.552,37.037],[-11.039,33.522],[-23.339,27.255],[-27.645,27.309],[-39.722,3.605],[-37.146,0.154],[-34.987,-13.481],[-36.37,-17.558]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.2745,0.5137,0.9608]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[47.325,47.325]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":9},{"ty":4,"nm":"Element 2","sr":1,"st":9,"op":1809,"ip":9,"ln":"321","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[33.515,41.664,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.292,"y":0},"i":{"x":0.33,"y":1},"s":[960,960,0],"t":11.83,"ti":[3.667,95,0],"to":[-95.667,-127,0]},{"s":[788.638,547.457,0],"t":54.746}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[3.281,-1.895],[0,0],[1.895,3.281],[0,0],[-3.281,1.894],[0,0],[-1.895,-3.281],[0,0]],"o":[[0,0],[-3.282,1.895],[0,0],[-1.895,-3.281],[0,0],[3.281,-1.894],[0,0],[1.895,3.282]],"v":[[28.859,29.054],[10.733,39.519],[1.36,37.008],[-31.37,-19.683],[-28.859,-29.054],[-10.732,-39.52],[-1.36,-37.009],[31.37,19.682]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.2745,0.5137,0.9608]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[33.515,41.664]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":10},{"ty":4,"nm":"Element 1","sr":1,"st":9,"op":1809,"ip":9,"ln":"320","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[30.771,30.771,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.252,"y":0},"i":{"x":0.33,"y":1},"s":[960,960,0],"t":11.83,"ti":[40,75.667,0],"to":[22,-151.667,0]},{"s":[1027.199,504.507,0],"t":51.929}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.002},"i":{"x":0.833,"y":0.998},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.167,"y":0.003},"i":{"x":0.833,"y":0.997},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-16.856],[16.856,0],[0,16.857],[-16.857,0]],"o":[[0,16.857],[-16.857,0],[0,-16.856],[16.856,0]],"v":[[30.522,-0.001],[0,30.522],[-30.522,-0.001],[0,-30.522]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.255,0.686,0.502]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[30.771,30.772]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":11}]}]} \ No newline at end of file diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..ad322bc --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..b5586f2 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..0b2d479 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..0b2d479 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9b52ff9 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.nearle; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.nearle.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.nearle.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.nearle.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.nearle; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.nearle; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..c4b79bd --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..fc6bf80 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..af0309c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..bbabc4e --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..59c6d39 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..fc6bf80 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..af0309c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..8be1cec --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..8025f31 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..fed9dda Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..2ef9041 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..4257d24 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..39e3be0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..b753db5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..3ad6d54 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..2ef9041 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..a996d8e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..65615eb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..5e6ab1a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..cc7c615 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..ef8062c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..31a0e14 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..65615eb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..153bf5e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..8201815 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..2df29fe Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..af69973 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..b9a7570 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0fa075e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json new file mode 100644 index 0000000..9f447e1 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png new file mode 100644 index 0000000..8e21404 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..00cabce --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "LaunchImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "LaunchImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "LaunchImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..0b6728c Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..fda7bb7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9aeb9a9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..65a94b5 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..00bd051 --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..bbb83ca --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..7c215b2 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,53 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nearle + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + nearle + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + UIStatusBarHidden + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..fae207f --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..4d206de --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/Models/Message/GetSms.dart b/lib/Models/Message/GetSms.dart new file mode 100644 index 0000000..33ece12 --- /dev/null +++ b/lib/Models/Message/GetSms.dart @@ -0,0 +1,78 @@ +class Sms { + int? code; + SmsDetails? details; + String? message; + bool? status; + + Sms({this.code, this.details, this.message, this.status}); + + factory Sms.fromJson(Map json) { + return Sms( + code: json['code'] as int?, + details: json['details'] != null + ? SmsDetails.fromJson(json['details'] as Map) + : null, + message: json['message'] as String?, + status: json['status'] as bool?, + ); + } + + Map toJson() { + final Map data = {}; + data['code'] = code; + if (details != null) { + data['details'] = details!.toJson(); + } + data['message'] = message; + data['status'] = status; + return data; + } +} + +class SmsDetails { + int? providerId; + int? templateTypeId; + String? templateName; + int? templateId; + String? providerApi; + String? content; + int? defaultProvider; + int? passkey; + + SmsDetails({ + this.providerId, + this.templateTypeId, + this.templateName, + this.templateId, + this.providerApi, + this.content, + this.defaultProvider, + this.passkey, + }); + + factory SmsDetails.fromJson(Map json) { + return SmsDetails( + providerId: json['providerid'] as int?, + templateTypeId: json['templatetypeid'] as int?, + templateName: json['templatename'] as String?, + templateId: json['templateid'] as int?, + providerApi: json['providerapi'] as String?, + content: json['content'] as String?, + defaultProvider: json['defaultprovider'] as int?, + passkey: json['passkey'] as int?, + ); + } + + Map toJson() { + final Map data = {}; + data['providerid'] = providerId; + data['templatetypeid'] = templateTypeId; + data['templatename'] = templateName; + data['templateid'] = templateId; + data['providerapi'] = providerApi; + data['content'] = content; + data['defaultprovider'] = defaultProvider; + data['passkey'] = passkey; + return data; + } +} \ No newline at end of file diff --git a/lib/Models/Offline/Offline.dart b/lib/Models/Offline/Offline.dart new file mode 100644 index 0000000..14ec0e9 --- /dev/null +++ b/lib/Models/Offline/Offline.dart @@ -0,0 +1,175 @@ +class offline { +int? userid; +String? authname; +int? configid; +int? authmode; +int? roleid; +String? firstname; +String? lastname; +String? fullname; +String? password; +String? email; +String? contactno; +String? address; +String? suburb; +String? city; +String? state; +String? postcode; +String? userfcmtoken; +int? pin; +int? partnerid; +String? identificationno; +String? vehiclename; +String? vehicleno; +String? licenseno; +String? insoranceno; +String? insurancedate; +int? shiftid; +String? starttime; +String? endtime; +int? shifthours; +double? basefare; +double? additionalcharges; +int? orders; +double? fuelcharge; +String? logdate; +int? applocationid; +String? applocation; +int? logseconds; +int? riderid; +String? status; +int? tenantid; + +offline({ +this.userid, +this.authname, +this.configid, +this.authmode, +this.roleid, +this.firstname, +this.lastname, +this.fullname, +this.password, +this.email, +this.contactno, +this.address, +this.suburb, +this.city, +this.state, +this.postcode, +this.userfcmtoken, +this.pin, +this.partnerid, +this.identificationno, +this.vehiclename, +this.vehicleno, +this.licenseno, +this.insoranceno, +this.insurancedate, +this.shiftid, +this.starttime, +this.endtime, +this.shifthours, +this.basefare, +this.additionalcharges, +this.orders, +this.fuelcharge, +this.logdate, +this.applocationid, +this.applocation, +this.logseconds, +this.riderid, +this.status, +this.tenantid, +}); + +factory offline.fromJson(Map json) { +return offline( +userid: json['userid'] as int?, +authname: json['authname'] as String?, +configid: json['configid'] as int?, +authmode: json['authmode'] as int?, +roleid: json['roleid'] as int?, +firstname: json['firstname'] as String?, +lastname: json['lastname'] as String?, +fullname: json['fullname'] as String?, +password: json['password'] as String?, +email: json['email'] as String?, +contactno: json['contactno'] as String?, +address: json['address'] as String?, +suburb: json['suburb'] as String?, +city: json['city'] as String?, +state: json['state'] as String?, +postcode: json['postcode'] as String?, +userfcmtoken: json['userfcmtoken'] as String?, +pin: json['pin'] as int?, +partnerid: json['partnerid'] as int?, +identificationno: json['identificationno'] as String?, +vehiclename: json['vehiclename'] as String?, +vehicleno: json['vehicleno'] as String?, +licenseno: json['licenseno'] as String?, +insoranceno: json['insoranceno'] as String?, +insurancedate: json['insurancedate'] as String?, +shiftid: json['shiftid'] as int?, +starttime: json['starttime'] as String?, +endtime: json['endtime'] as String?, +shifthours: json['shifthours'] as int?, +basefare: (json['basefare'] as num?)?.toDouble(), +additionalcharges: (json['additionalcharges'] as num?)?.toDouble(), +orders: json['orders'] as int?, +fuelcharge: (json['fuelcharge'] as num?)?.toDouble(), +logdate: json['logdate'] as String?, +applocationid: json['applocationid'] as int?, +applocation: json['applocation'] as String?, +logseconds: json['logseconds'] as int?, +riderid: json['riderid'] as int?, +status: json['status'] as String?, +tenantid: json['tenantid'] as int?, +); +} + +Map toJson() { +final Map data = {}; +data['userid'] = userid; +data['authname'] = authname; +data['configid'] = configid; +data['authmode'] = authmode; +data['roleid'] = roleid; +data['firstname'] = firstname; +data['lastname'] = lastname; +data['fullname'] = fullname; +data['password'] = password; +data['email'] = email; +data['contactno'] = contactno; +data['address'] = address; +data['suburb'] = suburb; +data['city'] = city; +data['state'] = state; +data['postcode'] = postcode; +data['userfcmtoken'] = userfcmtoken; +data['pin'] = pin; +data['partnerid'] = partnerid; +data['identificationno'] = identificationno; +data['vehiclename'] = vehiclename; +data['vehicleno'] = vehicleno; +data['licenseno'] = licenseno; +data['insoranceno'] = insoranceno; +data['insurancedate'] = insurancedate; +data['shiftid'] = shiftid; +data['starttime'] = starttime; +data['endtime'] = endtime; +data['shifthours'] = shifthours; +data['basefare'] = basefare; +data['additionalcharges'] = additionalcharges; +data['orders'] = orders; +data['fuelcharge'] = fuelcharge; +data['logdate'] = logdate; +data['applocationid'] = applocationid; +data['applocation'] = applocation; +data['logseconds'] = logseconds; +data['riderid'] = riderid; +data['status'] = status; +data['tenantid'] = tenantid; +return data; +} +} diff --git a/lib/Models/Orders/Orders.dart b/lib/Models/Orders/Orders.dart new file mode 100644 index 0000000..84074ba --- /dev/null +++ b/lib/Models/Orders/Orders.dart @@ -0,0 +1,285 @@ + + +class Orders { + int? deliveryid; + int? orderheaderid; + int? applocationid; + int? configid; + int? partnerid; + int? tenantid; + int? moduleid; + int? locationid; + int? categoryid; + int? userid; + int? subcategoryid; + String? orderid; + String? deliverydate; + String? orderstatus; + String? assigntime; + String? starttime; + String? arrivaltime; + String? pickuptime; + String? deliverytime; + String? canceltime; + int? itemcount; + double? orderamount; + int? customerid; + String? pickupcustomer; + String? pickupcontactno; + String? pickupaddress; + String? pickuplocation; + int? pickuplocationid; + String? pickuplat; + String? pickuplon; + int? deliverycustomerid; + int? deliverylocationid; + String? deliverycustomer; + String? deliverycontactno; + String? deliveryaddress; + String? deliverylocation; + String? droplat; + String? droplon; + String? deliverylat; + String? deliverylong; + double? deliverycharges; + double? deliveryamt; + String? deliverytype; + String? notes; + String? ordernotes; + String? riderslat; + String? riderslon; + int? firstmilekm; + double? firstmilecharges; + double? lastmilecharges; + double? ridercharges; + String? kms; + String? actualkms; + int? paymenttype; + String? tenantname; + String? tenantcontactno; + String? tenanttoken; + String? tenantsuburb; + String? tenantcity; + String? locationname; + String? locationcontactno; + String? locationsuburb; + String? ridername; + String? userfcmtoken; + int? queueid; + int? smsdelivery; + bool startStatus; + + Orders({ + this.deliveryid, + this.orderheaderid, + this.applocationid, + this.configid, + this.partnerid, + this.tenantid, + this.moduleid, + this.locationid, + this.categoryid, + this.userid, + this.subcategoryid, + this.orderid, + this.deliverydate, + this.orderstatus, + this.assigntime, + this.starttime, + this.arrivaltime, + this.pickuptime, + this.deliverytime, + this.canceltime, + this.itemcount, + this.orderamount, + this.customerid, + this.pickupcustomer, + this.pickupcontactno, + this.pickupaddress, + this.pickuplocation, + this.pickuplocationid, + this.pickuplat, + this.pickuplon, + this.deliverycustomerid, + this.deliverylocationid, + this.deliverycustomer, + this.deliverycontactno, + this.deliveryaddress, + this.deliverylocation, + this.droplat, + this.droplon, + this.deliverylat, + this.deliverylong, + this.deliverycharges, + this.deliveryamt, + this.deliverytype, + this.notes, + this.ordernotes, + this.riderslat, + this.riderslon, + this.firstmilekm, + this.firstmilecharges, + this.lastmilecharges, + this.ridercharges, + this.kms, + this.actualkms, + this.paymenttype, + this.tenantname, + this.tenantcontactno, + this.tenanttoken, + this.tenantsuburb, + this.tenantcity, + this.locationname, + this.locationcontactno, + this.locationsuburb, + this.ridername, + this.userfcmtoken, + this.queueid, + this.smsdelivery, + this.startStatus = false, + }); + + factory Orders.fromJson(Map json) { + return Orders( + deliveryid: json['deliveryid'] as int?, + orderheaderid: json['orderheaderid'] as int?, + applocationid: json['applocationid'] as int?, + configid: json['configid'] as int?, + partnerid: json['partnerid'] as int?, + tenantid: json['tenantid'] as int?, + moduleid: json['moduleid'] as int?, + locationid: json['locationid'] as int?, + categoryid: json['categoryid'] as int?, + userid: json['userid'] as int?, + subcategoryid: json['subcategoryid'] as int?, + orderid: json['orderid'] as String?, + deliverydate: json['deliverydate'] as String?, + orderstatus: json['orderstatus'] as String?, + assigntime: json['assigntime'] as String?, + starttime: json['starttime'] as String?, + arrivaltime: json['arrivaltime'] as String?, + pickuptime: json['pickuptime'] as String?, + deliverytime: json['deliverytime'] as String?, + canceltime: json['canceltime'] as String?, + itemcount: json['itemcount'] as int?, + orderamount: (json['orderamount'] as num?)?.toDouble(), + customerid: json['customerid'] as int?, + pickupcustomer: json['pickupcustomer'] as String?, + pickupcontactno: json['pickupcontactno'] as String?, + pickupaddress: json['Pickupaddress'] as String?, + pickuplocation: json['pickuplocation'] as String?, + pickuplocationid: json['pickuplocationid'] as int?, + pickuplat: json['pickuplat'] as String?, + pickuplon: json['pickuplon'] as String?, + deliverycustomerid: json['deliverycustomerid'] as int?, + deliverylocationid: json['deliverylocationid'] as int?, + deliverycustomer: json['deliverycustomer'] as String?, + deliverycontactno: json['deliverycontactno'] as String?, + deliveryaddress: json['deliveryaddress'] as String?, + deliverylocation: json['deliverylocation'] as String?, + droplat: json['droplat'] as String?, + droplon: json['droplon'] as String?, + deliverylat: json['deliverylat'] as String?, + deliverylong: json['deliverylong'] as String?, + deliverycharges: (json['deliverycharges'] as num?)?.toDouble(), + deliveryamt: (json['deliveryamt'] as num?)?.toDouble(), + deliverytype: json['deliverytype'] as String?, + notes: json['notes'] as String?, + ordernotes: json['ordernotes'] as String?, + riderslat: json['riderslat'] as String?, + riderslon: json['riderslon'] as String?, + firstmilekm: json['firstmilekm'] as int?, + firstmilecharges: (json['firstmilecharges'] as num?)?.toDouble(), + lastmilecharges: (json['lastmilecharges'] as num?)?.toDouble(), + ridercharges: (json['ridercharges'] as num?)?.toDouble(), + kms: json['kms'] as String?, + actualkms: json['actualkms'] as String?, + paymenttype: json['paymenttype'] as int?, + tenantname: json['tenantname'] as String?, + tenantcontactno: json['tenantcontactno'] as String?, + tenanttoken: json['tenanttoken'] as String?, + tenantsuburb: json['tenantsuburb'] as String?, + tenantcity: json['tenantcity'] as String?, + locationname: json['locationname'] as String?, + locationcontactno: json['locationcontactno'] as String?, + locationsuburb: json['locationsuburb'] as String?, + ridername: json['ridername'] as String?, + userfcmtoken: json['userfcmtoken'] as String?, + queueid: json['queueid'] as int?, + smsdelivery: json['smsdelivery'] as int?, + startStatus: json['startStatus'] as bool? ?? false, + ); + } + + Map toJson() { + final Map data = {}; + data['deliveryid'] = deliveryid; + data['orderheaderid'] = orderheaderid; + data['applocationid'] = applocationid; + data['configid'] = configid; + data['partnerid'] = partnerid; + data['tenantid'] = tenantid; + data['moduleid'] = moduleid; + data['locationid'] = locationid; + data['categoryid'] = categoryid; + data['userid'] = userid; + data['subcategoryid'] = subcategoryid; + data['orderid'] = orderid; + data['deliverydate'] = deliverydate; + data['orderstatus'] = orderstatus; + data['assigntime'] = assigntime; + data['starttime'] = starttime; + data['arrivaltime'] = arrivaltime; + data['pickuptime'] = pickuptime; + data['deliverytime'] = deliverytime; + data['canceltime'] = canceltime; + data['itemcount'] = itemcount; + data['orderamount'] = orderamount; + data['customerid'] = customerid; + data['pickupcustomer'] = pickupcustomer; + data['pickupcontactno'] = pickupcontactno; + data['Pickupaddress'] = pickupaddress; + data['pickuplocation'] = pickuplocation; + data['pickuplocationid'] = pickuplocationid; + data['pickuplat'] = pickuplat; + data['pickuplon'] = pickuplon; + data['deliverycustomerid'] = deliverycustomerid; + data['deliverylocationid'] = deliverylocationid; + data['deliverycustomer'] = deliverycustomer; + data['deliverycontactno'] = deliverycontactno; + data['deliveryaddress'] = deliveryaddress; + data['deliverylocation'] = deliverylocation; + data['droplat'] = droplat; + data['droplon'] = droplon; + data['deliverylat'] = deliverylat; + data['deliverylong'] = deliverylong; + data['deliverycharges'] = deliverycharges; + data['deliveryamt'] = deliveryamt; + data['deliverytype'] = deliverytype; + data['notes'] = notes; + data['ordernotes'] = ordernotes; + data['riderslat'] = riderslat; + data['riderslon'] = riderslon; + data['firstmilekm'] = firstmilekm; + data['firstmilecharges'] = firstmilecharges; + data['lastmilecharges'] = lastmilecharges; + data['ridercharges'] = ridercharges; + data['kms'] = kms; + data['actualkms'] = actualkms; + data['paymenttype'] = paymenttype; + data['tenantname'] = tenantname; + data['tenantcontactno'] = tenantcontactno; + data['tenanttoken'] = tenanttoken; + data['tenantsuburb'] = tenantsuburb; + data['tenantcity'] = tenantcity; + data['locationname'] = locationname; + data['locationcontactno'] = locationcontactno; + data['locationsuburb'] = locationsuburb; + data['ridername'] = ridername; + data['userfcmtoken'] = userfcmtoken; + data['queueid'] = queueid; + data['smsdelivery'] = smsdelivery; + data['startStatus'] = startStatus; + return data; + } +} \ No newline at end of file diff --git a/lib/Models/ProductDetails/ProductDetails.dart b/lib/Models/ProductDetails/ProductDetails.dart new file mode 100644 index 0000000..d590f90 --- /dev/null +++ b/lib/Models/ProductDetails/ProductDetails.dart @@ -0,0 +1,207 @@ +class ProductDetails { + int? code; + List? products; + String? message; + Pricedetails? pricedetails; + bool? status; + + ProductDetails({ + this.code, + this.products, + this.message, + this.pricedetails, + this.status, + }); + + factory ProductDetails.fromJson(Map json) { + return ProductDetails( + code: json['code'] as int?, + products: json['details'] != null + ? (json['details'] as List) + .map((v) => Product.fromJson(v as Map)) + .toList() + : null, + message: json['message'] as String?, + pricedetails: json['pricedetails'] != null + ? Pricedetails.fromJson(json['pricedetails'] as Map) + : null, + status: json['status'] as bool?, + ); + } + + Map toJson() { + final Map data = {}; + data['code'] = code; + if (products != null) { + data['details'] = products!.map((v) => v.toJson()).toList(); + } + data['message'] = message; + if (pricedetails != null) { + data['pricedetails'] = pricedetails!.toJson(); + } + data['status'] = status; + return data; + } +} + +class Product { + int? orderDetailId; + int? orderHeaderId; + int? tenantId; + int? locationId; + int? productId; + String? productName; + String? productDescription; + int? supplyQty; + int? balanceQty; + int? orderQty; + int? price; + int? unitId; + String? unitName; + int? productAddonId; + int? addonTypeId; + int? productMapId; + int? productVariantId; + String? productAddonDescription; + int? discountId; + String? discountName; + String? discountCode; + String? discountTerms; + int? discountPercentage; + int? discountAmount; + int? landingAmount; + int? taxPercentage; + int? taxAmount; + int? productSumPrice; + String? itemStatus; + String? delivered; + int? orderAmount; + String? productImage; + + Product({ + this.orderDetailId, + this.orderHeaderId, + this.tenantId, + this.locationId, + this.productId, + this.productName, + this.productDescription, + this.supplyQty, + this.balanceQty, + this.orderQty, + this.price, + this.unitId, + this.unitName, + this.productAddonId, + this.addonTypeId, + this.productMapId, + this.productVariantId, + this.productAddonDescription, + this.discountId, + this.discountName, + this.discountCode, + this.discountTerms, + this.discountPercentage, + this.discountAmount, + this.landingAmount, + this.taxPercentage, + this.taxAmount, + this.productSumPrice, + this.itemStatus, + this.delivered, + this.orderAmount, + this.productImage, + }); + + factory Product.fromJson(Map json) { + return Product( + orderDetailId: json['orderdetailid'] as int?, + orderHeaderId: json['orderheaderid'] as int?, + tenantId: json['tenantid'] as int?, + locationId: json['locationid'] as int?, + productId: json['productid'] as int?, + productName: json['productname'] as String?, + productDescription: json['productdescription'] as String?, + supplyQty: json['supplyqty'] as int?, + balanceQty: json['balanceqty'] as int?, + orderQty: json['orderqty'] as int?, + price: json['price'] as int?, + unitId: json['unitid'] as int?, + unitName: json['unitname'] as String?, + productAddonId: json['productaddonid'] as int?, + addonTypeId: json['addontypeid'] as int?, + productMapId: json['productmapid'] as int?, + productVariantId: json['productvariantid'] as int?, + productAddonDescription: json['productaddondescription'] as String?, + discountId: json['discountid'] as int?, + discountName: json['discountname'] as String?, + discountCode: json['discountcode'] as String?, + discountTerms: json['discountterms'] as String?, + discountPercentage: json['discountpercentage'] as int?, + discountAmount: json['discountamount'] as int?, + landingAmount: json['landingamount'] as int?, + taxPercentage: json['taxpercentage'] as int?, + taxAmount: json['taxamount'] as int?, + productSumPrice: json['productsumprice'] as int?, + itemStatus: json['itemstatus'] as String?, + delivered: json['delivered'] as String?, + orderAmount: json['orderamount'] as int?, + productImage: json['productimage'] as String?, + ); + } + + Map toJson() { + final Map data = {}; + data['orderdetailid'] = orderDetailId; + data['orderheaderid'] = orderHeaderId; + data['tenantid'] = tenantId; + data['locationid'] = locationId; + data['productid'] = productId; + data['productname'] = productName; + data['productdescription'] = productDescription; + data['supplyqty'] = supplyQty; + data['balanceqty'] = balanceQty; + data['orderqty'] = orderQty; + data['price'] = price; + data['unitid'] = unitId; + data['unitname'] = unitName; + data['productaddonid'] = productAddonId; + data['addontypeid'] = addonTypeId; + data['productmapid'] = productMapId; + data['productvariantid'] = productVariantId; + data['productaddondescription'] = productAddonDescription; + data['discountid'] = discountId; + data['discountname'] = discountName; + data['discountcode'] = discountCode; + data['discountterms'] = discountTerms; + data['discountpercentage'] = discountPercentage; + data['discountamount'] = discountAmount; + data['landingamount'] = landingAmount; + data['taxpercentage'] = taxPercentage; + data['taxamount'] = taxAmount; + data['productsumprice'] = productSumPrice; + data['itemstatus'] = itemStatus; + data['delivered'] = delivered; + data['orderamount'] = orderAmount; + data['productimage'] = productImage; + return data; + } +} + +class Pricedetails { + int? orderAmount; + + Pricedetails({this.orderAmount}); + + factory Pricedetails.fromJson(Map json) { + return Pricedetails( + orderAmount: json['orderamount'] as int?, + ); + } + + Map toJson() { + final Map data = {}; + data['orderamount'] = orderAmount; + return data; + } +} \ No newline at end of file diff --git a/lib/Models/TenantPrice/Tenant.dart b/lib/Models/TenantPrice/Tenant.dart new file mode 100644 index 0000000..0f6bcee --- /dev/null +++ b/lib/Models/TenantPrice/Tenant.dart @@ -0,0 +1,78 @@ +class Tenant { + int? code; + TenantDetails? details; + String? message; + bool? status; + + Tenant({this.code, this.details, this.message, this.status}); + + factory Tenant.fromJson(Map json) { + return Tenant( + code: json['code'] as int?, + details: json['details'] != null + ? TenantDetails.fromJson(json['details'] as Map) + : null, + message: json['message'] as String?, + status: json['status'] as bool?, + ); + } + + Map toJson() { + final Map data = {}; + data['code'] = code; + if (details != null) { + data['details'] = details!.toJson(); + } + data['message'] = message; + data['status'] = status; + return data; + } +} + +class TenantDetails { + int? pricingId; + int? tenantId; + int? locationId; + String? pricingDate; + int? basePrice; + int? pricePerKm; + int? minKm; + int? otherCharges; + + TenantDetails({ + this.pricingId, + this.tenantId, + this.locationId, + this.pricingDate, + this.basePrice, + this.pricePerKm, + this.minKm, + this.otherCharges, + }); + + factory TenantDetails.fromJson(Map json) { + return TenantDetails( + pricingId: json['pricingid'] as int?, + tenantId: json['tenantid'] as int?, + locationId: json['locationid'] as int?, + pricingDate: json['pricingdate'] as String?, + basePrice: json['baseprice'] as int?, + pricePerKm: json['priceperkm'] as int?, + minKm: json['minkm'] as int?, + otherCharges: json['othercharges'] as int?, + ); + } + + Map toJson() { + final Map data = {}; + data['pricingid'] = pricingId; + data['tenantid'] = tenantId; + data['locationid'] = locationId; + data['pricingdate'] = pricingDate; + data['baseprice'] = basePrice; + data['priceperkm'] = pricePerKm; + data['minkm'] = minKm; + data['othercharges'] = otherCharges; + return data; + } +} \ No newline at end of file diff --git a/lib/Models/User/User.dart b/lib/Models/User/User.dart new file mode 100644 index 0000000..4f9c944 --- /dev/null +++ b/lib/Models/User/User.dart @@ -0,0 +1,131 @@ + +class User { +int? userid; +String? authname; +int? configid; +int? authmode; +int? roleid; +String? firstname; +String? lastname; +String? password; +String? email; +String? contactno; +String? address; +String? suburb; +String? city; +String? state; +String? postcode; +String? userfcmtoken; +int? pin; +int? partnerid; +int? tenantid; +String? fullname; +String? tenantname; +String? tenantaddress; +String? tenantcity; +String? tenantpostcode; +String? tenantlat; +String? tenantlong; +int? locationid; +String? locationname; +int? applocationid; + +User({ + this.userid, + this.authname, + this.configid, + this.authmode, + this.roleid, + this.firstname, + this.lastname, + this.password, + this.email, + this.contactno, + this.address, + this.suburb, + this.city, + this.state, + this.postcode, + this.userfcmtoken, + this.pin, + this.partnerid, + this.tenantid, + this.fullname, + this.tenantname, + this.tenantaddress, + this.tenantcity, + this.tenantpostcode, + this.tenantlat, + this.tenantlong, + this.locationid, + this.locationname, + this.applocationid, +}); + +factory User.fromJson(Map json) { + return User( +userid: json['userid'] as int?, +authname: json['authname'] as String?, +configid: json['configid'] as int?, +authmode: json['authmode'] as int?, +roleid: json['roleid'] as int?, +firstname: json['firstname'] as String?, +lastname: json['lastname'] as String?, +password: json['password'] as String?, +email: json['email'] as String?, +contactno: json['contactno'] as String?, +address: json['address'] as String?, +suburb: json['suburb'] as String?, +city: json['city'] as String?, +state: json['state'] as String?, +postcode: json['postcode'] as String?, +userfcmtoken: json['userfcmtoken'] as String?, +pin: json['pin'] as int?, +partnerid: json['partnerid'] as int?, +tenantid: json['tenantid'] as int?, +fullname: json['fullname'] as String?, +tenantname: json['tenantname'] as String?, +tenantaddress: json['tenantaddress'] as String?, +tenantcity: json['tenantcity'] as String?, +tenantpostcode: json['tenantpostcode'] as String?, +tenantlat: json['tenantlat'] as String?, +tenantlong: json['tenantlong'] as String?, +locationid: json['locationid'] as int?, +locationname: json['locationname'] as String?, +applocationid: json['applocationid'] as int?, +); +} + +Map toJson() => { +'userid': userid, +'authname': authname, +'configid': configid, +'authmode': authmode, +'roleid': roleid, +'firstname': firstname, +'lastname': lastname, +'password': password, +'email': email, +'contactno': contactno, +'address': address, +'suburb': suburb, +'city': city, +'state': state, +'postcode': postcode, +'userfcmtoken': userfcmtoken, +'pin': pin, +'partnerid': partnerid, +'tenantid': tenantid, +'fullname': fullname, +'tenantname': tenantname, +'tenantaddress': tenantaddress, +'tenantcity': tenantcity, +'tenantpostcode': tenantpostcode, +'tenantlat': tenantlat, +'tenantlong': tenantlong, +'locationid': locationid, +'locationname': locationname, +'applocationid': applocationid, +}; +} + diff --git a/lib/Models/deliveries/deliveries_models.dart b/lib/Models/deliveries/deliveries_models.dart new file mode 100644 index 0000000..e378419 --- /dev/null +++ b/lib/Models/deliveries/deliveries_models.dart @@ -0,0 +1,315 @@ +// Consolidated deliveries models + +class DeliveryUpdate { + int? deliveryid; + int? orderheaderid; + int? deliverylocationid; + int? pickuplocationid; + int? smsdelivery; + String? orderstatus; + String? starttime; + String? arrivaltime; + String? pickuptime; + String? deliverytime; + String? canceltime; + String? riderslat; + String? riderslon; + String? pickuplat; + String? pickuplong; + String? deliverylat; + String? deliverylong; + String? address; + String? city; + String? state; + String? suburb; + String? postcode; + String? kms; + String? riderkms; + String? actualkms; + double? deliveryamt; + String? notes; + String? deliverytype; + String? kmcal; + String? feedback; + + DeliveryUpdate({ + this.deliveryid, + this.orderheaderid, + this.deliverylocationid, + this.pickuplocationid, + this.smsdelivery, + this.orderstatus, + this.starttime, + this.arrivaltime, + this.pickuptime, + this.deliverytime, + this.canceltime, + this.riderslat, + this.riderslon, + this.pickuplat, + this.pickuplong, + this.deliverylat, + this.deliverylong, + this.address, + this.city, + this.state, + this.suburb, + this.postcode, + this.kms, + this.riderkms, + this.actualkms, + this.deliveryamt, + this.notes, + this.deliverytype, + this.kmcal, + this.feedback, + }); + + factory DeliveryUpdate.fromJson(Map json) { + return DeliveryUpdate( + deliveryid: json['deliveryid'], + orderheaderid: json['orderheaderid'], + deliverylocationid: json['deliverylocationid'], + pickuplocationid: json['pickuplocationid'], + smsdelivery: json['smsdelivery'], + orderstatus: json['orderstatus'], + starttime: json['starttime'], + arrivaltime: json['arrivaltime'], + pickuptime: json['pickuptime'], + deliverytime: json['deliverytime'], + canceltime: json['canceltime'], + riderslat: json['riderslat'], + riderslon: json['riderslon'], + pickuplat: json['pickuplat'], + pickuplong: json['pickuplong'], + deliverylat: json['deliverylat'], + deliverylong: json['deliverylong'], + address: json['address'], + city: json['city'], + state: json['state'], + suburb: json['suburb'], + postcode: json['postcode'], + kms: json['kms'], + riderkms: json['riderkms'], + actualkms: json['actualkms'], + deliveryamt: (json['deliveryamt'] as num?)?.toDouble(), + notes: json['notes'], + deliverytype: json['deliverytype'], + kmcal: json['kmcal'], + feedback: json['feedback'], + ); + } + + Map toJson() { + final Map data = {}; + data['deliveryid'] = deliveryid; + data['orderheaderid'] = orderheaderid; + data['deliverylocationid'] = deliverylocationid; + data['pickuplocationid'] = pickuplocationid; + data['smsdelivery'] = smsdelivery; + data['orderstatus'] = orderstatus; + data['starttime'] = starttime; + data['arrivaltime'] = arrivaltime; + data['pickuptime'] = pickuptime; + data['deliverytime'] = deliverytime; + data['canceltime'] = canceltime; + data['riderslat'] = riderslat; + data['riderslon'] = riderslon; + data['pickuplat'] = pickuplat; + data['pickuplong'] = pickuplong; + data['deliverylat'] = deliverylat; + data['deliverylong'] = deliverylong; + data['address'] = address; + data['city'] = city; + data['state'] = state; + data['suburb'] = suburb; + data['postcode'] = postcode; + data['kms'] = kms; + data['riderkms'] = riderkms; + data['actualkms'] = actualkms; + data['deliveryamt'] = deliveryamt; + data['notes'] = notes; + data['deliverytype'] = deliverytype; + data['kmcal'] = kmcal; + data['feedback'] = feedback; + return data; + } +} + +class DeliveryLogModel { + int? logid; + int? tenantid; + int? partnerid; + int? locationid; + int? orderheaderid; + int? deliveryid; + int? userid; + String? orderid; + String? logdate; + String? orderstatus; + String? latitude; + String? longitude; + + DeliveryLogModel({ + this.logid, + this.tenantid, + this.partnerid, + this.locationid, + this.orderheaderid, + this.deliveryid, + this.userid, + this.orderid, + this.logdate, + this.orderstatus, + this.latitude, + this.longitude, + }); + + factory DeliveryLogModel.fromJson(Map json) { + return DeliveryLogModel( + logid: json['logid'], + tenantid: json['tenantid'], + partnerid: json['Partnerid'] ?? json['partnerid'], + locationid: json['Locationid'] ?? json['locationid'], + orderheaderid: json['orderheaderid'], + deliveryid: json['deliveryid'], + userid: json['userid'], + orderid: json['orderid'], + logdate: json['logdate'], + orderstatus: json['orderstatus'], + latitude: json['latitude'], + longitude: json['longitude'], + ); + } + + Map toJson() { + final Map data = {}; + data['logid'] = logid; + data['tenantid'] = tenantid; + data['Partnerid'] = partnerid; + data['Locationid'] = locationid; + data['orderheaderid'] = orderheaderid; + data['deliveryid'] = deliveryid; + data['userid'] = userid; + data['orderid'] = orderid; + data['logdate'] = logdate; + data['orderstatus'] = orderstatus; + data['latitude'] = latitude; + data['longitude'] = longitude; + return data; + } +} + +class DeliverySummaryModel { + int? total; + int? created; + int? pending; + int? accepted; + int? picked; + int? delivered; + int? cancelled; + + DeliverySummaryModel({ + this.total, + this.created, + this.pending, + this.accepted, + this.picked, + this.delivered, + this.cancelled, + }); + + factory DeliverySummaryModel.fromJson(Map json) { + return DeliverySummaryModel( + total: json['total'], + created: json['created'], + pending: json['pending'], + accepted: json['accepted'], + picked: json['picked'], + delivered: json['delivered'], + cancelled: json['cancelled'], + ); + } + + Map toJson() { + final Map data = {}; + data['total'] = total; + data['created'] = created; + data['pending'] = pending; + data['accepted'] = accepted; + data['picked'] = picked; + data['delivered'] = delivered; + data['cancelled'] = cancelled; + return data; + } +} + +class DeliveryItem { + int? logid; + String? logdate; + int? tenantid; + int? locationid; + int? orderheaderid; + int? deliveryid; + int? userid; + int? partnerid; + String? orderid; + String? orderstatus; + String? latitude; + String? longitude; + int? logstatus; + + DeliveryItem({ + this.logid, + this.logdate, + this.tenantid, + this.locationid, + this.orderheaderid, + this.deliveryid, + this.userid, + this.partnerid, + this.orderid, + this.orderstatus, + this.latitude, + this.longitude, + this.logstatus, + }); + + factory DeliveryItem.fromJson(Map json) { + return DeliveryItem( + logid: json['logid'], + logdate: json['logdate'], + tenantid: json['tenantid'], + locationid: json['locationid'], + orderheaderid: json['orderheaderid'], + deliveryid: json['deliveryid'], + userid: json['userid'], + partnerid: json['partnerid'], + orderid: json['orderid'], + orderstatus: json['orderstatus'], + latitude: json['latitude'], + longitude: json['longitude'], + logstatus: json['logstatus'], + ); + } + + Map toJson() { + final Map data = {}; + data['logid'] = logid; + data['logdate'] = logdate; + data['tenantid'] = tenantid; + data['locationid'] = locationid; + data['orderheaderid'] = orderheaderid; + data['deliveryid'] = deliveryid; + data['userid'] = userid; + data['partnerid'] = partnerid; + data['orderid'] = orderid; + data['orderstatus'] = orderstatus; + data['latitude'] = latitude; + data['longitude'] = longitude; + data['logstatus'] = logstatus; + return data; + } +} + + diff --git a/lib/Models/login/login.dart b/lib/Models/login/login.dart new file mode 100644 index 0000000..88501b7 --- /dev/null +++ b/lib/Models/login/login.dart @@ -0,0 +1,136 @@ +class Login { + int? code; + String? message; + bool? status; + + int? userid; + String? contactno; + String? devicetype; + int? configid; + String? deviceid; + String? userfcmtoken; + String? authname; + int? authmode; + int? roleid; + String? firstname; + String? lastname; + String? fullname; + String? password; + String? email; + String? address; + String? suburb; + String? city; + String? state; + String? postcode; + int? pin; + int? shiftid; + int? logid; + int? partnerid; + int? tenantid; + int? riderid; + int? deliveryradius; + int? locationid; + int? applocationid; + + Login({ + this.code, + this.message, + this.status, + this.userid, + this.contactno, + this.devicetype, + this.configid, + this.deviceid, + this.userfcmtoken, + this.authname, + this.authmode, + this.roleid, + this.firstname, + this.lastname, + this.fullname, + this.password, + this.email, + this.address, + this.suburb, + this.city, + this.state, + this.postcode, + this.pin, + this.shiftid, + this.logid, + this.partnerid, + this.tenantid, + this.riderid, + this.deliveryradius, + this.locationid, + this.applocationid, + }); + + /// Factory: builds a Login model from JSON (supports both "data" and "details") + factory Login.fromJson(Map json) { + // Safely extract nested data from either "data" or "details" + final Map nested = + (json['details'] ?? json['data'] ?? {}); + + final dynamic topAuthMode = json['authmode']; + final dynamic nestedAuthMode = nested['authmode']; + + return Login( + code: json['code'] is int + ? json['code'] + : int.tryParse('${json['code']}'), + message: json['message']?.toString(), + status: json['status'] is bool ? json['status'] : json['status'] == 1, + userid: json['userid'] ?? nested['userid'], + contactno: + json['contactno']?.toString() ?? nested['contactno']?.toString(), + devicetype: json['devicetype']?.toString(), + configid: json['configid'] ?? nested['configid'], + deviceid: json['deviceid']?.toString(), + userfcmtoken: + json['userfcmtoken']?.toString() ?? nested['userfcmtoken']?.toString(), + authname: (json['authname'] ?? nested['authname'])?.toString(), + authmode: topAuthMode is int + ? topAuthMode + : (nestedAuthMode is int + ? nestedAuthMode + : int.tryParse('$topAuthMode')), + roleid: nested['roleid'], + firstname: nested['firstname']?.toString(), + lastname: nested['lastname']?.toString(), + fullname: nested['fullname']?.toString(), + password: nested['password']?.toString(), + email: nested['email']?.toString(), + address: nested['address']?.toString(), + suburb: nested['suburb']?.toString(), + city: nested['city']?.toString(), + state: nested['state']?.toString(), + postcode: nested['postcode']?.toString(), + pin: nested['pin'], + shiftid: nested['shiftid'], + logid: nested['logid'], + partnerid: nested['partnerid'], + tenantid: nested['tenantid'], + riderid: nested['riderid'], + deliveryradius: nested['deliveryradius'], + locationid: nested['locationid'] is int + ? nested['locationid'] + : int.tryParse('${nested['locationid'] ?? 0}'), + applocationid: nested['applocationid'] is int + ? nested['applocationid'] + : int.tryParse('${nested['applocationid'] ?? 0}'), + ); + } + + /// Convert this model to JSON for sending to the backend + Map toJson() { + final Map data = {}; + if (contactno != null) data['contactno'] = contactno; + if (devicetype != null) data['devicetype'] = devicetype; + if (configid != null) data['configid'] = configid; + if (deviceid != null) data['deviceid'] = deviceid; + if (userfcmtoken != null) data['userfcmtoken'] = userfcmtoken; + if (pin != null) data['pin'] = pin; + return data; + } +} diff --git a/lib/Models/notification/notification_models.dart b/lib/Models/notification/notification_models.dart new file mode 100644 index 0000000..8c923f7 --- /dev/null +++ b/lib/Models/notification/notification_models.dart @@ -0,0 +1,139 @@ +// Consolidated notification models + +class AdminNotification { + String? priority; + List? registrationIds; + String? accessid; + String? title; + String? body; + String? sound; + + AdminNotification({ + this.priority, + this.registrationIds, + this.accessid, + this.title, + this.body, + this.sound, + }); + + factory AdminNotification.fromJson(Map json) { + return AdminNotification( + priority: json['priority'] as String?, + registrationIds: json['registration_ids']?.cast(), + accessid: json['data']?['accessid'] as String?, + title: json['notification']?['title'] as String?, + body: json['notification']?['body'] as String?, + sound: json['notification']?['sound'] as String?, + ); + } + + Map toJson() { + final Map data = {}; + data['priority'] = priority; + data['registration_ids'] = registrationIds; + if (accessid != null) { + data['data'] = {'accessid': accessid}; + } + if (title != null || body != null || sound != null) { + data['notification'] = { + if (title != null) 'title': title, + if (body != null) 'body': body, + if (sound != null) 'sound': sound, + }; + } + return data; + } +} + +class NotificationMessage { + String? sender; + String? accessid; + String? priority; + String? to; + String? title; + String? body; + String? sound; + + NotificationMessage({ + this.sender, + this.accessid, + this.priority, + this.to, + this.title, + this.body, + this.sound, + }); + + factory NotificationMessage.fromJson(Map json) { + return NotificationMessage( + sender: json['sender'] as String?, + accessid: json['accessid'] as String?, + priority: json['notification']?['priority'] as String?, + to: json['notification']?['to'] as String?, + title: json['notification']?['notification']?['title'] as String?, + body: json['notification']?['notification']?['body'] as String?, + sound: json['notification']?['notification']?['sound'] as String?, + ); + } + + Map toJson() { + final Map data = {}; + data['sender'] = sender; + data['accessid'] = accessid; + if (priority != null || to != null || title != null || body != null || sound != null) { + data['notification'] = { + if (priority != null) 'priority': priority, + if (to != null) 'to': to, + if (title != null || body != null || sound != null) 'notification': { + if (title != null) 'title': title, + if (body != null) 'body': body, + if (sound != null) 'sound': sound, + }, + }; + } + return data; + } +} + +class RiderNotification { + String? token; + String? title; + String? body; + String? sound; + String? image; + + RiderNotification({ + this.token, + this.title, + this.body, + this.sound, + this.image, + }); + + factory RiderNotification.fromJson(Map json) { + return RiderNotification( + token: json['token'] as String?, + title: json['notification']?['title'] as String?, + body: json['notification']?['body'] as String?, + sound: json['notification']?['sound'] as String?, + image: json['notification']?['image'] as String?, + ); + } + + Map toJson() { + final Map data = {}; + data['token'] = token; + if (title != null || body != null || sound != null || image != null) { + data['notification'] = { + if (title != null) 'title': title, + if (body != null) 'body': body, + if (sound != null) 'sound': sound, + if (image != null) 'image': image, + }; + } + return data; + } +} + + diff --git a/lib/Models/riders/riders_models.dart b/lib/Models/riders/riders_models.dart new file mode 100644 index 0000000..4a58ce3 --- /dev/null +++ b/lib/Models/riders/riders_models.dart @@ -0,0 +1,289 @@ +// Consolidated riders models + +class RiderLog { + int? logid; + String? logdate; + int? userid; + int? partnerid; + int? shiftid; + double? shifthours; + String? login; + String? latitude; + double? workhours; + double? shorthours; + int? logstatus; + String? longitude; + double? breakhours; + + int? onduty; + int? tenantid; + int? locationid; + int? applocationid; + String? userfcmtoken; + + RiderLog({ + this.logid, + this.logdate, + this.userid, + this.partnerid, + this.shiftid, + this.shifthours, + this.login, + this.latitude, + this.workhours, + this.shorthours, + this.logstatus, + this.longitude, + this.breakhours, + this.onduty, + this.tenantid, + this.locationid, + this.applocationid, + this.userfcmtoken, + }); + + factory RiderLog.fromJson(Map json) { + return RiderLog( + logid: json['logid'], + logdate: json['logdate'], + userid: json['userid'], + partnerid: json['partnerid'], + shiftid: json['shiftid'], + shifthours: (json['shifthours'] as num?)?.toDouble(), + login: json['login'], + latitude: json['latitude'], + workhours: (json['workhours'] as num?)?.toDouble(), + shorthours: (json['shorthours'] as num?)?.toDouble(), + logstatus: json['logstatus'], + longitude: json['longitude'], + breakhours: (json['breakhours'] as num?)?.toDouble(), + onduty: json['onduty'], + tenantid: json['tenantid'], + locationid: json['locationid'], + applocationid: json['applocationid'], + userfcmtoken: json['userfcmtoken'], + ); + } + + Map toJson() { + final Map data = {}; + data['logid'] = logid; + data['logdate'] = logdate; + data['userid'] = userid; + data['partnerid'] = partnerid; + data['shiftid'] = shiftid; + data['shifthours'] = shifthours; + data['login'] = login; + data['latitude'] = latitude; + data['workhours'] = workhours; + data['shorthours'] = shorthours; + data['logstatus'] = logstatus; + data['longitude'] = longitude; + data['breakhours'] = breakhours; + data['onduty'] = onduty; + data['tenantid'] = tenantid; + data['locationid'] = locationid; + data['applocationid'] = applocationid; + data['userfcmtoken'] = userfcmtoken; + return data; + } +} + +class RiderBreak { + int? breakid; + int? logid; + String? breakdate; + int? userid; + int? partnerid; + int? shiftid; + String? breakstart; + String? breakend; + double? breakhours; + String? latitude; + String? longitude; + + RiderBreak({ + this.breakid, + this.logid, + this.breakdate, + this.userid, + this.partnerid, + this.shiftid, + this.breakstart, + this.breakend, + this.breakhours, + this.latitude, + this.longitude, + }); + + factory RiderBreak.fromJson(Map json) { + return RiderBreak( + breakid: json['breakid'], + logid: json['logid'], + breakdate: json['breakdate'], + userid: json['userid'], + partnerid: json['partnerid'], + shiftid: json['shiftid'], + breakstart: json['breakstart'], + breakend: json['breakend'], + breakhours: (json['breakhours'] as num?)?.toDouble(), + latitude: json['latitude'], + longitude: json['longitude'], + ); + } + + Map toJson() { + final Map data = {}; + data['breakid'] = breakid; + data['logid'] = logid; + data['breakdate'] = breakdate; + data['userid'] = userid; + data['partnerid'] = partnerid; + data['shiftid'] = shiftid; + data['breakstart'] = breakstart; + data['breakend'] = breakend; + data['breakhours'] = breakhours; + data['latitude'] = latitude; + data['longitude'] = longitude; + return data; + } +} + +class RiderLogin { + int? logid; + int? userid; + int? partnerid; + int? shiftid; + String? logdate; + String? login; + double? shifthours; + String? latitude; + String? longitude; + int? onduty; + String? status; // "active" when there are active deliveries, "idle" otherwise + String? username; // Rider display name for rider logs + String? contactno; + int? tenantid; + int? locationid; + int? applocationid; + String? userfcmtoken; + + RiderLogin({ + this.logid, + this.userid, + this.partnerid, + this.shiftid, + this.logdate, + this.login, + this.shifthours, + this.latitude, + this.longitude, + this.onduty, + this.status, + this.username, + + this.contactno, + this.tenantid, + this.locationid, + this.applocationid, + this.userfcmtoken, + }); + + factory RiderLogin.fromJson(Map json) { + return RiderLogin( + logid: json['logid'], + userid: json['userid'], + partnerid: json['partnerid'], + shiftid: json['shiftid'], + logdate: json['logdate'], + login: json['Login'] ?? json['login'], + shifthours: (json['shifthours'] as num?)?.toDouble(), + latitude: json['latitude'], + longitude: json['longitude'], + onduty: json['onduty'], + status: json['status'], + username: json['username'], + contactno: json['contactno'], + tenantid: json['tenantid'], + locationid: json['locationid'], + applocationid: json['applocationid'], + userfcmtoken: json['userfcmtoken'], + ); + } + + Map toJson() { + final Map data = {}; + data['logid'] = logid; + data['userid'] = userid; + data['partnerid'] = partnerid; + data['shiftid'] = shiftid; + data['logdate'] = logdate; + data['Login'] = login; + data['shifthours'] = shifthours; + data['latitude'] = latitude; + data['longitude'] = longitude; + if (onduty != null) data['onduty'] = onduty; + if (status != null) data['status'] = status; + if (username != null && username!.trim().isNotEmpty) { + data['username'] = username; + } + if (contactno != null) { + data['contactno'] = contactno; + } + data['tenantid'] = tenantid; + data['locationid'] = locationid; + data['applocationid'] = applocationid; + data['userfcmtoken'] = userfcmtoken; + return data; + } +} + +class RiderUpdate { + int? userid; + int? logstatus; + String? logout; + double? workhours; + double? shorthours; + String? latitude; + String? longitude; + int? onduty; + + RiderUpdate({ + this.userid, + this.logstatus, + this.logout, + this.workhours, + this.shorthours, + this.latitude, + this.longitude, + this.onduty, + }); + + factory RiderUpdate.fromJson(Map json) { + return RiderUpdate( + userid: json['userid'], + logstatus: json['logstatus'], + logout: json['Logout'] ?? json['logout'], + workhours: (json['workhours'] as num?)?.toDouble(), + shorthours: (json['shorthours'] as num?)?.toDouble(), + latitude: json['latitude'], + longitude: json['longitude'], + onduty: json['onduty'], + ); + } + + Map toJson() { + final Map data = {}; + data['userid'] = userid; + data['logstatus'] = logstatus; + data['Logout'] = logout; + data['workhours'] = workhours; + data['shorthours'] = shorthours; + data['latitude'] = latitude; + data['longitude'] = longitude; + if (onduty != null) data['onduty'] = onduty; + return data; + } +} + + diff --git a/lib/Models/summary/deliverystats.dart b/lib/Models/summary/deliverystats.dart new file mode 100644 index 0000000..5a86fd4 --- /dev/null +++ b/lib/Models/summary/deliverystats.dart @@ -0,0 +1,27 @@ +class DeliveryStats { + final int today; + final int week; + final int month; + final int total; + final int cancelled; + + DeliveryStats({ + required this.today, + required this.week, + required this.month, + required this.total, + required this.cancelled, + }); + + factory DeliveryStats.fromJson(Map json) { + return DeliveryStats( + today: json['today'] ?? 0, + week: json['week'] ?? 0, + month: json['month'] ?? 0, + total: json['total'] ?? 0, + cancelled: json['cancelled'] ?? 0, + ); + } +} + + diff --git a/lib/Models/summary/riderweeklykms.dart b/lib/Models/summary/riderweeklykms.dart new file mode 100644 index 0000000..62261b3 --- /dev/null +++ b/lib/Models/summary/riderweeklykms.dart @@ -0,0 +1,49 @@ +class RiderWeeklyKms { + final String day; + final double kms; + + RiderWeeklyKms({ + required this.day, + required this.kms, + }); + + factory RiderWeeklyKms.fromJson(Map json) { + return RiderWeeklyKms( + day: json['day'] ?? '', + kms: double.tryParse('${json['kms'] ?? 0}') ?? 0.0, + ); + } +} + +class RiderWeeklyKmsResponse { + final bool status; + final int code; + final String message; + final double totalKms; + final double overallKms; + final List details; + + RiderWeeklyKmsResponse({ + required this.status, + required this.code, + required this.message, + required this.totalKms, + required this.overallKms, + required this.details, + }); + + factory RiderWeeklyKmsResponse.fromJson(Map json) { + return RiderWeeklyKmsResponse( + status: json['status'] ?? false, + code: json['code'] ?? 0, + message: json['message'] ?? '', + totalKms: double.tryParse('${json['total_kms'] ?? 0}') ?? 0.0, + overallKms: double.tryParse('${json['overall_kms'] ?? 0}') ?? 0.0, + details: (json['details'] as List? ?? []) + .map((e) => RiderWeeklyKms.fromJson(e)) + .toList(), + ); + } +} + + diff --git a/lib/Models/supportticket/support_ticket.dart b/lib/Models/supportticket/support_ticket.dart new file mode 100644 index 0000000..b5745e5 --- /dev/null +++ b/lib/Models/supportticket/support_ticket.dart @@ -0,0 +1,37 @@ +class SupportTicketModel { + final int ridersupportid; + final int userid; + final String category; + final String priority; + final String subject; + final String issue; + final String? image; + final DateTime created; + final DateTime updated; + + SupportTicketModel({ + required this.ridersupportid, + required this.userid, + required this.category, + required this.priority, + required this.subject, + required this.issue, + this.image, + required this.created, + required this.updated, + }); + + factory SupportTicketModel.fromJson(Map json) { + return SupportTicketModel( + ridersupportid: json['ridersupportid'] as int, + userid: json['userid'] as int, + category: json['category'] as String, + priority: json['priority'] as String, + subject: json['subject'] as String, + issue: json['issue'] as String, + image: json['image'] as String?, + created: DateTime.parse(json['created'] as String), + updated: DateTime.parse(json['updated'] as String), + ); + } +} \ No newline at end of file diff --git a/lib/background/backgroundservice.dart b/lib/background/backgroundservice.dart new file mode 100644 index 0000000..50b68b4 --- /dev/null +++ b/lib/background/backgroundservice.dart @@ -0,0 +1,969 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'dart:math' as math; + +import 'package:nearle/providers/deliverylog/deliverylog_provider.dart'; +import 'package:nearle/providers/notifications/notificationservce.dart'; +import 'package:nearle/views/helpers/constants/apiconstants.dart'; +import 'package:nearle/utils/kalman_filter.dart'; + +/// Background service for managing active delivery logs +class BackgroundDeliveryLog { + static NearleKalmanFilter? _kf; + static DateTime? _lastUpdateTime; + + static final CreateDeliveryLogProvider _logProvider = + CreateDeliveryLogProvider(); + static final http.Client _httpClient = http.Client(); + static final FlutterTts _tts = FlutterTts(); + static final AudioPlayer _proximityPlayer = AudioPlayer(); + + static const double _idleThresholdMeters = 10; // ~5-10m tolerance + static const double _proximityThresholdMeters = 50.0; // 50 meters for arrival alert + static const String _activeDeliveriesKey = 'active_deliveries'; + static const String _payloadKeyPrefix = 'delivery_payload_'; + static const String _offlineLogKey = 'offline_delivery_logs'; + static const String _proximityAlertKeyPrefix = 'delivery_proximity_alerted_'; + + static bool _isPosting = false; + static bool _ttsReady = false; + static bool _audioReady = false; + + /// Process active deliveries: fetch from API, filter active, and post logs + static Future processActiveDeliveries() async { + if (_isPosting) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Already posting, skipping...'); + return; + } + _isPosting = true; + + try { + // Get userid from SharedPreferences + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Checking for user: $userId'); + + if (userId == 0) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] ❌ No user ID found in prefs'); + return; + } + + // Get current date dynamically (YYYY-MM-DD format) + final now = DateTime.now(); + final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}'; + + // Build API URL using v1 endpoint (getdeliveries) + final bool isLive = ApiConstants.mainRoute == 'live'; + final baseUrl = isLive + ? ApiConstants.currentDeliveryLive + : ApiConstants.currentDeliveryDev; + + final uri = Uri.parse(baseUrl).replace( + queryParameters: { + 'userid': userId.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Fetching deliveries: $uri'); + + // Fetch deliveries from API using v1 endpoint + final deliveries = await _fetchDeliveriesFromApi(uri); + + debugPrint('[ACTIVE_DELIVERY_LOG][BG] API returned ${deliveries.length} items'); + + // Filter for active orders only + final activeOrders = deliveries.whereType>().where(( + order, + ) { + final status = (order['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isActive = status == 'active'; + return isActive; + }).toList(); + + if (activeOrders.isEmpty) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] No active orders found'); + // ✅ Still check shift end even if no active deliveries + debugPrint('[ACTIVE_DELIVERY_LOG][BG] 🔍 Checking shift end time...'); + await checkShiftEnd(); + return; + } + + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Found ${activeOrders.length} active deliveries', + ); + + // Post logs for each active delivery + for (final order in activeOrders) { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) continue; + + // Get current coordinates once per order to reuse for proximity + log + final Map? coords = await _getCoordinatesWithFallback(); + if (coords != null) { + await _checkProximityAlert(order, coords); + } + + // Get payload from SharedPreferences or create new one + Map? payload = await _loadPayload(orderId); + if (payload == null) { + // Create new payload from order data with userId from SharedPreferences + payload = _createPayload(order, userId); + await _persistPayload(orderId, payload); + await _markAsActive(orderId); + } else { + // Ensure userid is set correctly in existing payload + payload['userid'] = userId; + } + + // Post the log + await _postDeliveryLog(orderId, payload, currentCoords: coords); + } + + // ✅ CRITICAL: Check if shift end time has passed (backup to alarm) + await checkShiftEnd(); + + // Attempt to flush offline logs + await _flushOfflineLogs(); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error processing: $e'); + } finally { + _isPosting = false; + } + } + + /// Post delivery log for a specific order + static Future _postDeliveryLog( + String orderId, + Map payload, { + Map? currentCoords, + }) async { + Map? payloadWithCoords; + try { + // Get current coordinates + final coords = currentCoords ?? await _getCoordinatesWithFallback(); + if (coords == null) { + return; + } + + // Distance calculation is now fully handled by LiveTrackingService in the background. + // 03-14: Removed redundant 60-second background calculations to avoid logic conflicts. + final deliveryId = payload['deliveryid']?.toString() ?? ''; + + + // Create log date timestamp + final now = DateTime.now(); + final logDate = + '${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; + + // Retrieve final cumulative KM to send + double riderKmsToSend = 0.0; + if (deliveryId.isNotEmpty && deliveryId != '0') { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); // Force reload so we get the latest value written by the main isolate + riderKmsToSend = double.tryParse(prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? '0') ?? 0.0; + } catch (_) {} + } + + // Build payload with coordinates and timestamp + payloadWithCoords = { + ...payload, + 'logdate': logDate, + 'latitude': coords['lat'] ?? '0', + 'longitude': coords['lng'] ?? '0', + 'raw_latitude': coords['raw_lat'] ?? '0', + 'raw_longitude': coords['raw_lng'] ?? '0', + 'velocity_lat': coords['velocity_lat'] ?? '0', + 'velocity_lng': coords['velocity_lng'] ?? '0', + 'speed': coords['speed'] ?? '0', + 'heading': coords['heading'] ?? '0', + 'riderkms': riderKmsToSend, + 'logstatus': await _resolveLogStatus(orderId, coords), + }; + + // Determine API endpoint + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + // Post the log + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Posting log for $orderId'); + + final result = await _logProvider + .createDeliveryLog(url, payloadWithCoords) + .timeout( + const Duration(seconds: 8), + onTimeout: () => throw TimeoutException( + 'Delivery log post timeout for $orderId', + ), + ); + + if (result != null) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Success for $orderId'); + await _persistLastLogLocation(orderId, coords); + } else { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Warning: No response for $orderId', + ); + } + } catch (e) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Failed to post log for $orderId: $e', + ); + // Offline fallback + if (payloadWithCoords != null) { + await _saveToOfflineQueue(orderId, payloadWithCoords); + } + } + } + + /// Create payload from delivery order data + static Map _createPayload( + Map delivery, + int userId, + ) { + return { + 'logid': 0, + 'tenantid': delivery['tenantid'] ?? 0, + 'partnerid': delivery['partnerid'] ?? 0, + 'locationid': delivery['locationid'] ?? 0, + 'orderheaderid': delivery['orderheaderid'] ?? 0, + 'deliveryid': delivery['deliveryid'] ?? 0, + 'userid': userId, + 'orderid': (delivery['orderid'] ?? '').toString(), + 'orderstatus': 'active', + }; + } + + /// Get coordinates with fallback (current position -> last known -> cached) + static Future?> _getCoordinatesWithFallback() async { + Map result = { + 'lat': '0', + 'lng': '0', + 'raw_lat': '0', + 'raw_lng': '0', + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + }; + + try { + // 1. Check if location services are enabled + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location services disabled'); + return await _getCachedCoordinatesAsMap(); + } + + // 2. Check permissions + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location permission denied'); + return await _getCachedCoordinatesAsMap(); + } + + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ).timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Failed to get current pos: $e'); + position = await Geolocator.getLastKnownPosition(); + } + + if (position != null) { + // Reject mocked GPS (anti-cheat) + if (position.isMocked) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Mocked position — using cached'); + return await _getCachedCoordinatesAsMap(); + } + // Reject very poor accuracy to avoid phantom movements in delivery logs + if (position.accuracy > 50.0) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Low-accuracy position (${position.accuracy.toStringAsFixed(0)}m) — using cached', + ); + return await _getCachedCoordinatesAsMap(); + } + + final now = DateTime.now(); + double outLat = position.latitude; + double outLng = position.longitude; + double speed = position.speed; + double heading = position.heading; + + // Decompose velocity for Kalman + final double headingRadians = heading * (math.pi / 180.0); + final double velocityLng = speed * math.sin(headingRadians); + final double velocityLat = speed * math.cos(headingRadians); + + if (_kf == null) { + _kf = NearleKalmanFilter(lat: outLat, lng: outLng); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; + + _kf!.predict(dt); + _kf!.update(outLat, outLng); + outLat = _kf!.x[0]; + outLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + result = { + 'lat': outLat.toString(), + 'lng': outLng.toString(), + 'raw_lat': position.latitude.toString(), + 'raw_lng': position.longitude.toString(), + 'speed': speed.toStringAsFixed(2), + 'heading': heading.toStringAsFixed(2), + 'velocity_lat': velocityLat.toStringAsFixed(4), + 'velocity_lng': velocityLng.toStringAsFixed(4), + }; + + await _cacheCoordinates(result['lat']!, result['lng']!); + return result; + } + + return await _getCachedCoordinatesAsMap(); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error getting coords: $e'); + return await _getCachedCoordinatesAsMap(); + } + } + + static Future?> _getCachedCoordinatesAsMap() async { + final coords = await _getCachedCoordinates(); + if (coords == null) return null; + return { + 'lat': coords.$1, + 'lng': coords.$2, + 'raw_lat': coords.$1, + 'raw_lng': coords.$2, + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + }; + } + + /// Cache coordinates to SharedPreferences + static Future _cacheCoordinates(String lat, String lng) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lat); + await prefs.setString('last_lng', lng); + } catch (_) {} + } + + /// Get cached coordinates from SharedPreferences + static Future<(String, String)?> _getCachedCoordinates() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = (prefs.getString('last_lat') ?? '').trim(); + final lng = (prefs.getString('last_lng') ?? '').trim(); + if (lat.isNotEmpty && lng.isNotEmpty) { + return (lat, lng); + } + } catch (_) {} + return null; + } + + /// Load payload from SharedPreferences + static Future?> _loadPayload(String orderId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString('$_payloadKeyPrefix$orderId'); + if (jsonStr == null || jsonStr.isEmpty) return null; + final decoded = jsonDecode(jsonStr); + if (decoded is Map) return decoded; + if (decoded is Map) return decoded.cast(); + } catch (_) {} + return null; + } + + /// Persist payload to SharedPreferences + static Future _persistPayload( + String orderId, + Map payload, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('$_payloadKeyPrefix$orderId', jsonEncode(payload)); + } catch (_) {} + } + + /// Mark order as active in SharedPreferences + static Future _markAsActive(String orderId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(_activeDeliveriesKey) ?? []; + if (!list.contains(orderId)) { + list.add(orderId); + await prefs.setStringList(_activeDeliveriesKey, list); + } + } catch (_) {} + } + + /// Fetch deliveries from API using v1 endpoint + static Future> _fetchDeliveriesFromApi(Uri uri) async { + try { + final res = await _httpClient + .get(uri) + .timeout(const Duration(seconds: 10)); + + if (res.statusCode >= 200 && res.statusCode < 300) { + final decoded = json.decode(res.body); + + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + if (data is List) { + return data; + } + if (data is Map && data['items'] is List) { + return data['items'] as List; + } + + return []; + } + return []; + } catch (e) { + return []; + } + } + + /// Helper to pad numbers with leading zero + static String _pad(int n) => n.toString().padLeft(2, '0'); + + static Future _resolveLogStatus( + String orderId, + Map coords, + ) async { + try { + final last = await _loadLastLogLocation(orderId); + if (last != null) { + final lastLat = last.$1; + final lastLng = last.$2; + final currentLat = double.tryParse(coords['lat'] ?? '0') ?? 0; + final currentLng = double.tryParse(coords['lng'] ?? '0') ?? 0; + if (currentLat != 0 && + currentLng != 0 && + lastLat != 0 && + lastLng != 0) { + final distance = Geolocator.distanceBetween( + lastLat, + lastLng, + currentLat, + currentLng, + ); + if (distance <= _idleThresholdMeters) { + return 1; // idle + } + } + } + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] logstatus error: $e'); + } + return 0; // moving + } + + static Future<(double, double)?> _loadLastLogLocation(String orderId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString('last_log_loc_$orderId'); + if (jsonStr == null || jsonStr.isEmpty) return null; + final decoded = jsonDecode(jsonStr); + if (decoded is Map) { + final lat = double.tryParse('${decoded['lat']}') ?? 0; + final lng = double.tryParse('${decoded['lng']}') ?? 0; + if (lat != 0 && lng != 0) return (lat, lng); + } + } catch (_) {} + return null; + } + + static Future _persistLastLogLocation( + String orderId, + Map coords, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'last_log_loc_$orderId', + jsonEncode({'lat': coords['lat'], 'lng': coords['lng']}), + ); + } catch (_) {} + } + + // ---------------- Offline Queue Logic ---------------- + + static Future _flushOfflineLogs() async { + try { + final prefs = await SharedPreferences.getInstance(); + final List queue = prefs.getStringList(_offlineLogKey) ?? []; + if (queue.isEmpty) return; + + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flushing ${queue.length} offline logs...', + ); + + final List remaining = []; + bool anySuccess = false; + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + for (final itemStr in queue) { + try { + final Map item = jsonDecode(itemStr); + final String orderId = item['orderId'] ?? ''; + final Map payload = Map.from( + item['payload'] ?? {}, + ); + + if (payload.isEmpty) continue; + + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Retrying for orderId: $orderId', + ); + + final result = await _logProvider + .createDeliveryLog(url, payload) + .timeout(const Duration(seconds: 8)); + + if (result != null) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Success for orderId: $orderId', + ); + anySuccess = true; + } else { + remaining.add(itemStr); + } + } catch (e) { + remaining.add(itemStr); + } + } + + if (anySuccess || remaining.length != queue.length) { + await prefs.setStringList(_offlineLogKey, remaining); + } + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flush error: $e'); + } + } + + static Future _saveToOfflineQueue( + String orderId, + Map payload, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + final List queue = prefs.getStringList(_offlineLogKey) ?? []; + + final item = jsonEncode({ + 'orderId': orderId, + 'payload': payload, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }); + + queue.add(item); + await prefs.setStringList(_offlineLogKey, queue); + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Saved to queue. Total: ${queue.length}', + ); + } catch (e) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Error saving to queue: $e', + ); + } + } + + // ---------------- Auto Shift End Logic ---------------- + + /// Check if shift has ended and trigger auto-break if needed + static Future checkShiftEnd() async { + try { + debugPrint('[AUTO_SHIFT_END] 🔍 Starting shift end check...'); + final prefs = await SharedPreferences.getInstance(); + + // 1. Check if currently On Duty + final int onduty = prefs.getInt('onduty') ?? 0; + debugPrint('[AUTO_SHIFT_END] 📊 Current onduty status: $onduty'); + if (onduty != 1) { + debugPrint('[AUTO_SHIFT_END] ⏭️ Rider not on duty, skipping check'); + return; // Already offline + } + + // 2. Get Shift Timings + final String startTimeStr = prefs.getString('starttime') ?? ''; + final String endTimeStr = prefs.getString('endtime') ?? ''; + debugPrint('[AUTO_SHIFT_END] ⏰ Shift times - Start: "$startTimeStr", End: "$endTimeStr"'); + + if (endTimeStr.isEmpty) { + debugPrint('[AUTO_SHIFT_END] ⚠️ No endtime found, cannot check shift end'); + return; + } + + // 3. Parse Times (Assumed format HH:mm:ss) + final now = DateTime.now(); + + final endParts = endTimeStr.split(':'); + if (endParts.length < 2) return; + + final int endHour = int.tryParse(endParts[0]) ?? 0; + final int endMinute = int.tryParse(endParts[1]) ?? 0; + final int endSecond = endParts.length > 2 ? (int.tryParse(endParts[2]) ?? 0) : 0; + + // Create DateTime for end time on TODAY + final DateTime endToday = DateTime( + now.year, + now.month, + now.day, + endHour, + endMinute, + endSecond, + ); + + bool isShiftOver = false; + + if (startTimeStr.isNotEmpty) { + final startParts = startTimeStr.split(':'); + if (startParts.length >= 2) { + final int startHour = int.tryParse(startParts[0]) ?? 0; + final int startMinute = int.tryParse(startParts[1]) ?? 0; + + // Create DateTime for start time on TODAY + final DateTime startToday = DateTime( + now.year, + now.month, + now.day, + startHour, + startMinute, + ); + + // Check for overnight shift (Start > End in 24-hour format, e.g. 22:00 to 06:00) + // This means shift crosses midnight + final double startVal = startHour + (startMinute / 60.0); + final double endVal = endHour + (endMinute / 60.0); + + if (startVal > endVal) { + // ✅ OVERNIGHT SHIFT (crosses midnight, e.g. 22:00 to 06:00) + // Shift is over if current time is AFTER end time today AND BEFORE start time today + // Example: End 06:00, Start 22:00 + // - Now 23:00 -> After 22:00 (start) -> Still in shift (ACTIVE) + // - Now 05:00 -> Before 06:00 (end) -> Still in shift from yesterday (ACTIVE) + // - Now 10:00 -> After 06:00 (end) AND Before 22:00 (start) -> Shift OVER + + debugPrint('[AUTO_SHIFT_END] 🌙 Overnight shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})'); + debugPrint('[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}'); + + if (now.isAfter(endToday) && now.isBefore(startToday)) { + // We're in the gap period between end and start -> shift is OVER + isShiftOver = true; + debugPrint('[AUTO_SHIFT_END] ✅ Overnight shift: In gap period -> SHIFT OVER'); + } else { + // We're either before end (still in shift from yesterday) or after start (still in shift today) + isShiftOver = false; + debugPrint('[AUTO_SHIFT_END] ✅ Overnight shift: Still active'); + } + } else { + // ✅ NORMAL DAY SHIFT (e.g. 09:00 to 17:00, doesn't cross midnight) + // Shift is over if current time is AFTER end time + debugPrint('[AUTO_SHIFT_END] ☀️ Day shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})'); + debugPrint('[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}, End time: ${_pad(endHour)}:${_pad(endMinute)}'); + + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + isShiftOver = true; + debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Shift ended'); + } else { + isShiftOver = false; + debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Still active'); + } + } + } else { + // Fallback if start time parse fails: assume day shift + debugPrint('[AUTO_SHIFT_END] ⚠️ Could not parse start time, assuming day shift'); + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + isShiftOver = true; + } + } + } else { + // Fallback if no start time: assume day shift + debugPrint('[AUTO_SHIFT_END] ⚠️ No start time provided, assuming day shift'); + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + isShiftOver = true; + } + } + + // 4. Trigger Auto End if Shift is Over + debugPrint('[AUTO_SHIFT_END] 📅 Time check result - isShiftOver: $isShiftOver, Current: ${_pad(now.hour)}:${_pad(now.minute)}, End: ${_pad(endHour)}:${_pad(endMinute)}'); + if (isShiftOver) { + debugPrint('[AUTO_SHIFT_END] ⏰ Shift ended (Start: $startTimeStr, End: $endTimeStr). Current: ${_pad(now.hour)}:${_pad(now.minute)}'); + await _autoEndShift(prefs); + } else { + debugPrint('[AUTO_SHIFT_END] ✅ Shift not ended yet, continuing...'); + } + } catch (e) { + debugPrint('[AUTO_SHIFT_END] ❌ Error checking shift end: $e'); + debugPrint('[AUTO_SHIFT_END] Stack trace: ${StackTrace.current}'); + } + } + + static Future _autoEndShift(SharedPreferences prefs) async { + try { + debugPrint( + '[AUTO_SHIFT_END] 🚀 Initiating auto-break and offline sequence...', + ); + + // 1. Get Required IDs + final int userid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + final int partnerid = + prefs.getInt('partnerid') ?? prefs.getInt('partnerId') ?? 0; + final int shiftid = + prefs.getInt('shiftid') ?? prefs.getInt('shiftId') ?? 0; + final int logid = prefs.getInt('logid') ?? prefs.getInt('logId') ?? 0; + + if (userid == 0) { + debugPrint( + '[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log', + ); + return; + } + + // 2. Get Location + final Map? coords = await _getCoordinatesWithFallback(); + final String lat = coords?['lat'] ?? '0'; + final String lng = coords?['lng'] ?? '0'; + + // 3. Prepare Break Log Payload + final now = DateTime.now(); + final int localBreakId = + (DateTime.now().millisecondsSinceEpoch % 900) + 100; // Random-ish ID + final String breakdate = + '${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; + final String breakstart = + '${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; + + final payload = { + "breakid": localBreakId, + "logid": logid, + "breakdate": breakdate, + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "breakstart": breakstart, + "breakend": "", + "breakhours": 0.0, + "latitude": lat, + "longitude": lng, + }; + + // 4. Call API to Create Break + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createBreakRiderLogLive + : ApiConstants.createBreakRiderLogDev; + + debugPrint('[AUTO_SHIFT_END] Creating break log: $url'); + + // We use a separate provider instance or http call if needed, + // but _logProvider is for delivery logs. We need a generic post or use http directly. + // Since we don't have BreakRiderLogProvider here, we'll use http directly for simplicity and isolation. + + try { + final response = await _httpClient + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode >= 200 && response.statusCode < 300) { + debugPrint('[AUTO_SHIFT_END] ✅ Break log created successfully'); + + // Parse response to get server break ID if needed, but mainly we just need to go offline + final body = jsonDecode(response.body); + final det = (body['details'] is Map) ? body['details'] : body; + final serverBreakId = det['breakid']; + + if (serverBreakId != null) { + await prefs.setInt( + 'breakId', + int.tryParse('$serverBreakId') ?? localBreakId, + ); + } + await prefs.setString('breakStart', breakstart); + await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch); + } else { + debugPrint( + '[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${response.statusCode}', + ); + } + } catch (e) { + debugPrint('[AUTO_SHIFT_END] ❌ API Error: $e'); + // Even if API fails, we MUST go offline locally to prevent further issues + } + + // 5. Set Offline Locally + await prefs.setInt('onduty', 0); + await prefs.setBool('online', false); + + // 6. Update Rider Log (Set Duty = 0) + // We should also update the main rider log to say onduty=0 + final updateUrl = ApiConstants.mainRoute == 'live' + ? ApiConstants.updateRiderLogLive + : ApiConstants.updateRiderLogDev; + + final updatePayload = { + "userid": userid, + "onduty": 0, + "latitude": lat, + "longitude": lng, + }; + + try { + await _httpClient + .post( + Uri.parse(updateUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(updatePayload), + ) + .timeout(const Duration(seconds: 5)); + debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline'); + } catch (_) {} + + debugPrint('[AUTO_SHIFT_END] 🏁 Auto-shift end sequence complete.'); + } catch (e) { + debugPrint('[AUTO_SHIFT_END] Critical error in _autoEndShift: $e'); + } + } + + /// Trigger proximity alert once per delivery when within threshold + static Future _checkProximityAlert( + Map order, + Map coords, + ) async { + try { + final deliveryId = + (order['deliveryid'] ?? order['orderid'] ?? '').toString(); + if (deliveryId.isEmpty) return; + + final dropLatRaw = + (order['droplat'] ?? order['DropLat'] ?? order['deliverylat'] ?? '') + .toString(); + final dropLngRaw = (order['droplon'] ?? + order['droplong'] ?? + order['DropLon'] ?? + order['DropLong'] ?? + order['deliverylong'] ?? + '') + .toString(); + final dropLat = double.tryParse(dropLatRaw) ?? 0.0; + final dropLng = double.tryParse(dropLngRaw) ?? 0.0; + if (dropLat == 0 || + dropLng == 0 || + dropLat.abs() > 90 || + dropLng.abs() > 180) { + return; + } + + final riderLat = double.tryParse(coords['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(coords['lng'] ?? '0') ?? 0.0; + if (riderLat == 0 || + riderLng == 0 || + riderLat.abs() > 90 || + riderLng.abs() > 180) { + return; + } + + final distanceMeters = Geolocator.distanceBetween( + dropLat, + dropLng, + riderLat, + riderLng, + ); + + if (distanceMeters <= _proximityThresholdMeters) { + final prefs = await SharedPreferences.getInstance(); + final alreadyAlerted = prefs.getBool(‘$_proximityAlertKeyPrefix$deliveryId’) ?? false; + if (alreadyAlerted) { + debugPrint(‘[PROXIMITY] Already alerted for deliveryId=$deliveryId, skipping’); + return; + } + + await NotificationServce.showLocalNotification( + title: ‘Near delivery location’, + body: ‘You\’ve reached your destination. Please update the status.’, + ); + + final played = await _playProximityAudio(); + await prefs.setBool(‘$_proximityAlertKeyPrefix$deliveryId’, true); + + debugPrint( + ‘[PROXIMITY] Alerted deliveryId=$deliveryId at ${distanceMeters.toStringAsFixed(1)}m ‘ + ‘target=($dropLat,$dropLng) rider=($riderLat,$riderLng) played=$played’, + ); + } else { + debugPrint( + ‘[PROXIMITY] Skipped alert for deliveryId=$deliveryId | distance=${distanceMeters.toStringAsFixed(1)}m’, + ); + } + } catch (e) { + debugPrint('[PROXIMITY] Error sending alert: $e'); + } + } + + static Future _playProximityAudio() async { + try { + if (!_audioReady) { + await _proximityPlayer.setReleaseMode(ReleaseMode.stop); + await _proximityPlayer.setVolume(1.0); + _audioReady = true; + } + // Attempt to play bundled destination audio + await _proximityPlayer.stop(); + await _proximityPlayer.play(AssetSource('audio/destination.mp3')); + debugPrint('[PROXIMITY][AUDIO] Playing destination.mp3'); + return true; + } catch (e) { + debugPrint('[PROXIMITY][AUDIO] Error playing destination clip: $e'); + } + + // Fallback to TTS if audio fails + try { + // Initialize once + if (!_ttsReady) { + await _tts.setLanguage('en-US'); + await _tts.setSpeechRate(0.9); + await _tts.setVolume(1.0); + await _tts.setPitch(1.0); + _ttsReady = true; + } + // Speak without awaiting completion to avoid blocking + await _tts.speak('You have reached your destination. Please update the delivery status.'); + debugPrint('[PROXIMITY][TTS] Spoke destination prompt'); + return true; + } catch (e) { + debugPrint('[PROXIMITY][TTS] Error speaking prompt: $e'); + } + return false; + } +} diff --git a/lib/background/foreground_service.dart b/lib/background/foreground_service.dart new file mode 100644 index 0000000..77130b6 --- /dev/null +++ b/lib/background/foreground_service.dart @@ -0,0 +1,461 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'dart:math' as math; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/views/helpers/constants/apiconstants.dart'; +import 'package:nearle/providers/Riderlog/riderlog_provider.dart'; +import 'package:nearle/background/backgroundservice.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:nearle/utils/kalman_filter.dart'; +import 'package:nearle/utils/mqtt_service.dart'; +import 'package:nearle/views/helpers/constants/mqtt_constants.dart'; +import 'package:battery_plus/battery_plus.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'dart:io'; +import 'package:nearle/helpers/http_overrides.dart'; + +class _BackgroundRiderLog { + static NearleKalmanFilter? _kf; + static DateTime? _lastUpdateTime; + + static Future> _ensureLatLng() async { + Map result = { + 'lat': '0', + 'lng': '0', + 'raw_lat': '0', + 'raw_lng': '0', + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + 'status': 'unknown', + 'accuracy': '0', + }; + try { + // 1. Check if location services are enabled + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[BG_RIDER_LOG] Location services are disabled.'); + result['status'] = 'disabled'; + return result; + } + + // 2. Check permissions + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + debugPrint('[BG_RIDER_LOG] Location permission denied.'); + result['status'] = 'denied'; + return result; + } + if (permission == LocationPermission.deniedForever) { + debugPrint('[BG_RIDER_LOG] Location permission denied forever.'); + result['status'] = 'denied_forever'; + return result; + } + + result['status'] = 'enabled'; + + // 3. Get position (using non-deprecated LocationSettings + explicit timeout) + final pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + // Reject mocked positions (anti-cheat) + if (pos.isMocked) { + debugPrint('[BG_RIDER_LOG] Mocked position detected — using cached'); + return result; + } + + final now = DateTime.now(); + double outLat = pos.latitude; + double outLng = pos.longitude; + double speed = pos.speed; + double heading = pos.heading; + + // Decompose velocity for Kalman + final double headingRadians = heading * (math.pi / 180.0); + final double velocityLng = speed * math.sin(headingRadians); + final double velocityLat = speed * math.cos(headingRadians); + + if (_kf == null) { + _kf = NearleKalmanFilter(lat: outLat, lng: outLng); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; // Default background interval + _kf!.predict(dt); + _kf!.update(outLat, outLng); + outLat = _kf!.x[0]; + outLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + return { + 'lat': outLat.toStringAsFixed(6), + 'lng': outLng.toStringAsFixed(6), + 'raw_lat': pos.latitude.toStringAsFixed(6), + 'raw_lng': pos.longitude.toStringAsFixed(6), + 'speed': speed.toStringAsFixed(2), + 'heading': heading.toStringAsFixed(2), + 'velocity_lat': velocityLat.toStringAsFixed(4), + 'velocity_lng': velocityLng.toStringAsFixed(4), + 'status': 'enabled', + 'accuracy': pos.accuracy.toStringAsFixed(1), + }; + } catch (e) { + debugPrint('[BG_RIDER_LOG] Error getting location: $e'); + return result; + } + } + + static String _two(int n) => n.toString().padLeft(2, '0'); + static String _formatDateTimeFull(DateTime dt) { + final y = dt.year.toString(); + final m = _two(dt.month); + final d = _two(dt.day); + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$y-$m-$d $hh:$mm:$ss"; + } + static String _formatTime(DateTime dt) { + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$hh:$mm:$ss"; + } + + /// Accumulates cumulative KMs for active deliveries using the foreground service GPS position. + /// Only runs when LiveTrackingService (main isolate) hasn't updated in the last 10 seconds, + /// which means the app is backgrounded/screen-off/power-saver and the main isolate is dormant. + static Future _accumulateBackgroundKms( + SharedPreferences prefs, + Map loc, + ) async { + try { + // Check if the main isolate's LiveTrackingService is still actively updating + final lastLiveUpdateMs = prefs.getInt('live_tracking_last_update_ms') ?? 0; + final nowMs = DateTime.now().millisecondsSinceEpoch; + final secondsSinceLiveUpdate = (nowMs - lastLiveUpdateMs) / 1000.0; + + if (secondsSinceLiveUpdate < 10.0) { + // Main isolate is active — let it handle KMs to avoid race conditions + debugPrint( + '[BG_KM] LiveTrackingService active (${secondsSinceLiveUpdate.toStringAsFixed(1)}s ago) — skipping background accumulation', + ); + return; + } + + // Skip if GPS accuracy is too poor for reliable KM tracking + final double accuracy = double.tryParse(loc['accuracy'] ?? '9999') ?? 9999.0; + if (accuracy > 50.0) { + debugPrint( + '[BG_KM] Low-accuracy position (${accuracy.toStringAsFixed(0)}m) — skipping KM accumulation', + ); + return; + } + + final double currentLat = double.tryParse(loc['lat'] ?? '0') ?? 0.0; + final double currentLng = double.tryParse(loc['lng'] ?? '0') ?? 0.0; + if (currentLat == 0.0 || currentLng == 0.0) return; + + final activeDeliveryIds = + prefs.getStringList('active_tracking_delivery_ids') ?? []; + + for (final dId in activeDeliveryIds) { + try { + final lastLatStr = prefs.getString('delivery_tracking_${dId}_lastLat') ?? ''; + final lastLngStr = prefs.getString('delivery_tracking_${dId}_lastLng') ?? ''; + final currentCumKm = double.tryParse( + prefs.getString('delivery_tracking_${dId}_cumulativeKm') ?? '0', + ) ?? + 0.0; + + if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) { + final lastLat = double.tryParse(lastLatStr) ?? 0.0; + final lastLng = double.tryParse(lastLngStr) ?? 0.0; + + if (lastLat != 0.0 && lastLng != 0.0) { + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + currentLat, + currentLng, + ); + + // Speed-based jump guard: reject if implied speed > 120 km/h (33.3 m/s). + // Uses elapsed time since last recorded position so the threshold scales + // correctly whether the background interval is 30s, 60s, or longer. + final lastUpdateMs = + prefs.getInt('delivery_tracking_${dId}_lastUpdateMs') ?? 0; + final nowMs = DateTime.now().millisecondsSinceEpoch; + final elapsedSeconds = lastUpdateMs > 0 + ? (nowMs - lastUpdateMs) / 1000.0 + : 60.0; // conservative default + final maxRealisticMeters = elapsedSeconds * 33.3; // 120 km/h ceiling + + if (distanceMeters > maxRealisticMeters && distanceMeters > 50.0) { + // GPS jumped — update anchor without counting phantom distance + debugPrint( + '[BG_KM] GPS jump for $dId: ${distanceMeters.toStringAsFixed(0)}m ' + 'in ${elapsedSeconds.toStringAsFixed(1)}s (max: ${maxRealisticMeters.toStringAsFixed(0)}m) — resetting anchor', + ); + await prefs.setString('delivery_tracking_${dId}_lastLat', currentLat.toString()); + await prefs.setString('delivery_tracking_${dId}_lastLng', currentLng.toString()); + await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', nowMs); + } else if (distanceMeters >= 5.0) { + final newCumKm = currentCumKm + (distanceMeters / 1000.0); + await prefs.setString( + 'delivery_tracking_${dId}_cumulativeKm', + newCumKm.toStringAsFixed(4), + ); + await prefs.setString('delivery_tracking_${dId}_lastLat', currentLat.toString()); + await prefs.setString('delivery_tracking_${dId}_lastLng', currentLng.toString()); + await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', nowMs); + debugPrint( + '[BG_KM] +${(distanceMeters / 1000.0).toStringAsFixed(4)} km for $dId ' + 'in ${elapsedSeconds.toStringAsFixed(1)}s (total: ${newCumKm.toStringAsFixed(4)} km)', + ); + } + } + } else { + // No anchor yet — set initial position + await prefs.setString('delivery_tracking_${dId}_lastLat', currentLat.toString()); + await prefs.setString('delivery_tracking_${dId}_lastLng', currentLng.toString()); + await prefs.setInt( + 'delivery_tracking_${dId}_lastUpdateMs', + DateTime.now().millisecondsSinceEpoch, + ); + } + } catch (_) {} + } + } catch (e) { + debugPrint('[BG_KM] Error in background KM accumulation: $e'); + } + } + + static Future createLoginNow() async { + try { + final prefs = await SharedPreferences.getInstance(); + // Reload from disk so we see the latest values written by the main isolate + await prefs.reload(); + + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + return; + } + final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + if ((userid ?? 0) == 0) return; + + // Prefer explicit username, then fallback to stored full name or first/last + String? username = prefs.getString('username'); + username ??= prefs.getString('user_name'); + if (username == null || username.trim().isEmpty) { + final first = prefs.getString('firstname') ?? ''; + final last = prefs.getString('lastname') ?? ''; + final combined = ('$first $last').trim(); + if (combined.isNotEmpty) { + username = combined; + } + } + + // ✅ Check if there are active deliveries to set status + final bool hasActiveDeliveries = prefs.getBool('has_live_deliveries') ?? false; + final String riderStatus = hasActiveDeliveries ? 'active' : 'idle'; + + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); + final loginTime = _formatTime(now); + final loc = await _ensureLatLng(); + + // Accumulate KMs in background when LiveTrackingService (main isolate) is not active + await _accumulateBackgroundKms(prefs, loc); + + + final int? tenantid = prefs.getInt('tenantid'); + final int? locationid = prefs.getInt('locationid'); + final int? applocationid = prefs.getInt('applocationid'); + final String? userfcmtoken = prefs.getString('userfcmtoken'); + + final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + final String orderId = prefs.getString('current_riding_order_id') ?? ''; + + final payload = { + "logid": logid ?? 0, + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "logdate": iso, + "login": loginTime, + "latitude": loc['lat'] ?? '0', + "longitude": loc['lng'] ?? '0', + "raw_latitude": loc['raw_lat'] ?? '0', + "raw_longitude": loc['raw_lng'] ?? '0', + "velocity_lat": loc['velocity_lat'] ?? '0', + "velocity_lng": loc['velocity_lng'] ?? '0', + "speed": loc['speed'] ?? '0', + "heading": loc['heading'] ?? '0', + "onduty": 1, + "status": riderStatus, + "contactno": prefs.getString('contactno') ?? '', + "tenantid": tenantid ?? 0, + "locationid": locationid ?? 0, + "applocationid": applocationid ?? 0, + "userfcmtoken": userfcmtoken ?? '', + "username": (username ?? '').trim(), + "orderid": orderId, + }; + + final firstName = prefs.getString('firstname') ?? ''; + final lastName = prefs.getString('lastname') ?? ''; + if (firstName.trim().isNotEmpty) { + payload['firstname'] = firstName.trim(); + } + if (lastName.trim().isNotEmpty) { + payload['lastname'] = lastName.trim(); + } + + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + + final provider = CreateRiderLogProvider(); + final resp = await provider.createRiderLog(base, payload); + + if (resp == null || resp.isEmpty) return; + final det = (resp['details'] is Map) + ? (resp['details'] as Map) + : resp; + final newLogId = int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); + await prefs.setInt('logid', newLogId); + await prefs.setInt('logId', newLogId); + + // ✅ MQTT BACKGROUND PUBLISH ( Lane Split ) + final mqttService = NearleMqttService(); + if (!mqttService.isConnected) { + // Use a slightly different client ID for background to avoid kicking the main one off + await mqttService.connect(); + } + + if (mqttService.isConnected) { + // Gather Telemetry + final battery = Battery(); + final int batteryLevel = await battery.batteryLevel; + final BatteryState batteryState = await battery.batteryState; + final isCharging = batteryState == BatteryState.charging || batteryState == BatteryState.full; + + final connectivity = await Connectivity().checkConnectivity(); + final String connType = connectivity.isNotEmpty ? connectivity.first.toString().split('.').last : 'none'; + + // 1. Direct Telemetry (Feeding the /full API) + mqttService.publish('battery', '$batteryLevel%'); + mqttService.publish('charging', isCharging ? 'yes' : 'no'); + mqttService.publish('speed', loc['speed'] ?? '0'); + mqttService.publish('connection', connType); + mqttService.publish('accuracy', loc['accuracy'] ?? '0'); + + // 2. Alert if Location is Off + final String locStatus = loc['status'] ?? 'unknown'; + if (locStatus != 'enabled') { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'location_turned_off', + 'error_type': locStatus, + 'battery': '$batteryLevel%', + 'is_charging': isCharging, + 'connection': connType, + 'logdate': iso, + }); + } + + // 3. Low Battery Alert + if (batteryLevel < 15 && !isCharging) { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'low_battery_warning', + 'battery': '$batteryLevel%', + 'logdate': iso, + }); + } + + // 4. Poor GPS Accuracy Alert + final double accuracy = double.tryParse(loc['accuracy'] ?? '0') ?? 0; + if (accuracy > 30) { + mqttService.publish('alerts', { + 'userid': userid, + 'username': (username ?? '').trim(), + 'event': 'poor_gps_signal', + 'accuracy': '${accuracy.toStringAsFixed(1)}m', + 'logdate': iso, + }); + } + + // 5. Lane: Status + mqttService.updateStatus(riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline); + + // 6. Lane: Periodic Log (Comprehensive Snapshot) + mqttService.publishLog('rider_periodic_log', { + 'userid': userid, + 'username': username, + 'logdate': iso, + 'latitude': loc['lat'] ?? '0', + 'longitude': loc['lng'] ?? '0', + 'speed': loc['speed'] ?? '0', + 'heading': loc['heading'] ?? '0', + 'accuracy': loc['accuracy'] ?? '0', + 'status': riderStatus, + 'orderid': orderId, + 'battery': '$batteryLevel%', + 'is_charging': isCharging, + 'connection': connType, + 'location_service': locStatus, + 'is_background': true, + }); + } + } catch (e) { + // ignore background errors + } + } +} + +class RiderLogTaskHandler extends TaskHandler { + Timer? _timer; // not used; plugin provides repeat callback, but keep safety + + @override + Future onStart(DateTime timestamp, SendPort? sendPort) async { + // No-op + } + + @override + Future onRepeatEvent(DateTime timestamp, SendPort? sendPort) async { + // 1. Rider Log (existing) + await _BackgroundRiderLog.createLoginNow(); + + // 2. Delivery Log (new) + await BackgroundDeliveryLog.processActiveDeliveries(); + + // 3. Auto Shift End (new) + await BackgroundDeliveryLog.checkShiftEnd(); + } + + @override + Future onDestroy(DateTime timestamp, SendPort? sendPort) async { + _timer?.cancel(); + _timer = null; + } +} + +@pragma('vm:entry-point') +void riderLogCallback() { + HttpOverrides.global = MyHttpOverrides(); + FlutterForegroundTask.setTaskHandler(RiderLogTaskHandler()); +} + diff --git a/lib/background/live_tracking_service.dart b/lib/background/live_tracking_service.dart new file mode 100644 index 0000000..a2e6537 --- /dev/null +++ b/lib/background/live_tracking_service.dart @@ -0,0 +1,292 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart'; +import 'dart:math' as math; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/utils/kalman_filter.dart'; +import 'package:nearle/utils/mqtt_service.dart'; +import 'package:battery_plus/battery_plus.dart'; + +class LiveTrackingService { + static final LiveTrackingService _instance = LiveTrackingService._internal(); + + factory LiveTrackingService() => _instance; + + LiveTrackingService._internal(); + + StreamSubscription? _positionStreamSubscription; + bool _isTracking = false; + bool _isProcessing = false; + NearleKalmanFilter? _kf; + DateTime? _lastUpdateTime; + DateTime? _lastSentTime; + DateTime? _lastTelemetrySent; + Timer? _watchdogTimer; + + // Thresholds + static const double _maxAccuracyMeters = 50.0; // Reject GPS > 50m accuracy + static const double _minDistanceMeters = 5.0; // Minimum movement to count + static const double _maxJumpMeters = 200.0; // Reject single-step jumps > 200m + static const int _rateLimitSeconds = 3; // Minimum seconds between updates + static const int _watchdogSeconds = 45; // Restart if no update in 45s + + void startTracking() async { + if (_isTracking) return; + + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[LIVE TRACKING] Location services disabled.'); + return; + } + + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + debugPrint('[LIVE TRACKING] Location permission denied.'); + return; + } + + _isTracking = true; + _lastSentTime = null; + debugPrint('[LIVE TRACKING] Starting high-frequency tracking...'); + + _startStream(); + _startWatchdog(); + } + + void _startStream() { + _positionStreamSubscription?.cancel(); + + const locationSettings = LocationSettings( + accuracy: LocationAccuracy.bestForNavigation, + distanceFilter: 0, + ); + + _positionStreamSubscription = Geolocator.getPositionStream( + locationSettings: locationSettings, + ).listen( + (Position position) async { + await _sendToKalmanBackend(position); + }, + onError: (error) { + debugPrint('[LIVE TRACKING] Stream error: $error — restarting in 5s'); + _positionStreamSubscription?.cancel(); + _positionStreamSubscription = null; + if (_isTracking) { + Future.delayed(const Duration(seconds: 5), () { + if (_isTracking) _startStream(); + }); + } + }, + cancelOnError: true, + ); + } + + void _startWatchdog() { + _watchdogTimer?.cancel(); + _watchdogTimer = Timer.periodic( + const Duration(seconds: _watchdogSeconds), + (_) { + if (!_isTracking) return; + final lastSent = _lastSentTime; + if (lastSent == null) return; + final staleSeconds = DateTime.now().difference(lastSent).inSeconds; + if (staleSeconds > _watchdogSeconds) { + debugPrint( + '[LIVE TRACKING] Watchdog: stream stale for ${staleSeconds}s — restarting', + ); + _positionStreamSubscription?.cancel(); + _positionStreamSubscription = null; + _kf = null; + _lastUpdateTime = null; + _startStream(); + } + }, + ); + } + + void stopTracking() { + if (!_isTracking) return; + debugPrint('[LIVE TRACKING] Stopping tracking.'); + _watchdogTimer?.cancel(); + _watchdogTimer = null; + _positionStreamSubscription?.cancel(); + _positionStreamSubscription = null; + _kf = null; + _lastUpdateTime = null; + _isTracking = false; + } + + Future _sendToKalmanBackend(Position position) async { + final now = DateTime.now(); + + // Rate limiter + if (_lastSentTime != null && + now.difference(_lastSentTime!).inSeconds < _rateLimitSeconds) { + return; + } + + // Mutex — prevent concurrent processing + if (_isProcessing) return; + _isProcessing = true; + _lastSentTime = now; + + try { + // Reject mocked GPS (anti-cheat) + if (position.isMocked) { + debugPrint('[LIVE TRACKING] Skipped mocked position'); + return; + } + + // Reject poor-accuracy GPS (power saver / other apps degrading GPS) + if (position.accuracy > _maxAccuracyMeters) { + debugPrint( + '[LIVE TRACKING] Skipped low-accuracy position: ${position.accuracy.toStringAsFixed(0)}m', + ); + return; + } + + final prefs = await SharedPreferences.getInstance(); + final userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final currentOrderId = prefs.getString('current_riding_order_id') ?? ''; + + final double headingRadians = position.heading * (math.pi / 180.0); + final double velocityLng = position.speed * math.sin(headingRadians); + final double velocityLat = position.speed * math.cos(headingRadians); + + double displayLat = position.latitude; + double displayLng = position.longitude; + + if (_kf == null) { + _kf = NearleKalmanFilter( + lat: position.latitude, + lng: position.longitude, + ); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : _rateLimitSeconds.toDouble(); + _kf!.predict(dt); + _kf!.update(position.latitude, position.longitude); + displayLat = _kf!.x[0]; + displayLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + // Stamp that the main isolate is actively tracking. + // The foreground service reads this to avoid double-counting KMs. + await prefs.setInt('live_tracking_last_update_ms', now.millisecondsSinceEpoch); + + final payload = { + 'userid': userid, + 'orderid': currentOrderId, + 'lat': displayLat, + 'lng': displayLng, + 'raw_lat': position.latitude, + 'raw_lng': position.longitude, + 'speed': position.speed, + 'heading': position.heading, + 'velocity_lat': velocityLat, + 'velocity_lng': velocityLng, + 'timestamp': now.toIso8601String(), + }; + + // Accumulate cumulative KMs for active deliveries + final activeDeliveryIds = + prefs.getStringList('active_tracking_delivery_ids') ?? []; + for (final dId in activeDeliveryIds) { + try { + final lastLatStr = + prefs.getString('delivery_tracking_${dId}_lastLat') ?? ''; + final lastLngStr = + prefs.getString('delivery_tracking_${dId}_lastLng') ?? ''; + final currentCumKm = double.tryParse( + prefs.getString('delivery_tracking_${dId}_cumulativeKm') ?? '0', + ) ?? + 0.0; + + if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) { + final lastLat = double.tryParse(lastLatStr) ?? 0.0; + final lastLng = double.tryParse(lastLngStr) ?? 0.0; + + if (lastLat != 0 && lastLng != 0 && displayLat != 0 && displayLng != 0) { + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + displayLat, + displayLng, + ); + + // Speed-based jump guard: reject if implied speed > 120 km/h (33.3 m/s) + final lastUpdateMs = prefs.getInt('delivery_tracking_${dId}_lastUpdateMs') ?? 0; + final elapsedSeconds = lastUpdateMs > 0 + ? (now.millisecondsSinceEpoch - lastUpdateMs) / 1000.0 + : _rateLimitSeconds.toDouble(); + final maxRealisticMeters = elapsedSeconds * 33.3; // 120 km/h ceiling + + if (distanceMeters > maxRealisticMeters && distanceMeters > _maxJumpMeters) { + // GPS jumped — update anchor without counting the phantom distance + debugPrint( + '[LIVE TRACKING] GPS jump for $dId: ${distanceMeters.toStringAsFixed(0)}m ' + 'in ${elapsedSeconds.toStringAsFixed(1)}s (max realistic: ${maxRealisticMeters.toStringAsFixed(0)}m) — resetting anchor', + ); + await prefs.setString('delivery_tracking_${dId}_lastLat', displayLat.toString()); + await prefs.setString('delivery_tracking_${dId}_lastLng', displayLng.toString()); + await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', now.millisecondsSinceEpoch); + } else if (distanceMeters >= _minDistanceMeters) { + final newCumKm = currentCumKm + (distanceMeters / 1000.0); + await prefs.setString( + 'delivery_tracking_${dId}_cumulativeKm', + newCumKm.toStringAsFixed(4), + ); + await prefs.setString('delivery_tracking_${dId}_lastLat', displayLat.toString()); + await prefs.setString('delivery_tracking_${dId}_lastLng', displayLng.toString()); + await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', now.millisecondsSinceEpoch); + } + // else: too small — skip without moving anchor (avoids GPS noise accumulation) + } + } else { + // First point for this delivery — set anchor only + await prefs.setString('delivery_tracking_${dId}_lastLat', displayLat.toString()); + await prefs.setString('delivery_tracking_${dId}_lastLng', displayLng.toString()); + await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', now.millisecondsSinceEpoch); + } + } catch (_) {} + } + + final mqttService = NearleMqttService(); + if (mqttService.isConnected) { + mqttService.publishLocation(payload); + debugPrint( + '[LIVE TRACKING] ${displayLat.toStringAsFixed(6)}, ${displayLng.toStringAsFixed(6)} ' + '(acc: ${position.accuracy.toStringAsFixed(0)}m, speed: ${position.speed.toStringAsFixed(1)} m/s)', + ); + } else { + debugPrint('[LIVE TRACKING] MQTT not connected — attempting reconnect'); + mqttService.connect(); + } + + // Telemetry once every 5 minutes (timestamp guard prevents duplicate fires + // across the 3-second GPS update window at the same minute mark) + final shouldSendTelemetry = _lastTelemetrySent == null || + now.difference(_lastTelemetrySent!).inMinutes >= 5; + if (now.minute % 5 == 0 && shouldSendTelemetry) { + _lastTelemetrySent = now; + try { + final battery = Battery(); + final batteryLevel = await battery.batteryLevel; + mqttService.publishTelemetry({ + 'battery_level': batteryLevel, + 'gps_accuracy': position.accuracy, + 'mocked': position.isMocked, + 'timestamp': now.toIso8601String(), + }); + } catch (_) {} + } + } catch (e) { + debugPrint('[LIVE TRACKING] Error: $e'); + } finally { + _isProcessing = false; + } + } +} diff --git a/lib/controllers/auth.dart b/lib/controllers/auth.dart new file mode 100644 index 0000000..2f7fd9f --- /dev/null +++ b/lib/controllers/auth.dart @@ -0,0 +1,712 @@ +import 'dart:io' show Platform; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/providers/auth/auth_provider.dart'; +import 'package:nearle/utils/device.dart'; +import 'package:sms_autofill/sms_autofill.dart'; +import 'package:nearle/controllers/profile_controller.dart'; +// ignore: unused_import +import 'package:nearle/controllers/riderlog.dart'; +import 'package:nearle/Models/login/login.dart'; +import 'dart:convert'; + +enum AuthNext { verifyPin, otp, notRegistered, error } + +class AuthController extends GetxController { + final RxBool sendingOtp = false.obs; + String? currentPhone; + final AuthProvider _api = AuthProvider(); + AuthNext? lastDecision; + // Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry" + VoidCallback? onPinRetry; + static const String _prefsUserIdKey = 'userid'; + static const String _prefsPendingPinUserIdKey = 'pending_pin_userid'; + static const String _prefsUserNameKey = 'user_name'; + static const String _prefsUserEmailKey = 'user_email'; + static const String _prefsContactNoKey = 'contactno'; + static const String _prefsAddressKey = 'user_address'; + static const String _prefsForceMasterPinKey = 'force_master_pin'; + static const String _masterPinValue = '1234'; + static const String forceMasterPinPrefKey = _prefsForceMasterPinKey; + static const String masterPinValue = _masterPinValue; + bool _forceMasterPinFlow = false; + void _log(String msg) => debugPrint('[AUTH] $msg'); + Future _notifyProfileController() async { + try { + if (Get.isRegistered()) { + final prefs = await SharedPreferences.getInstance(); + final pc = Get.find(); + await pc.loadFromPrefs(); + pc.setProfile( + name: prefs.getString(_prefsUserNameKey), + email: prefs.getString(_prefsUserEmailKey), + contact: prefs.getString(_prefsContactNoKey), + address: prefs.getString(_prefsAddressKey), + ); + } + } catch (_) {} + } + + String _normalizePhone(String input) { + final digitsOnly = input.replaceAll(RegExp(r'\D'), ''); + if (digitsOnly.length >= 10) { + return digitsOnly.substring(digitsOnly.length - 10); + } + return digitsOnly; + } + + void _showBottomSheet({required String title, required String message}) { + Get.bottomSheet( + SafeArea( + child: Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.info_outline, + color: Color(0xFF662582), + size: 40, + ), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + fontSize: 20, + ), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16), + ), + const SizedBox(height: 16), + SizedBox( + height: 50, + width: double.infinity, + child: ElevatedButton( + onPressed: () { + Get.back(); + // If MPIN screen has registered a retry callback, run it + onPinRetry?.call(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF662582), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + 'Retry', + style: TextStyle(color: Colors.white, fontSize: 20), + ), + ), + ), + ], + ), + ), + ), + isScrollControlled: true, + backgroundColor: Colors.transparent, + ); + } + + Future precheckPhone(String phone) async { + try { + final normalized = _normalizePhone(phone); + final prefs = await SharedPreferences.getInstance(); + _forceMasterPinFlow = false; + await prefs.remove(_prefsForceMasterPinKey); + String deviceId; + try { + deviceId = await DeviceUtils.ensureDeviceId(prefs); + } catch (e) { + _showBottomSheet( + title: 'Device Error', + message: + 'Failed to get device ID. Please restart the app and try again.', + ); + lastDecision = AuthNext.error; + return lastDecision!; + } + final String fcmToken = await DeviceUtils.ensureFcmToken(prefs); + final bool fcmWasEmpty = fcmToken.isEmpty; + _log( + 'POST /users/rider/login body=${jsonEncode({'contactno': normalized, 'devicetype': Platform.operatingSystem, 'configid': 6, 'deviceid': deviceId, 'userfcmtoken': fcmToken})}', + ); + final Login loginRes = await _api.loginParsed( + contactNo: normalized, + deviceType: Platform.operatingSystem, + configId: 6, + deviceId: deviceId, + fcmToken: fcmToken, + ); + + debugPrint('RAW LOGIN RESPONSE: $loginRes'); + final String serverMessage = (loginRes.message ?? '').toLowerCase(); + final bool masterPinFlow = (loginRes.authmode ?? 0) == 1; + final bool requiresPinSetup = + serverMessage.contains('pin not set') || + serverMessage.contains('mpin not set') || + serverMessage.contains('please set your pin') || + (loginRes.code != null && loginRes.code == 201); + if (serverMessage.contains('not registered') || + serverMessage.contains('register first') || + serverMessage.contains('not found') || + serverMessage.contains('no user')) { + currentPhone = null; + _showBottomSheet( + title: 'Not Registered', + message: 'Please contact admin and register first.', + ); + lastDecision = AuthNext.notRegistered; + return lastDecision!; + } + if (serverMessage.contains('inactive')) { + currentPhone = null; + _showBottomSheet( + title: 'Rider Inactive', + message: 'Rider is inactive. Please contact admin.', + ); + lastDecision = AuthNext.notRegistered; + return lastDecision!; + } + final bool messageSaysEnterPin = (loginRes.message ?? '') + .toLowerCase() + .contains('enter your pin'); + + if (loginRes.status == true) { + try { + final SharedPreferences prefsSave = + await SharedPreferences.getInstance(); + // Persist from model + if (loginRes.userid != null) { + await prefsSave.setInt(_prefsUserIdKey, loginRes.userid!); + + final SharedPreferences prefs = + await SharedPreferences.getInstance(); + await prefs.setString( + 'username', + loginRes.fullname ?? loginRes.firstname.toString(), + ); + await prefs.setInt('userid', loginRes.userid ?? 0); + await prefs.setInt('userId', loginRes.userid ?? 0); + await prefs.setInt('shiftid', loginRes.shiftid ?? 0); + await prefs.setInt('shiftId', loginRes.shiftid ?? 0); + await prefs.setInt('logid', loginRes.logid ?? 0); + await prefs.setInt('logId', loginRes.logid ?? 0); + await prefs.setInt('riderid', loginRes.riderid ?? 0); + await prefs.setInt('partnerid', loginRes.partnerid ?? 0); + await prefs.setInt('partnerId', loginRes.partnerid ?? 0); + await prefs.setInt('rconfigid', loginRes.configid ?? 0); + await prefs.setInt('locationid', loginRes.locationid ?? 0); + await prefs.setInt('tenantid', loginRes.tenantid ?? 0); + await prefs.setInt('applocationid', loginRes.applocationid ?? 0); + if (loginRes.userfcmtoken != null && loginRes.userfcmtoken!.isNotEmpty) { + await prefs.setString('userfcmtoken', loginRes.userfcmtoken!); + } + + debugPrint('saved on shared pref :${loginRes.fullname}'); + } + + final String? name = loginRes.fullname ?? loginRes.firstname; + final String? email = loginRes.email; + final String contact = (loginRes.contactno ?? normalized).toString(); + final String? address = loginRes.address; + _log( + 'Saving from model: name=$name, email=$email, contact=$contact, address=$address', + ); + if (name != null && name.trim().isNotEmpty) { + await prefsSave.setString(_prefsUserNameKey, name.trim()); + } + if (email != null && email.trim().isNotEmpty) { + await prefsSave.setString(_prefsUserEmailKey, email.trim()); + } + if (contact.isNotEmpty) { + await prefsSave.setString( + _prefsContactNoKey, + _normalizePhone(contact), + ); + } + if (address != null && address.trim().isNotEmpty) { + await prefsSave.setString(_prefsAddressKey, address.trim()); + } + await _notifyProfileController(); + } catch (_) {} + } + // Persist basic user profile details from model even if above branch didn't run + try { + final prefs2 = await SharedPreferences.getInstance(); + final String? name = loginRes.fullname ?? loginRes.firstname; + final String? email = loginRes.email; + final String contact = (loginRes.contactno ?? normalized).toString(); + final String? address = loginRes.address; + if (name != null && name.trim().isNotEmpty) { + await prefs2.setString(_prefsUserNameKey, name.trim()); + } + if (email != null && email.trim().isNotEmpty) { + await prefs2.setString(_prefsUserEmailKey, email.trim()); + } + if (contact.isNotEmpty) { + final normalizedContact = _normalizePhone(contact); + await prefs2.setString(_prefsContactNoKey, normalizedContact); + } + if (address != null && address.trim().isNotEmpty) { + await prefs2.setString(_prefsAddressKey, address.trim()); + } + await _notifyProfileController(); + } catch (_) {} + + if (masterPinFlow) { + currentPhone = _normalizePhone(phone); + _forceMasterPinFlow = true; + await prefs.setBool(_prefsForceMasterPinKey, true); + await prefs.remove('dbPin'); + if (loginRes.userid != null) { + await prefs.setInt(_prefsUserIdKey, loginRes.userid!); + } + Get.snackbar( + 'Temporary PIN', + 'Use $_masterPinValue as PIN to continue.', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 4), + backgroundColor: ColorConstants.primaryColor, + colorText: Colors.white, + ); + lastDecision = AuthNext.verifyPin; + return lastDecision!; + } + + if (messageSaysEnterPin) { + currentPhone = _normalizePhone(phone); + lastDecision = AuthNext.verifyPin; + return lastDecision!; + } + + if (requiresPinSetup && !masterPinFlow) { + currentPhone = _normalizePhone(phone); + await prefs.remove('dbPin'); + if (loginRes.userid != null) { + await prefs.setInt(_prefsPendingPinUserIdKey, loginRes.userid!); + } + lastDecision = AuthNext.otp; + return lastDecision!; + } + + // Immediately create rider log entry after successful login + currentPhone = normalized; + String? dbPin = loginRes.pin?.toString(); + if (dbPin != null && dbPin.isNotEmpty) { + await prefs.setString('dbPin', dbPin); + lastDecision = AuthNext.verifyPin; + return lastDecision!; + } + await prefs.remove('dbPin'); + lastDecision = AuthNext.verifyPin; + // If FCM was empty during the request, try to refresh session once token becomes available + if (fcmWasEmpty) { + try { + final String newToken = await DeviceUtils.ensureFcmToken(prefs); + if (newToken.isNotEmpty) { + await refreshSession(phone: normalized); + } + } catch (_) {} + } + return lastDecision!; + } catch (e) { + debugPrint('Precheck phone error: $e'); + lastDecision = AuthNext.error; + return lastDecision!; + } + } + + Future sendOtp([String? phoneArg]) async { + if (sendingOtp.value) return false; + if (phoneArg != null && phoneArg.isNotEmpty) { + currentPhone = _normalizePhone(phoneArg); + } + if (currentPhone == null) { + Get.snackbar( + 'Error', + 'Phone number not set. Please enter your number again.', + ); + return false; + } + sendingOtp.value = true; + try { + final prefs = await SharedPreferences.getInstance(); + final phone = currentPhone!; + + // Get cached SMS provider settings or use defaults + String templateId = + prefs.getString('smsTemplateId') ?? '1107173468024541800'; + String smsContent = + prefs.getString('smsContent') ?? + '<#> Dear customer, use this One Time Password {#var#} to sign-in to Nearle App. This OTP will be valid for the next 5 mins.'; + + // Only fetch SMS provider settings if not cached or cache is old (older than 1 hour) + final lastProviderFetch = prefs.getInt('lastProviderFetch') ?? 0; + final now = DateTime.now().millisecondsSinceEpoch; + if (now - lastProviderFetch > 3600000) { + // 1 hour in milliseconds + try { + final providerUri = Uri.parse( + 'https://jupiter.nearle.app/live/api/v1/platform/getsmsprovider?templatetypeid=1', + ); + final provRes = await http + .get(providerUri) + .timeout(const Duration(seconds: 10)); + if (provRes.statusCode == 200) { + final Map prov = json.decode(provRes.body); + final details = prov['details'] as Map?; + if (details != null) { + templateId = (details['templateid'] ?? templateId).toString(); + smsContent = (details['content'] ?? smsContent).toString(); + // Cache the settings + await prefs.setString('smsTemplateId', templateId); + await prefs.setString('smsContent', smsContent); + await prefs.setInt('lastProviderFetch', now); + } + } + } catch (_) {} + } + // Append app hash for Android SMS Retriever so auto-fill works silently + String appHash = ''; + try { + appHash = await SmsAutoFill().getAppSignature; + if (appHash.isNotEmpty) { + _log('Using app hash for SMS Retriever: $appHash'); + } + } catch (_) {} + // Generate OTP ourselves (like the original implementation) + String actualOtp = _generateOtp(); + await prefs.setString('lastOtp', actualOtp); + _log('Generated OTP: $actualOtp'); + + // Replace {#var#} with actual OTP before sending to Lion SMS + final composedSmsBase = smsContent.replaceAll('{#var#}', actualOtp); + final composedSms = appHash.isNotEmpty + ? ('$composedSmsBase\n$appHash') + : composedSmsBase; + final phoneWithCountry = phone.startsWith('+') ? phone : '+91$phone'; + final encodedSms = Uri.encodeComponent(composedSms); + final smsUrl = Uri.parse( + 'https://msg.lionsms.com/api/smsapi?key=e57f5c9679af26077be1a7eadabb1b2a&route=7&sender=NEARLE&number=$phoneWithCountry&templateid=$templateId&sms=$encodedSms', + ); + final smsRes = await http + .get(smsUrl) + .timeout(const Duration(seconds: 10)); + if (smsRes.statusCode == 200 && + !(smsRes.body.contains('108') || + smsRes.body.contains('110') || + smsRes.body.toLowerCase().contains('error'))) { + return true; + } else { + Get.snackbar( + 'OTP Send Failed', + 'Provider: ${smsRes.statusCode} ${smsRes.body}', + ); + return false; + } + } catch (e) { + debugPrint('sendOtp error: $e'); + Get.snackbar('Error', 'An unexpected error occurred while sending OTP.'); + return false; + } finally { + sendingOtp.value = false; + } + } + + Future verifyOtp(String code) async { + try { + final prefs = await SharedPreferences.getInstance(); + final sentOtp = prefs.getString('lastOtp'); + if (sentOtp != null && code == sentOtp) { + await prefs.remove('lastOtp'); + _log('OTP verification successful: $code'); + return true; + } + _log('OTP verification failed. Expected: $sentOtp, Got: $code'); + return false; + } catch (e) { + _log('OTP verification error: $e'); + return false; + } + } + + // Generate 6-digit OTP (same as original implementation) + String _generateOtp() { + final random = Random(); + final otp = 100000 + random.nextInt(900000); // Generates 100000-999999 + return otp.toString(); + } + + Future setPin(String newPin) async { + try { + final prefs = await SharedPreferences.getInstance(); + int? userId = + prefs.getInt(_prefsPendingPinUserIdKey) ?? + prefs.getInt(_prefsUserIdKey); + if (newPin.length != 4 || int.tryParse(newPin) == null) { + _showBottomSheet( + title: 'Invalid PIN', + message: 'Please enter a valid 4-digit PIN.', + ); + return false; + } + if (userId == null) { + _showBottomSheet( + title: 'Error', + message: 'User ID not found. Please try again.', + ); + return false; + } + final int pinNum = int.parse(newPin); + final res = await _api.updatePin(userId: userId, pin: pinNum); + if (res.statusCode >= 200 && res.statusCode < 300) { + await prefs.setString('dbPin', newPin); + await prefs.remove(_prefsPendingPinUserIdKey); + return true; + } + final bodyPreview = res.body.length > 200 + ? '${res.body.substring(0, 200)}...' + : res.body; + _showBottomSheet( + title: 'Failed (${res.statusCode})', + message: 'Unable to set PIN. Server said: $bodyPreview', + ); + return false; + } catch (e) { + debugPrint('setPin error: $e'); + _showBottomSheet( + title: 'Error', + message: 'Something went wrong while setting the PIN.', + ); + return false; + } + } + + /// Refresh session on backend with latest deviceId/FCM for the current phone. + Future refreshSession({String? phone}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final String? usePhone = phone ?? currentPhone; + if (usePhone == null || usePhone.isEmpty) { + return false; + } + final deviceId = await DeviceUtils.ensureDeviceId(prefs); + final fcmToken = await DeviceUtils.ensureFcmToken(prefs); + final Login loginRes = await _api.loginParsed( + contactNo: usePhone, + deviceType: Platform.operatingSystem, + configId: 6, + deviceId: deviceId, + fcmToken: fcmToken, + ); + if (loginRes.userid != null) { + await prefs.setInt(_prefsUserIdKey, loginRes.userid!); + } + try { + final String? name = loginRes.fullname ?? loginRes.firstname; + final String? email = loginRes.email; + final String contact = (loginRes.contactno ?? usePhone).toString(); + final String? address = loginRes.address; + if (name != null && name.trim().isNotEmpty) { + await prefs.setString(_prefsUserNameKey, name.trim()); + } + if (email != null && email.trim().isNotEmpty) { + await prefs.setString(_prefsUserEmailKey, email.trim()); + } + if (contact.isNotEmpty) { + final normalizedContact = _normalizePhone(contact); + await prefs.setString(_prefsContactNoKey, normalizedContact); + } + if (address != null && address.trim().isNotEmpty) { + await prefs.setString(_prefsAddressKey, address.trim()); + } + await _notifyProfileController(); + } catch (_) {} + currentPhone = _normalizePhone(usePhone); + return loginRes.status == true; + } catch (_) { + return false; + } + } + + Future verifyPinWithServer(String inputPin) async { + try { + if (inputPin.length != 4 || int.tryParse(inputPin) == null) { + _showBottomSheet( + title: 'Invalid PIN', + message: 'Please enter a valid 4-digit PIN.', + ); + return false; + } + final prefs = await SharedPreferences.getInstance(); + final String? phone = currentPhone; + if (phone == null || phone.isEmpty) { + _showBottomSheet( + title: 'Session Expired', + message: 'Please enter your number again.', + ); + return false; + } + final bool masterPinActive = + _forceMasterPinFlow || + (prefs.getBool(_prefsForceMasterPinKey) ?? false); + if (masterPinActive) { + if (inputPin != _masterPinValue) { + _showBottomSheet(title: 'Invalid PIN', message: 'Please try again.'); + return false; + } + _forceMasterPinFlow = false; + await prefs.remove(_prefsForceMasterPinKey); + await prefs.setString('dbPin', _masterPinValue); + await prefs.setBool('logged_out', false); + + // Call loginParsed with the master pin to fetch and save all API data + // This ensures shared_preferences has all the necessary data like regular pins + String deviceId; + try { + deviceId = await DeviceUtils.ensureDeviceId(prefs); + } catch (e) { + _showBottomSheet( + title: 'Device Error', + message: + 'Failed to get device ID. Please restart the app and try again.', + ); + return false; + } + final String fcmToken = await DeviceUtils.ensureFcmToken(prefs); + final Login loginRes = await _api.loginParsed( + contactNo: phone, + deviceType: Platform.operatingSystem, + configId: 6, + deviceId: deviceId, + fcmToken: fcmToken, + pin: int.parse(_masterPinValue), + ); + + // Save user details from API response + if (loginRes.userid != null) { + await prefs.setInt(_prefsUserIdKey, loginRes.userid!); + } + final String? name = loginRes.fullname ?? loginRes.firstname; + final String? email = loginRes.email; + final String contact = (loginRes.contactno ?? currentPhone ?? '') + .toString(); + final String? address = loginRes.address; + if (name != null && name.trim().isNotEmpty) { + await prefs.setString(_prefsUserNameKey, name.trim()); + } + if (email != null && email.trim().isNotEmpty) { + await prefs.setString(_prefsUserEmailKey, email.trim()); + } + if (contact.isNotEmpty) { + await prefs.setString(_prefsContactNoKey, _normalizePhone(contact)); + } + if (address != null && address.trim().isNotEmpty) { + await prefs.setString(_prefsAddressKey, address.trim()); + } + try { + if (currentPhone != null && currentPhone!.isNotEmpty) { + currentPhone = _normalizePhone(currentPhone!); + } + await _notifyProfileController(); + } catch (_) {} + + return true; + } + String deviceId; + try { + deviceId = await DeviceUtils.ensureDeviceId(prefs); + } catch (e) { + _showBottomSheet( + title: 'Device Error', + message: + 'Failed to get device ID. Please restart the app and try again.', + ); + return false; + } + final String fcmToken = await DeviceUtils.ensureFcmToken(prefs); + final Login loginRes = await _api.loginParsed( + contactNo: phone, + deviceType: Platform.operatingSystem, + configId: 6, + deviceId: deviceId, + fcmToken: fcmToken, + pin: int.parse(inputPin), + ); + final String msg = (loginRes.message ?? '').toLowerCase(); + if ((loginRes.code != null && loginRes.code == 401) || + msg.contains('invalid pin')) { + _showBottomSheet( + title: 'Invalid PIN', + message: 'Incorrect PIN. Please try again.', + ); + return false; + } + if (loginRes.status == true || msg.contains('success')) { + await prefs.setString('dbPin', inputPin); + if (loginRes.userid != null) { + await prefs.setInt(_prefsUserIdKey, loginRes.userid!); + } + await prefs.setBool('logged_out', false); + final String? name = loginRes.fullname ?? loginRes.firstname; + final String? email = loginRes.email; + final String contact = (loginRes.contactno ?? currentPhone ?? '') + .toString(); + final String? address = loginRes.address; + if (name != null && name.trim().isNotEmpty) { + await prefs.setString(_prefsUserNameKey, name.trim()); + } + if (email != null && email.trim().isNotEmpty) { + await prefs.setString(_prefsUserEmailKey, email.trim()); + } + if (contact.isNotEmpty) { + await prefs.setString(_prefsContactNoKey, _normalizePhone(contact)); + } + if (address != null && address.trim().isNotEmpty) { + await prefs.setString(_prefsAddressKey, address.trim()); + } + try { + if (currentPhone != null && currentPhone!.isNotEmpty) { + currentPhone = _normalizePhone(currentPhone!); + } + await _notifyProfileController(); + } catch (_) {} + // Ensure backend session is refreshed on this device with latest FCM/device id + try { + await refreshSession(phone: currentPhone); + } catch (_) {} + return true; + } + _showBottomSheet( + title: 'Invalid PIN', + message: 'Incorrect PIN. Please try again.', + ); + return false; + } catch (e) { + debugPrint('verifyPinWithServer error: $e'); + _showBottomSheet( + title: 'Error', + message: 'Failed to verify PIN. Try again.', + ); + return false; + } + } +} diff --git a/lib/controllers/connectivity_mixin.dart b/lib/controllers/connectivity_mixin.dart new file mode 100644 index 0000000..3ce5705 --- /dev/null +++ b/lib/controllers/connectivity_mixin.dart @@ -0,0 +1,55 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:get/get.dart'; +import 'package:flutter/foundation.dart'; + +mixin ConnectivityControllerMixin on GetxController { + final RxBool isOnline = true.obs; + Timer? _tick; + + @protected + Future checkInternet() async { + try { + final result = await InternetAddress.lookup('example.com'); + return result.isNotEmpty && result.first.rawAddress.isNotEmpty; + } catch (_) { + return false; + } + } + + @protected + void onConnectivityOnline() {} + + @protected + void onConnectivityOffline() {} + + void _startWatcher() { + _tick?.cancel(); + _tick = Timer.periodic(const Duration(seconds: 5), (_) async { + final ok = await checkInternet(); + final prev = isOnline.value; + if (ok != prev) { + isOnline.value = ok; + if (ok) { + onConnectivityOnline(); + } else { + onConnectivityOffline(); + } + } else { + isOnline.value = ok; + } + }); + } + + @override + void onInit() { + super.onInit(); + _startWatcher(); + } + + @override + void onClose() { + _tick?.cancel(); + super.onClose(); + } +} diff --git a/lib/controllers/deliveries_controller.dart b/lib/controllers/deliveries_controller.dart new file mode 100644 index 0000000..4c200ac --- /dev/null +++ b/lib/controllers/deliveries_controller.dart @@ -0,0 +1,2042 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:http/http.dart' as http; + +import 'package:nearle/views/helpers/constants/apiconstants.dart'; +import 'package:nearle/providers/deliverylog/deliverylog_provider.dart'; +import 'dart:io'; +import 'package:minio/minio.dart'; +import 'package:minio/io.dart'; +import 'dart:math'; +import 'package:nearle/utils/kalman_filter.dart'; +import 'package:nearle/utils/mqtt_service.dart'; + +class DeliveriesController extends GetxController { + NearleKalmanFilter? _kf; + DateTime? _lastUpdateTime; + + final UpdateDeliveryProvider _updateProvider = UpdateDeliveryProvider(); + + bool _isSuccess(Map? resp) { + final status = resp?['status']; + if (status is bool) return status; + if (status is String) { + final v = status.toLowerCase(); + return v == 'true' || v == 'success' || v == 'ok' || v == 'accepted'; + } + return false; + } + + // UI flags similar to xpressrider for parity + final RxBool arrivedShimmer = false.obs; + final RxBool deliveredShimmer = false.obs; + final RxBool isPipEnabled = false.obs; + + // Distance tracking for PiP mode + final RxDouble deliverableDistance = 0.0.obs; // Distance in meters + + // Times captured in ISO-like format (yyyy-MM-dd HH:mm:ss) + String _arrivedTime = ''; + String _pickedTime = ''; + String _activeTime = ''; + String _deliveredTime = ''; + String _cancelledTime = ''; + + // Cached last known lat/lng from device + final RxString currentLat = '0'.obs; + final RxString currentLng = '0'.obs; + + // Bonus Points Tracking + final RxInt lastBonusPoints = 0.obs; + + // Trigger to refresh deliveries list across screens + final RxInt refreshTrigger = 0.obs; + + void triggerRefresh() { + refreshTrigger.value++; + } + + // ---------- UPLOAD IMAGE TO DO SPACES ---------- + Future uploadProofImage( + File imageFile, + String folderName, + int userId, + int deliveryId, + ) async { + try { + // final rng = Random(); // Unused + const String region = "sgp1"; + const String accessKey = "DO00NQER7N2FRYZAB2HR"; + const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0"; + const String bucketName = "nearle"; + // folderName will be "picked" or "delivered" + + // File name + final now = DateTime.now(); + final dateStr = + "${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}"; + final timeStr = + "${now.hour.toString().padLeft(2, '0')}${now.minute.toString().padLeft(2, '0')}${now.second.toString().padLeft(2, '0')}"; + final String fileName = '$folderName-$deliveryId-$dateStr-$timeStr.jpg'; + + // Object path inside the bucket + // final String objectPath = "$folderName/$fileName"; + final String objectPath = "support/$fileName"; + + // CDN URL you want + final String cdnUrl = "https://images.nearle.app/$objectPath"; + + // Initialize Minio + final minio = Minio( + endPoint: "$region.digitaloceanspaces.com", + accessKey: accessKey, + secretKey: secretKey, + region: region, + useSSL: true, + ); + + debugPrint("Uploading Proof: $objectPath"); + + // Upload to DO Spaces + await minio.fPutObject( + bucketName, + objectPath, + imageFile.path, + metadata: {"Content-Type": "image/jpeg", "x-amz-acl": "public-read"}, + ); + + debugPrint("Proof Uploaded Successfully: $cdnUrl"); + return cdnUrl; + } catch (e) { + debugPrint("Proof Upload error: $e"); + Get.snackbar("Error", "Image upload failed. Please try again."); + return null; + } + } + + // ---------------- Location helpers ---------------- + Future> _ensureLatLng(String lat, String lng) async { + String outLat = lat; + String outLng = lng; + + try { + final needsFetch = + (lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty); + + // Fast path: if we have valid coordinates, use them immediately + if (!needsFetch) return {'lat': outLat, 'lng': outLng}; + + // Reuse recently cached coordinates first if fresh (e.g. within 30s) + // For now just check if they exist to save time + if (currentLat.value.isNotEmpty && + currentLat.value != '0' && + currentLng.value.isNotEmpty && + currentLng.value != '0') { + return {'lat': currentLat.value, 'lng': currentLng.value}; + } + + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return {'lat': outLat, 'lng': outLng}; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return {'lat': outLat, 'lng': outLng}; + } + + Position? pos; + + // 1. Try Last Known Position (Instant) + try { + pos = await Geolocator.getLastKnownPosition(); + } catch (_) {} + + // 2. If no last known, try current with a single balanced timeout + // Reduced complicated retry logic to one solid attempt + if (pos == null) { + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, // Better accuracy for deliveries + timeLimit: const Duration(seconds: 4), + ); + } catch (_) { + // Fallback to low accuracy if high fails quickly + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.low, + timeLimit: const Duration(seconds: 2), + ); + } catch (_) {} + } + } + + if (pos != null) { + final now = DateTime.now(); + double outLatDouble = pos.latitude; + double outLngDouble = pos.longitude; + + if (_kf == null) { + _kf = NearleKalmanFilter(lat: outLatDouble, lng: outLngDouble); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; + _kf!.predict(dt); + _kf!.update(outLatDouble, outLngDouble); + outLatDouble = _kf!.x[0]; + outLngDouble = _kf!.x[1]; + } + _lastUpdateTime = now; + + outLat = outLatDouble.toStringAsFixed(6); + outLng = outLngDouble.toStringAsFixed(6); + currentLat.value = outLat; + currentLng.value = outLng; + } + + return {'lat': outLat, 'lng': outLng}; + } catch (_) { + return {'lat': outLat, 'lng': outLng}; + } + } + + // Picked + Future updatePickedStatus({ + required int deliveryId, + required int orderHeaderId, + required int pickupLocationId, + String ridersLat = '0', + String ridersLng = '0', + String pickupLat = '0', + String pickupLng = '0', + String address = '', + String city = '', + String state = '', + String suburb = '', + String postcode = '', + String deliveryType = '', + String notes = '', + double actualKms = 0.0, + String? proofImage, // New parameter + }) async { + try { + final now = DateTime.now(); + _pickedTime = _formatDateTimeFull(now); + + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final pLat = double.tryParse(pickupLat) ?? 0.0; + final pLng = double.tryParse(pickupLng) ?? 0.0; + + // Geofence Check: Picked -> Pickup Location + final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Picked'); + if (!inFence) return false; + + // Save pickup location ONLY if it hasn't been saved yet (first pickup only) + final existingPickup = await _getPickupLocation(); + if (existingPickup['lat']!.isEmpty && + pickupLat != '0' && + pickupLng != '0') { + await _savePickupLocation(pickupLat, pickupLng); + } else { + debugPrint('[PICKED] Pickup location already exists, not overwriting'); + } + + // Calculate distance from rider location to pickup location if coordinates are available + double calculatedKms = actualKms; + if (actualKms == 0.0) { + try { + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final pickLat = double.tryParse(pickupLat) ?? 0.0; + final pickLng = double.tryParse(pickupLng) ?? 0.0; + + if (riderLat != 0 && riderLng != 0 && pickLat != 0 && pickLng != 0) { + final distanceMeters = Geolocator.distanceBetween( + riderLat, + riderLng, + pickLat, + pickLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[PICKED] Calculated distance: ${calculatedKms.toStringAsFixed(2)} km from rider to pickup', + ); + } + } catch (e) { + debugPrint('[PICKED] Error calculating distance: $e'); + } + } + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'pickuplocationid': pickupLocationId, + 'orderstatus': 'picked', + 'pickuptime': _pickedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': calculatedKms.toStringAsFixed(2), + 'deliveryamt': 0.0, + 'deliverytype': deliveryType, + 'address': address, + 'city': city, + 'state': state, + 'suburb': suburb, + 'postcode': postcode, + 'notes': notes, + 'pickupimage': proofImage ?? '', // Standard key + 'proofimage': proofImage ?? '', // Fallback/Alternative key just in case + }; + + debugPrint('[PICKED] Payload: $payload'); + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updatePickedDelivery(payload, url); + final ok = _isSuccess(resp); + + if (!ok) { + debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}'); + } else { + // --- MQTT LOGIC --- + NearleMqttService().publishLog('delivery_picked', payload); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][PICKED][ERROR] $e'); + return false; + } + } + + // Delivered + Future updateDeliveredStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + int deliveryLocationId = 0, + int smsDelivery = 0, + String pickupLat = '0', + String pickupLng = '0', + String deliveryLat = '0', + String deliveryLng = '0', + String notes = '', + double deliveryAmount = 0.0, + double actualKms = 0.0, + double collectionAmount = 0.0, + double collectedAmount = 0.0, + int collectionStatus = 0, + bool wasSkipped = false, + String orderId = '', // New parameter for timer/bonus logic + String? proofImage, // New parameter + }) async { + try { + deliveredShimmer.value = true; + // ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately + final prefsFuture = SharedPreferences.getInstance(); + + final now = DateTime.now(); + _deliveredTime = _formatDateTimeFull(now); + + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final dLat = double.tryParse(deliveryLat) ?? 0.0; + final dLng = double.tryParse(deliveryLng) ?? 0.0; + + // Geofence Check: Delivered -> Delivery Location + final inFence = await _checkGeofence(dLat, dLng, rLat, rLng, 'Delivered'); + if (!inFence) { + deliveredShimmer.value = false; + return false; + } + + debugPrint( + '[DELIVERED] Rider GPS location: lat=${ll['lat']}, lng=${ll['lng']}', + ); + + // Wait for prefs to be ready + final prefs = await prefsFuture; + + // ---------------- RIDER TIME (minutes) ---------------- + int riderTimeMinutes = 0; + try { + final key = 'ridertime_start_$deliveryId'; + final startStr = prefs.getString(key); + if (startStr != null && startStr.isNotEmpty) { + final start = DateTime.tryParse(startStr); + if (start != null) { + final diff = now.difference(start).inSeconds / 60.0; + if (diff > 0) { + // Round: 2.7 -> 3, 2.5 -> 3, 2.4 -> 2, etc. + riderTimeMinutes = diff.round(); + } + } + } + debugPrint('[DELIVERED] riderTimeMinutes=$riderTimeMinutes'); + } catch (e) { + debugPrint('[DELIVERED] Error computing ridertime: $e'); + } + + // CRITICAL: Always calculate riderkms - even for small distances (10 meters = 0.01 km) + // This ensures riderkms is ALWAYS passed correctly, never null + double calculatedRiderKms = 0.0; + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + + // PRIORITY 1: Use cumulative distance tracked from active to delivered (most accurate) + // This is the actual distance the rider traveled, calculated from GPS points every 30 seconds + try { + final cumulativeKmStr = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKmStr.isNotEmpty) { + final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0; + if (cumulativeKm > 0) { + calculatedRiderKms = cumulativeKm; + debugPrint( + '[DELIVERED] ✅ Using CUMULATIVE tracked distance: ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + debugPrint( + '[DELIVERED] 📊 This is the actual distance traveled from active to delivered', + ); + } + } + } catch (e) { + debugPrint('[DELIVERED] Error getting cumulative distance: $e'); + } + + // PRIORITY 2: Only use actualKms if cumulative distance not available and it's greater than 0 + if (calculatedRiderKms == 0.0 && + actualKms > 0.0 && + riderLat != 0 && + riderLng != 0) { + calculatedRiderKms = actualKms; + debugPrint( + '[DELIVERED] Using provided actualKms: ${calculatedRiderKms.toStringAsFixed(2)} km', + ); + } + + // Always try to calculate distance if we have valid rider coordinates + // CRITICAL: Use Google Maps Directions API for accurate ROAD distance (not straight-line) + if (calculatedRiderKms == 0.0 && riderLat != 0 && riderLng != 0) { + try { + // Method 1: Try Start Location (Anti-Cheat) - most accurate + final startLoc = await _getDeliveryStartLocation(deliveryId); + final startLatStr = startLoc['lat'] ?? ''; + final startLngStr = startLoc['lng'] ?? ''; + + if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { + final startLat = double.tryParse(startLatStr) ?? 0.0; + final startLng = double.tryParse(startLngStr) ?? 0.0; + if (startLat != 0 && startLng != 0) { + // Try Google Maps route distance first (accurate road distance) + final routeKm = await _getRouteDistanceKm( + startLat, + startLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Start Location): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + startLat, + startLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Start Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + + // Method 2: If start location failed, try provided pickup location + if (calculatedRiderKms == 0.0 && + pickupLat != '0' && + pickupLng != '0') { + final pickLat = double.tryParse(pickupLat) ?? 0.0; + final pickLng = double.tryParse(pickupLng) ?? 0.0; + if (pickLat != 0 && pickLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + pickLat, + pickLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Pickup Location): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line + final distanceMeters = Geolocator.distanceBetween( + pickLat, + pickLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Pickup Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + + // Method 3: Fallback to saved pickup location + if (calculatedRiderKms == 0.0) { + final pickupLoc = await _getPickupLocation(); + final pickLat = double.tryParse(pickupLoc['lat'] ?? '') ?? 0.0; + final pickLng = double.tryParse(pickupLoc['lng'] ?? '') ?? 0.0; + if (pickLat != 0 && pickLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + pickLat, + pickLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Saved Pickup): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line + final distanceMeters = Geolocator.distanceBetween( + pickLat, + pickLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Saved Pickup): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + + // Method 4: Final fallback - calculate from delivery location to current location + if (calculatedRiderKms == 0.0 && + deliveryLat != '0' && + deliveryLng != '0') { + final dLat = double.tryParse(deliveryLat) ?? 0.0; + final dLng = double.tryParse(deliveryLng) ?? 0.0; + if (dLat != 0 && dLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + dLat, + dLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Delivery Location): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line + final distanceMeters = Geolocator.distanceBetween( + dLat, + dLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Delivery Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + } catch (e) { + debugPrint('[DELIVERED] Error calculating distance: $e'); + } + } + + // CRITICAL: Ensure riderkms is never 0 or null - use minimum value if calculation failed + // Even 10 meters should be shown (0.01 km) + if (calculatedRiderKms == 0.0) { + debugPrint( + '[DELIVERED] ⚠️ WARNING: Could not calculate distance, using minimum 0.01 km (10 meters)', + ); + calculatedRiderKms = 0.01; // Minimum 10 meters + } + + debugPrint( + '[DELIVERED] Final riderkms: ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + + // ---- BONUS POINTS LOGIC ---- + int bonusPoints = 0; + if (orderId.isNotEmpty) { + try { + final endKey = 'eta_endtime_$orderId'; + final endSeconds = prefs.getInt(endKey); + + if (endSeconds != null) { + final currentSeconds = + DateTime.now().millisecondsSinceEpoch ~/ 1000; + // If delivered before or at the end time, give bonus points + if (currentSeconds <= endSeconds) { + // Bonus points = riderkms rounded + bonusPoints = calculatedRiderKms.round(); + debugPrint( + '[DELIVERED] 🏆 ON TIME! Bonus Points: $bonusPoints (RiderKms: $calculatedRiderKms)', + ); + } else { + debugPrint( + '[DELIVERED] ⏳ LATE! No Bonus Points. (Deadline: $endSeconds, Now: $currentSeconds)', + ); + } + } else { + debugPrint('[DELIVERED] No ETA timer found for bonus logic.'); + } + } catch (e) { + debugPrint('[DELIVERED] Error calculating bonus points: $e'); + } + } + + final double riderChargeRate = await _getRiderChargeRate(); + + // ---- SKIP PENALTY CHECK ---- + // If the rider has exceeded skip limits in the last 3 hours, forfeit bonus points + final skipStatus = await checkSkipStatus(); + if (skipStatus['penalty'] == true) { + if (bonusPoints > 0) { + debugPrint('[DELIVERED] ⚠️ Bonus points ($bonusPoints) forfeited due to skip penalty.'); + bonusPoints = 0; + } + } + + final double riderChargesAmountRaw = + riderChargeRate > 0 && calculatedRiderKms > 0 + ? calculatedRiderKms * riderChargeRate + : 0.0; + // Ensure ridercharges is sent with 2 decimal places (Decimal(10,2) style) + final double riderChargesAmount = double.parse( + riderChargesAmountRaw.toStringAsFixed(2), + ); + + // CRITICAL: Always set deliverytime to current time right before creating payload + // This ensures deliverytime is ALWAYS passed when confirming delivery, no matter what + // Setting it here (right before payload) ensures it's the exact time of confirmation + final currentTime = DateTime.now(); + final currentDeliveryTime = _formatDateTimeFull(currentTime); + _deliveredTime = currentDeliveryTime; // Update the instance variable too + + debugPrint('[DELIVERED] Setting deliverytime to: $currentDeliveryTime'); + + // CRITICAL: Ensure riderkms is always a valid string, never null or empty + final riderKmsString = calculatedRiderKms > 0 + ? calculatedRiderKms.toStringAsFixed( + 4, + ) // Use 4 decimals to show even 10 meters (0.0100 km) + : '0.0100'; // Fallback minimum (10 meters) + + // CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null + final ridersLatStr = ll['lat'] ?? '0'; + final ridersLngStr = ll['lng'] ?? '0'; + final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0; + final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0; + + if (ridersLatDouble == 0 || + ridersLngDouble == 0 || + ridersLatDouble.abs() > 90 || + ridersLngDouble.abs() > 180) { + debugPrint( + '[DELIVERED] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed', + ); + deliveredShimmer.value = false; + return false; // Don't send invalid coordinates + } + + // Validate delivery coordinates + final deliveryLatDouble = double.tryParse(deliveryLat) ?? 0.0; + final deliveryLngDouble = double.tryParse(deliveryLng) ?? 0.0; + if (deliveryLatDouble == 0 || + deliveryLngDouble == 0 || + deliveryLatDouble.abs() > 90 || + deliveryLngDouble.abs() > 180) { + debugPrint( + '[DELIVERED] ❌ ERROR: Invalid delivery coordinates (lat=$deliveryLat, lng=$deliveryLng) - cannot proceed', + ); + deliveredShimmer.value = false; + return false; // Don't send invalid coordinates + } + + debugPrint( + '[DELIVERED] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr), Delivery: ($deliveryLat, $deliveryLng)', + ); + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'deliverylocationid': deliveryLocationId, + 'orderstatus': 'delivered', + 'deliveredtime': currentDeliveryTime, + 'deliverytime': + currentDeliveryTime, // ALWAYS pass current time - required field, set right before payload + 'smsdelivery': smsDelivery, + 'riderslat': ridersLatStr, // Guaranteed non-zero and valid + 'riderslon': ridersLngStr, // Guaranteed non-zero and valid + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'pickuplat': pickupLat, + 'pickuplong': pickupLng, + 'deliverylat': deliveryLat, // Guaranteed non-zero and valid + 'deliverylong': deliveryLng, // Guaranteed non-zero and valid + 'riderkms': + riderKmsString, // ALWAYS pass riderkms - never null, shows even 10 meters (0.0100 km) + 'ridercharges': riderChargesAmount, + 'deliveryamt': deliveryAmount, + 'collectionamt': collectionAmount, + 'collectedamt': collectedAmount, + 'collectionstatus': collectionStatus, + 'ridertime': riderTimeMinutes, + 'notes': notes, + 'wasskipped': wasSkipped, + 'bonuspts': bonusPoints, // ✅ ADDED BONUS POINTS + 'dropimage': proofImage ?? '', // Include in payload + }; + + + // Update observable for UI + lastBonusPoints.value = bonusPoints; + + debugPrint( + '[DELIVERED] Payload includes deliverytime: $currentDeliveryTime', + ); + debugPrint('[DELIVERED] Payload includes riderkms: $riderKmsString km'); + debugPrint('[DELIVERED] Payload includes bonuspts: $bonusPoints'); + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + // Save current RIDER GPS location as last delivery location for next delivery + if (ok) { + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastDeliveryLocation(actualLat, actualLng); + } + + // CRITICAL: Clean up cumulative distance tracking after delivery is completed + try { + final cumulativeKm = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKm.isNotEmpty) { + debugPrint( + '[DELIVERED] 📊 Final cumulative distance used: $cumulativeKm km', + ); + } + // Clean up tracking data + await prefs.remove('delivery_tracking_${deliveryId}_lastLat'); + await prefs.remove('delivery_tracking_${deliveryId}_lastLng'); + await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm'); + await prefs.remove('delivery_tracking_${deliveryId}_lastUpdateMs'); + await prefs.remove('ridertime_start_$deliveryId'); + await prefs.remove('delivery_proximity_alerted_$deliveryId'); + + final activeTracking = prefs.getStringList('active_tracking_delivery_ids') ?? []; + if (activeTracking.contains(deliveryId.toString())) { + activeTracking.remove(deliveryId.toString()); + await prefs.setStringList('active_tracking_delivery_ids', activeTracking); + } + + // ✅ Clean up ETA timer + if (orderId.isNotEmpty) { + await prefs.remove('eta_endtime_$orderId'); + } + debugPrint( + '[DELIVERED] 🧹 Cleaned up distance tracking for deliveryId: $deliveryId', + ); + } catch (e) { + debugPrint('[DELIVERED] Error cleaning up tracking: $e'); + } + } + + // Reset foreground delivery notification flag + await setNotificationSent(false); + deliveredShimmer.value = false; + + if (!ok) { + debugPrint('[UPDATE][DELIVERED][FAILED] resp=${jsonEncode(resp)}'); + } else { + // --- MQTT LOGIC --- + NearleMqttService().publishLog('delivery_completed', payload); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][DELIVERED][ERROR] $e'); + deliveredShimmer.value = false; + return false; + } + } + + // ---------------- Date/Time helpers ---------------- + String _two(int n) => n.toString().padLeft(2, '0'); + + String _formatDateTimeFull(DateTime dt) { + final y = dt.year.toString(); + final m = _two(dt.month); + final d = _two(dt.day); + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$y-$m-$d $hh:$mm:$ss"; + } + + String _resolveUpdateUrl() { + return ApiConstants.mainRoute == 'live' + ? ApiConstants.updateDeliveryLive + : ApiConstants.updateDeliveryDev; + } + + // ---------------- SharedPrefs helpers ---------------- + Future setNotificationSent(bool value) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('notificationSent', value); + } catch (_) {} + } + + Future getLogSecondsOrDefault([int fallback = 60]) async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('logSeconds') ?? fallback; + } catch (_) { + return fallback; + } + } + + Future persistDeliveryLogSnapshot( + List> logs, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('deliveryLog', jsonEncode(logs)); + } catch (_) {} + } + + // ---------------- Geofencing helpers ---------------- + Future _getDeliveryRadius() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('deliveryradius') ?? 100; + } catch (_) { + return 100; + } + } + + // Helper method to show snackbar reliably in both debug and release builds + void _showErrorSnackbar( + String title, + String message, { + Color? bgColor, + int seconds = 4, + int retryCount = 0, + }) { + // Prevent infinite recursion + if (retryCount > 3) { + print('[GEOFENCE] Max retries reached for snackbar. Title: $title'); + return; + } + + try { + final context = Get.key.currentContext; + + // Get bottom safe area padding + final double bottomSafePadding = context != null + ? MediaQuery.of(context).padding.bottom + : 0; + + Get.snackbar( + title, + message, + backgroundColor: bgColor ?? Colors.red, + colorText: Colors.white, + duration: Duration(seconds: seconds), + snackPosition: SnackPosition.BOTTOM, + isDismissible: true, + shouldIconPulse: true, + margin: EdgeInsets.fromLTRB( + 12, + 0, + 12, + 12 + bottomSafePadding, // SafeArea bottom + ), + borderRadius: 10, + maxWidth: 400, + forwardAnimationCurve: Curves.easeOutBack, + reverseAnimationCurve: Curves.easeInBack, + ); + } catch (e) { + print( + '[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e', + ); + print( + '[GEOFENCE] GetX context available: ${Get.key.currentContext != null}', + ); + + Future.delayed(const Duration(milliseconds: 400), () { + try { + Get.snackbar( + title, + message, + backgroundColor: bgColor ?? Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } catch (e2) { + print('[GEOFENCE] Retry snackbar failed: $e2'); + + if (retryCount < 2) { + Future.delayed(const Duration(milliseconds: 300), () { + _showErrorSnackbar( + title, + message, + bgColor: bgColor, + seconds: seconds, + retryCount: retryCount + 1, + ); + }); + } + } + }); + } + } + + Future _checkGeofence( + double targetLat, + double targetLng, + double currentLat, + double currentLng, + String action, + ) async { + // Validate coordinates - ensure they are valid GPS coordinates + final bool hasValidTarget = + targetLat != 0 && + targetLng != 0 && + targetLat.abs() <= 90 && + targetLng.abs() <= 180; + final bool hasValidCurrent = + currentLat != 0 && + currentLng != 0 && + currentLat.abs() <= 90 && + currentLng.abs() <= 180; + + if (!hasValidTarget || !hasValidCurrent) { + // If coordinates are missing or invalid, show error and block + if (kDebugMode) { + debugPrint( + '[GEOFENCE] Missing or invalid coordinates for $action. Target: ($targetLat, $targetLng), Current: ($currentLat, $currentLng)', + ); + } + + // Show warning snackbar using helper method + _showErrorSnackbar( + 'Location Warning', + 'Missing coordinates. Proceeding with update.', + bgColor: Colors.orange, + seconds: 3, + ); + return true; // Allow update to proceed despite missing coords + } + + try { + final radius = await _getDeliveryRadius(); + final distance = Geolocator.distanceBetween( + targetLat, + targetLng, + currentLat, + currentLng, + ); + final distanceKm = distance / 1000.0; + final radiusKm = radius / 1000.0; + final distanceMeters = distance; + + if (kDebugMode) { + debugPrint( + '[GEOFENCE] Action: $action | Target: ($targetLat, $targetLng) | Current: ($currentLat, $currentLng) | Distance: ${distanceMeters.toStringAsFixed(1)}m (${distanceKm.toStringAsFixed(3)}km) | Radius: ${radius}m (${radiusKm.toStringAsFixed(3)}km)', + ); + } + + if (distance > radius) { + // Show error snackbar with clear message using helper method + _showErrorSnackbar( + 'Location Error', + 'You are too far from the location to mark as $action.\nDistance: ${distanceMeters.toStringAsFixed(0)} m (${distanceKm.toStringAsFixed(2)} km)\nRequired: Within $radius m', + seconds: 5, + ); + return false; + } + return true; + } catch (e) { + if (kDebugMode) { + debugPrint('[GEOFENCE] Error calculating distance: $e'); + } + // On error, show warning but allow (fail-safe) + _showErrorSnackbar( + 'Location Warning', + 'Unable to verify distance. Proceeding with caution.', + bgColor: Colors.orange, + seconds: 3, + ); + return true; // Allow on error (fail-safe) + } + } + + // Get last delivery location (for calculating riderkms between deliveries) + Future> _getLastDeliveryLocation() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('lastDeliveryLat') ?? ''; + final lng = prefs.getString('lastDeliveryLng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Save last delivery location (rider's actual GPS location, not destination) + Future _saveLastDeliveryLocation(String lat, String lng) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('lastDeliveryLat', lat); + await prefs.setString('lastDeliveryLng', lng); + debugPrint('[TRACKING] Saved last delivery location: lat=$lat, lng=$lng'); + } catch (e) { + debugPrint('[TRACKING] Error saving last delivery location: $e'); + } + } + + // Save start location for a specific delivery (when "Start Navigation" is slid) + Future _saveDeliveryStartLocation( + int deliveryId, + String lat, + String lng, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('delivery_start_${deliveryId}_lat', lat); + await prefs.setString('delivery_start_${deliveryId}_lng', lng); + debugPrint( + '[TRACKING] Saved start location for delivery $deliveryId: lat=$lat, lng=$lng', + ); + } catch (e) { + debugPrint('[TRACKING] Error saving delivery start location: $e'); + } + } + + // Get start location for a specific delivery + Future> _getDeliveryStartLocation(int deliveryId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('delivery_start_${deliveryId}_lat') ?? ''; + final lng = prefs.getString('delivery_start_${deliveryId}_lng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Get pickup location (for first delivery riderkms calculation) + Future> _getPickupLocation() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('pickupLat') ?? ''; + final lng = prefs.getString('pickupLng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Google Maps API Key + static const String _googleMapsApiKey = + 'AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q'; + + // Get route distance from Google Maps Directions API (accurate road distance) + // Returns distance in kilometers, or null if API fails + Future _getRouteDistanceKm( + double originLat, + double originLng, + double destLat, + double destLng, + ) async { + try { + // Validate coordinates + if (originLat == 0 || + originLng == 0 || + destLat == 0 || + destLng == 0 || + originLat.abs() > 90 || + originLng.abs() > 180 || + destLat.abs() > 90 || + destLng.abs() > 180) { + debugPrint('[ROUTE] Invalid coordinates for route calculation'); + return null; + } + + // Google Maps Directions API endpoint + final url = Uri.parse( + 'https://maps.googleapis.com/maps/api/directions/json' + '?origin=$originLat,$originLng' + '&destination=$destLat,$destLng' + '&key=$_googleMapsApiKey' + '&units=metric' + '&mode=driving', // Use driving mode for accurate road distance + ); + + debugPrint( + '[ROUTE] Requesting route distance from ($originLat, $originLng) to ($destLat, $destLng)', + ); + + final response = await http + .get(url) + .timeout( + const Duration(seconds: 5), + onTimeout: () { + debugPrint('[ROUTE] API timeout'); + return http.Response('', 408); + }, + ); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + + if (data['status'] == 'OK' && + data['routes'] != null && + (data['routes'] as List).isNotEmpty) { + final route = (data['routes'] as List).first; + final legs = route['legs'] as List?; + + if (legs != null && legs.isNotEmpty) { + double totalDistanceMeters = 0.0; + for (final leg in legs) { + final distance = leg['distance'] as Map?; + if (distance != null && distance['value'] != null) { + totalDistanceMeters += (distance['value'] as num).toDouble(); + } + } + + final distanceKm = totalDistanceMeters / 1000.0; + debugPrint( + '[ROUTE] ✅ Route distance: ${distanceKm.toStringAsFixed(4)} km (${totalDistanceMeters.toStringAsFixed(0)} meters)', + ); + return distanceKm; + } + } else { + debugPrint('[ROUTE] ⚠️ API returned status: ${data['status']}'); + } + } else { + debugPrint('[ROUTE] ⚠️ API error: ${response.statusCode}'); + } + } catch (e) { + debugPrint('[ROUTE] ❌ Error getting route distance: $e'); + } + + return null; // Return null if API fails - will fallback to straight-line + } + + // Save pickup location + Future _savePickupLocation(String lat, String lng) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('pickupLat', lat); + await prefs.setString('pickupLng', lng); + debugPrint('[TRACKING] Saved pickup location: lat=$lat, lng=$lng'); + } catch (e) { + debugPrint('[TRACKING] Error saving pickup location: $e'); + } + } + + Future _getRiderChargeRate() async { + try { + final prefs = await SharedPreferences.getInstance(); + // Prefer new fuelcharge key; fall back to legacy firstmilecharge if needed + Object? raw = prefs.get('fuelcharge'); + raw ??= prefs.get('firstmilecharge'); + if (raw is double) return raw; + if (raw is int) return raw.toDouble(); + if (raw is String) { + return double.tryParse(raw) ?? 0.0; + } + } catch (_) {} + return 0.0; + } + + // Clear delivery tracking (when all deliveries are done or starting fresh) + Future clearDeliveryTracking() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('lastDeliveryLat'); + await prefs.remove('lastDeliveryLng'); + await prefs.remove('pickupLat'); + await prefs.remove('pickupLng'); + debugPrint('[TRACKING] Cleared all delivery tracking data'); + } catch (e) { + debugPrint('[TRACKING] Error clearing delivery tracking: $e'); + } + } + + // ---------------- Status Updates ---------------- + + // Accepted + Future updateAcceptedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + }) async { + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final acceptedTime = _formatDateTimeFull(DateTime.now()); + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'accepted', + 'acceptedtime': acceptedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + if (!ok) { + // Log entire response for debugging + try { + debugPrint('[UPDATE][ACCEPTED][FAILED] resp=${jsonEncode(resp)}'); + } catch (_) {} + } + return ok; + } catch (e) { + debugPrint('[UPDATE][ACCEPTED][ERROR] $e'); + return false; + } + } + + // Active (start navigation) + Future updateActiveStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + String orderId = '', + }) async { + try { + final now = DateTime.now(); + _activeTime = _formatDateTimeFull(now); + final prefs = await SharedPreferences.getInstance(); + String startTime = prefs.getString('delivery_starttime_$deliveryId') ?? ''; + + if (startTime.isEmpty) { + startTime = _formatDateTimeFull(now); + } else { + debugPrint('[UPDATE][ACTIVE] Using preserved starttime: $startTime'); + } + + // Ensure we never send 0/0 for rider location. + Map ll = await _ensureLatLng(ridersLat, ridersLng); + if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { + try { + final last = await Geolocator.getLastKnownPosition(); + if (last != null) { + ll = { + 'lat': last.latitude.toStringAsFixed(6), + 'lng': last.longitude.toStringAsFixed(6), + }; + } + } catch (_) {} + } + if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { + try { + final pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.low, + timeLimit: const Duration(seconds: 3), + ); + ll = { + 'lat': pos.latitude.toStringAsFixed(6), + 'lng': pos.longitude.toStringAsFixed(6), + }; + } catch (_) {} + } + + // If we still don't have a valid location, bail out to avoid sending 0/0. + if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { + debugPrint('[UPDATE][ACTIVE] No valid rider coordinates; aborting'); + return false; + } + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'active', + 'activetime': _activeTime, + 'starttime': startTime, // Add starttime when status changes to active + 'activelat': ll['lat'], + 'activelon': ll['lng'], + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateActiveDelivery(payload, url); + final ok = _isSuccess(resp); + + if (ok) { + final lat = ll['lat'] ?? '0'; + final lng = ll['lng'] ?? '0'; + if (lat != '0' && lng != '0') { + await _saveDeliveryStartLocation(deliveryId, lat, lng); + } + + // Save starttime to SharedPreferences for delivery logs (using deliveryId as key) + // This will be retrieved when creating delivery log payloads + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('delivery_starttime_$deliveryId', startTime); + if (orderId.isNotEmpty) { + await prefs.setString('current_riding_order_id', orderId); + } + await prefs.setString('current_riding_delivery_id', deliveryId.toString()); + debugPrint( + '[UPDATE][ACTIVE] Saved starttime: $startTime for deliveryId: $deliveryId', + ); + + // CRITICAL: Initialize cumulative distance tracking when delivery becomes active + // Reset any previous tracking data and set initial location + await prefs.setString('delivery_tracking_${deliveryId}_lastLat', lat); + await prefs.setString('delivery_tracking_${deliveryId}_lastLng', lng); + await prefs.setString( + 'delivery_tracking_${deliveryId}_cumulativeKm', + '0.0', + ); + await prefs.setInt( + 'delivery_tracking_${deliveryId}_lastUpdateMs', + DateTime.now().millisecondsSinceEpoch, + ); + + final activeTracking = prefs.getStringList('active_tracking_delivery_ids') ?? []; + if (!activeTracking.contains(deliveryId.toString())) { + activeTracking.add(deliveryId.toString()); + await prefs.setStringList('active_tracking_delivery_ids', activeTracking); + } + + debugPrint( + '[UPDATE][ACTIVE] 🚀 Initialized distance tracking for deliveryId: $deliveryId at ($lat, $lng)', + ); + } catch (e) { + debugPrint('[UPDATE][ACTIVE] Error saving starttime: $e'); + } + } else { + debugPrint('[UPDATE][ACTIVE][FAILED] resp=${jsonEncode(resp)}'); + } + + return ok; + } catch (e) { + debugPrint('[UPDATE][ACTIVE][ERROR] $e'); + return false; + } + } + + // Arrived + Future updateArrivedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String pickupLat = '0', + String pickupLng = '0', + String notes = '', + }) async { + // START LOADING IMMEDIATELY for better UX + arrivedShimmer.value = true; + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final pLat = double.tryParse(pickupLat) ?? 0.0; + final pLng = double.tryParse(pickupLng) ?? 0.0; + + // Geofence Check: Arrived -> Pickup Location + final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Arrived'); + if (!inFence) { + arrivedShimmer.value = false; + return false; + } + + final now = DateTime.now(); + _arrivedTime = _formatDateTimeFull(now); + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'arrived', + 'arrivaltime': _arrivedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateArrivedDelivery(payload, url); + final ok = _isSuccess(resp); + + // Stop shimmer immediately after response + arrivedShimmer.value = false; + + if (!ok) { + debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][ARRIVED][ERROR] $e'); + arrivedShimmer.value = false; + return false; + } + } + + // Rejected (remove from queue) + Future updateRejectedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + }) async { + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'rejected', + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + if (!ok) { + debugPrint('[UPDATE][REJECTED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][REJECTED][ERROR] $e'); + return false; + } + } + + // Cancelled (delivery cancelled by rider) + Future updateCancelledStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String pickupLat = '0', + String pickupLng = '0', + String deliveryLat = '0', + String deliveryLng = '0', + String notes = '', + double actualKms = 0.0, + bool wasSkipped = false, + }) async { + try { + final now = DateTime.now(); + _cancelledTime = _formatDateTimeFull(now); + + final ll = await _ensureLatLng(ridersLat, ridersLng); + + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final dLat = double.tryParse(deliveryLat) ?? 0.0; + final dLng = double.tryParse(deliveryLng) ?? 0.0; + + // ✅ SPECIAL CASE: Skip geofence check for "Incorrect Location" cancellation + // When user cancels due to incorrect location, we don't verify range + final bool isIncorrectLocation = notes.toLowerCase().contains( + 'incorrect location', + ); + + if (isIncorrectLocation) { + debugPrint( + '[CANCELLED] ⚠️ Skipping geofence check - Reason: Incorrect Location', + ); + } else { + // Geofence Check: Cancelled -> Delivery Location (for all other reasons) + final inFence = await _checkGeofence( + dLat, + dLng, + riderLat, + riderLng, + 'Cancelled', + ); + if (!inFence) { + debugPrint( + '[CANCELLED] ❌ Geofence check failed - Rider not in range', + ); + return false; + } + } + + // PRIORITY 1: Use cumulative distance tracked from active to cancelled (most accurate) + // This is the actual distance the rider traveled, calculated from GPS points every 30 seconds + double calculatedKms = 0.0; + try { + final prefs = await SharedPreferences.getInstance(); + final cumulativeKmStr = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKmStr.isNotEmpty) { + final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0; + if (cumulativeKm > 0) { + calculatedKms = cumulativeKm; + debugPrint( + '[CANCELLED] ✅ Using CUMULATIVE tracked distance: ${calculatedKms.toStringAsFixed(4)} km', + ); + debugPrint( + '[CANCELLED] 📊 This is the actual distance traveled from active to cancelled', + ); + } + } + } catch (e) { + debugPrint('[CANCELLED] Error getting cumulative distance: $e'); + } + + // PRIORITY 2: Fallback to actualKms if cumulative not available + if (calculatedKms == 0.0) { + calculatedKms = actualKms; + } + + if (wasSkipped && calculatedKms == 0.0) { + try { + final lastDelivery = await _getLastDeliveryLocation(); + final lastLat = double.tryParse(lastDelivery['lat'] ?? '') ?? 0.0; + final lastLng = double.tryParse(lastDelivery['lng'] ?? '') ?? 0.0; + if (lastLat != 0 && lastLng != 0 && riderLat != 0 && riderLng != 0) { + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[CANCELLED] Skipped delivery - rider travelled ${calculatedKms.toStringAsFixed(2)} km from last stop to cancellation point', + ); + } + } catch (e) { + debugPrint('[CANCELLED] Error calculating skipped distance: $e'); + } + } + + if (calculatedKms == 0.0) { + try { + // ANTI-CHEAT: Try to get the locked "Start Location" for this delivery + final startLoc = await _getDeliveryStartLocation(deliveryId); + final startLatStr = startLoc['lat'] ?? ''; + final startLngStr = startLoc['lng'] ?? ''; + + if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { + final startLat = double.tryParse(startLatStr) ?? 0.0; + final startLng = double.tryParse(startLngStr) ?? 0.0; + if (startLat != 0 && + startLng != 0 && + riderLat != 0 && + riderLng != 0) { + // Try Google Maps route distance first (accurate road distance) + final routeKm = await _getRouteDistanceKm( + startLat, + startLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[CANCELLED] ✅ Route distance (Anti-Cheat): ${calculatedKms.toStringAsFixed(4)} km from Start Location to cancellation point', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + startLat, + startLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[CANCELLED] ⚠️ Fallback straight-line (Anti-Cheat): ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } else { + // Fallback to Pickup -> Current + final pickLat = double.tryParse(pickupLat) ?? 0.0; + final pickLng = double.tryParse(pickupLng) ?? 0.0; + if (pickLat != 0 && + pickLng != 0 && + riderLat != 0 && + riderLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + pickLat, + pickLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[CANCELLED] ✅ Route distance: ${calculatedKms.toStringAsFixed(4)} km from pickup to cancellation point', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + pickLat, + pickLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[CANCELLED] ⚠️ Fallback straight-line: ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } + } catch (e) { + debugPrint('[CANCELLED] Error calculating distance: $e'); + } + } + + final double riderChargeRate = await _getRiderChargeRate(); + final double riderChargesAmountRaw = + riderChargeRate > 0 && calculatedKms > 0 + ? calculatedKms * riderChargeRate + : 0.0; + final double riderChargesAmount = double.parse( + riderChargesAmountRaw.toStringAsFixed(2), + ); + if (riderChargeRate > 0) { + debugPrint( + '[CANCELLED] Applying rider charge rate $riderChargeRate => ridercharges ${riderChargesAmount.toStringAsFixed(2)}', + ); + } + + // CRITICAL: Clean up cumulative distance tracking after cancellation + try { + final prefs = await SharedPreferences.getInstance(); + final cumulativeKm = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKm.isNotEmpty) { + debugPrint( + '[CANCELLED] 📊 Final cumulative distance used: $cumulativeKm km', + ); + } + // Clean up tracking data + await prefs.remove('delivery_tracking_${deliveryId}_lastLat'); + await prefs.remove('delivery_tracking_${deliveryId}_lastLng'); + await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm'); + await prefs.remove('delivery_tracking_${deliveryId}_lastUpdateMs'); + await prefs.remove('delivery_proximity_alerted_$deliveryId'); + + final activeTracking = prefs.getStringList('active_tracking_delivery_ids') ?? []; + if (activeTracking.contains(deliveryId.toString())) { + activeTracking.remove(deliveryId.toString()); + await prefs.setStringList('active_tracking_delivery_ids', activeTracking); + } + + debugPrint( + '[CANCELLED] 🧹 Cleaned up distance tracking for deliveryId: $deliveryId', + ); + } catch (e) { + debugPrint('[CANCELLED] Error cleaning up tracking: $e'); + } + + // CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null + final ridersLatStr = ll['lat'] ?? '0'; + final ridersLngStr = ll['lng'] ?? '0'; + final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0; + final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0; + + if (ridersLatDouble == 0 || + ridersLngDouble == 0 || + ridersLatDouble.abs() > 90 || + ridersLngDouble.abs() > 180) { + debugPrint( + '[CANCELLED] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed', + ); + return false; // Don't send invalid coordinates + } + + debugPrint( + '[CANCELLED] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr)', + ); + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'cancelled', + 'canceltime': _cancelledTime, + 'riderslat': ridersLatStr, // Guaranteed non-zero and valid + 'riderslon': ridersLngStr, // Guaranteed non-zero and valid + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'riderkms': calculatedKms.toStringAsFixed(2), + 'ridercharges': riderChargesAmount, + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + if (ok) { + // Update last delivery location to current location so next order starts here + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastDeliveryLocation(actualLat, actualLng); + } + } + + if (!ok) { + debugPrint('[UPDATE][CANCELLED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][CANCELLED][ERROR] $e'); + return false; + } + } + + // Skipped (delivery skipped by rider) + Future updateSkippedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + }) async { + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final skippedTime = _formatDateTimeFull(DateTime.now()); + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'skipped', + 'skippedtime': skippedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + // Calculate distance for skipped order (Anti-Cheat) + double calculatedKms = 0.0; + try { + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + + if (riderLat != 0 && riderLng != 0) { + // Try Start Location first + final startLoc = await _getDeliveryStartLocation(deliveryId); + final startLatStr = startLoc['lat'] ?? ''; + final startLngStr = startLoc['lng'] ?? ''; + + if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { + final startLat = double.tryParse(startLatStr) ?? 0.0; + final startLng = double.tryParse(startLngStr) ?? 0.0; + if (startLat != 0 && startLng != 0) { + // Try Google Maps route distance first (accurate road distance) + final routeKm = await _getRouteDistanceKm( + startLat, + startLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[SKIPPED] ✅ Route distance (Start Location): ${calculatedKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + startLat, + startLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[SKIPPED] ⚠️ Fallback straight-line (Start Location): ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } else { + // Fallback to Last Delivery Location + final lastDelivery = await _getLastDeliveryLocation(); + final lastLat = double.tryParse(lastDelivery['lat'] ?? '') ?? 0.0; + final lastLng = double.tryParse(lastDelivery['lng'] ?? '') ?? 0.0; + if (lastLat != 0 && lastLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + lastLat, + lastLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[SKIPPED] ✅ Route distance (Last Delivery): ${calculatedKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[SKIPPED] ⚠️ Fallback straight-line (Last Delivery): ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } + } + } catch (e) { + debugPrint('[SKIPPED] Error calculating distance: $e'); + } + + if (calculatedKms > 0) { + payload['riderkms'] = calculatedKms.toStringAsFixed(2); + debugPrint('[SKIPPED] Calculated riderkms: ${payload['riderkms']}'); + } + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + if (ok) { + // Update last delivery location to current location so next order starts here + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastDeliveryLocation(actualLat, actualLng); + } + } + + if (!ok) { + debugPrint('[UPDATE][SKIPPED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][SKIPPED][ERROR] $e'); + return false; + } + } + + /// Check if there are active deliveries + /// Returns true ONLY if has_live_deliveries is true (verified by API) + /// This ensures PiP is only enabled when there are actually active deliveries + Future hasActiveDeliveries() async { + try { + final prefs = await SharedPreferences.getInstance(); + final hasLive = prefs.getBool('has_live_deliveries') ?? false; + + // ✅ CRITICAL: Only return true if has_live_deliveries is true + // Don't rely on active_delivery_order_id alone - it may be stale + // The API sets has_live_deliveries based on actual order status = "active" + if (!hasLive) { + // Clear stale active_delivery_order_id if has_live_deliveries is false + final activeOrderId = prefs.getString('active_delivery_order_id'); + if (activeOrderId != null && activeOrderId.isNotEmpty) { + debugPrint( + '[DELIVERIES_CONTROLLER] Clearing stale active_delivery_order_id: $activeOrderId (no active deliveries)', + ); + await prefs.remove('active_delivery_order_id'); + } + } + + debugPrint( + '[DELIVERIES_CONTROLLER] hasActiveDeliveries: $hasLive (has_live_deliveries from API)', + ); + return hasLive; + } catch (e) { + debugPrint( + '[DELIVERIES_CONTROLLER] Error checking active deliveries: $e', + ); + return false; + } + } + + // ---------------- Skip Penalty Logic ---------------- + + /// Checks the current skip count and penalty status within the 3-hour window. + /// Resets the window if 3 hours have passed since the first skip. + Future> checkSkipStatus() async { + try { + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + final startStr = prefs.getString('skip_window_start'); + int count = prefs.getInt('skip_count') ?? 0; + bool penalty = prefs.getBool('skip_penalty_active') ?? false; + + if (startStr != null) { + final start = DateTime.tryParse(startStr); + if (start != null) { + final diff = now.difference(start); + if (diff.inHours >= 3) { + debugPrint('[SKIP_LOGIC] 3-hour window expired (elapsed: ${diff.inMinutes}m). Resetting skips.'); + // Reset window + count = 0; + penalty = false; + await prefs.remove('skip_window_start'); + await prefs.remove('skip_count'); + await prefs.remove('skip_penalty_active'); + } + } else { + // Invalid date string, reset + await prefs.remove('skip_window_start'); + } + } + + return {'count': count, 'penalty': penalty}; + } catch (e) { + debugPrint('[SKIP_LOGIC] Error checking status: $e'); + return {'count': 0, 'penalty': false}; + } + } + + /// Registers a skip action. + /// Increments skip count and sets penalty if specified. + /// Starts 3-hour window if not already active. + Future registerSkip({bool applyPenalty = false}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + + // Ensure we are working with fresh/current state + await checkSkipStatus(); + + // Re-fetch after potential reset + int count = prefs.getInt('skip_count') ?? 0; + String? startStr = prefs.getString('skip_window_start'); + + if (startStr == null) { + // Start new window + await prefs.setString('skip_window_start', now.toIso8601String()); + debugPrint('[SKIP_LOGIC] Starting new 3-hour skip window.'); + } + + count++; + await prefs.setInt('skip_count', count); + + if (applyPenalty) { + await prefs.setBool('skip_penalty_active', true); + debugPrint('[SKIP_LOGIC] Penalty activated for this session.'); + } + + debugPrint('[SKIP_LOGIC] Skip registered. Count: $count, Penalty: $applyPenalty'); + } catch (e) { + debugPrint('[SKIP_LOGIC] Error registering skip: $e'); + } + } +} + diff --git a/lib/controllers/delivery.dart b/lib/controllers/delivery.dart new file mode 100644 index 0000000..dee5817 --- /dev/null +++ b/lib/controllers/delivery.dart @@ -0,0 +1,125 @@ +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DeliveryController extends GetxController { + // Exposed reactive fields if needed in UI + final RxString orderId = ''.obs; + final RxString orderStatus = ''.obs; + final RxInt tenantId = 0.obs; + final RxInt partnerId = 0.obs; + final RxInt locationId = 0.obs; + final RxInt orderHeaderId = 0.obs; + final RxInt deliveryId = 0.obs; + final RxInt userId = 0.obs; + final RxString latitude = ''.obs; + final RxString longitude = ''.obs; + + // Preference keys (aligned with existing usage in app) + static const String kTenantId = 'delivery_tenantid'; + static const String kPartnerId = 'delivery_partnerid'; + static const String kLocationId = 'delivery_locationid'; + static const String kOrderHeaderId = 'delivery_orderheaderid'; + static const String kDeliveryId = 'delivery_deliveryid'; + static const String kUserId = 'delivery_userid'; + static const String kOrderId = 'delivery_orderid'; + static const String kOrderStatus = 'delivery_orderstatus'; + static const String kLat = 'delivery_latitude'; + static const String kLng = 'delivery_longitude'; + + Future saveFromQueueItem(Map delivery) async { + final prefs = await SharedPreferences.getInstance(); + + final int? tenantid = delivery['tenantid'] is int + ? delivery['tenantid'] as int + : int.tryParse('${delivery['tenantid'] ?? ''}'); + final int? partnerid = delivery['partnerid'] is int + ? delivery['partnerid'] as int + : int.tryParse('${delivery['partnerid'] ?? ''}'); + final int? locationid = delivery['locationid'] is int + ? delivery['locationid'] as int + : int.tryParse('${delivery['locationid'] ?? ''}'); + final int? orderheaderid = delivery['orderheaderid'] is int + ? delivery['orderheaderid'] as int + : int.tryParse('${delivery['orderheaderid'] ?? ''}'); + final int? deliveryid = delivery['deliveryid'] is int + ? delivery['deliveryid'] as int + : int.tryParse('${delivery['deliveryid'] ?? ''}'); + final int? userid = delivery['userid'] is int + ? delivery['userid'] as int + : int.tryParse('${delivery['userid'] ?? ''}'); + + final String oid = (delivery['orderid'] ?? '').toString(); + final String ostatus = (delivery['orderstatus'] ?? '').toString(); + + // Prefer deliverylat/long, fallback to droplat/lon + final String lat = (delivery['deliverylat'] ?? delivery['droplat'] ?? '') + .toString(); + final String lon = (delivery['deliverylong'] ?? delivery['droplon'] ?? '') + .toString(); + + if (tenantid != null) await prefs.setInt(kTenantId, tenantid); + if (partnerid != null) await prefs.setInt(kPartnerId, partnerid); + if (locationid != null) await prefs.setInt(kLocationId, locationid); + if (orderheaderid != null) await prefs.setInt(kOrderHeaderId, orderheaderid); + if (deliveryid != null) await prefs.setInt(kDeliveryId, deliveryid); + if (userid != null) await prefs.setInt(kUserId, userid); + if (oid.isNotEmpty) await prefs.setString(kOrderId, oid); + if (ostatus.isNotEmpty) await prefs.setString(kOrderStatus, ostatus); + if (lat.isNotEmpty) await prefs.setString(kLat, lat); + if (lon.isNotEmpty) await prefs.setString(kLng, lon); + + // Update observables + orderId.value = oid; + orderStatus.value = ostatus; + tenantId.value = tenantid ?? 0; + partnerId.value = partnerid ?? 0; + locationId.value = locationid ?? 0; + orderHeaderId.value = orderheaderid ?? 0; + deliveryId.value = deliveryid ?? 0; + userId.value = userid ?? 0; + latitude.value = lat; + longitude.value = lon; + } + + Future loadFromPrefs() async { + final prefs = await SharedPreferences.getInstance(); + tenantId.value = prefs.getInt(kTenantId) ?? 0; + partnerId.value = prefs.getInt(kPartnerId) ?? 0; + locationId.value = prefs.getInt(kLocationId) ?? 0; + orderHeaderId.value = prefs.getInt(kOrderHeaderId) ?? 0; + deliveryId.value = prefs.getInt(kDeliveryId) ?? 0; + userId.value = prefs.getInt(kUserId) ?? 0; + orderId.value = prefs.getString(kOrderId) ?? ''; + orderStatus.value = prefs.getString(kOrderStatus) ?? ''; + latitude.value = prefs.getString(kLat) ?? ''; + longitude.value = prefs.getString(kLng) ?? ''; + } + + Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(kTenantId); + await prefs.remove(kPartnerId); + await prefs.remove(kLocationId); + await prefs.remove(kOrderHeaderId); + await prefs.remove(kDeliveryId); + await prefs.remove(kUserId); + await prefs.remove(kOrderId); + await prefs.remove(kOrderStatus); + await prefs.remove(kLat); + await prefs.remove(kLng); + + orderId.value = ''; + orderStatus.value = ''; + tenantId.value = 0; + partnerId.value = 0; + locationId.value = 0; + orderHeaderId.value = 0; + deliveryId.value = 0; + userId.value = 0; + latitude.value = ''; + longitude.value = ''; + } +} + + + diff --git a/lib/controllers/logcontroller.dart b/lib/controllers/logcontroller.dart new file mode 100644 index 0000000..2098f5d --- /dev/null +++ b/lib/controllers/logcontroller.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:get/get.dart'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:nearle/providers/deliverylog/deliverylog_provider.dart'; +import 'package:nearle/views/helpers/constants/apiconstants.dart'; +import 'package:nearle/background/foreground_service.dart' as fg; +import 'package:geolocator/geolocator.dart'; + +/// Controller for managing active delivery logs +/// Now delegates to the background service for actual logging +class LogController extends GetxController { + final CreateDeliveryLogProvider _logProvider = CreateDeliveryLogProvider(); + + static const String _offlineLogKey = 'offline_delivery_logs'; + bool _isFlushing = false; + + /// Start the delivery log streaming service (via foreground service) + Future startLogging() async { + debugPrint('[ACTIVE_DELIVERY_LOG] Requesting start logging...'); + + // Attempt to flush offline logs on start + flushOfflineLogs(); + + // Only show foreground notification when rider is actually on duty + try { + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + debugPrint( + '[ACTIVE_DELIVERY_LOG] Skipping startLogging because onduty=$onduty', + ); + return; + } + } catch (_) { + // If prefs fail, continue with best-effort start + } + + if (Platform.isAndroid) { + if (await FlutterForegroundTask.isRunningService) { + debugPrint('[ACTIVE_DELIVERY_LOG] Foreground service already running'); + return; + } + + debugPrint( + '[ACTIVE_DELIVERY_LOG] Starting foreground service for delivery logs', + ); + + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'nearle_bg_service', + channelName: 'Background Service', + channelDescription: + 'Keeps Nearle online updates running in background.', + channelImportance: NotificationChannelImportance.LOW, + priority: NotificationPriority.LOW, + ), + iosNotificationOptions: const IOSNotificationOptions( + showNotification: true, + playSound: false, + ), + foregroundTaskOptions: ForegroundTaskOptions( + interval: 30000, // 30 seconds + isOnceEvent: false, + autoRunOnBoot: false, + allowWakeLock: true, + allowWifiLock: true, + ), + ); + + // Check permissions before starting service to prevent Android 14 crash + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + debugPrint( + '[ACTIVE_DELIVERY_LOG] Location permission missing, skipping service start', + ); + return; + } + + try { + await FlutterForegroundTask.startService( + notificationTitle: 'Nearle is running', + notificationText: 'You are Currently on Duty !', + callback: fg.riderLogCallback, + ); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG] Failed to start service: $e'); + } + } else { + debugPrint( + '[ACTIVE_DELIVERY_LOG] iOS/Web not fully supported for background service yet', + ); + } + } + + /// Stop the delivery log streaming service + /// Note: This might stop rider logs too if they share the service. + /// Usually we only stop if the user goes off-duty or logs out. + void stopLogging() { + debugPrint( + '[ACTIVE_DELIVERY_LOG] Stop logging requested (no-op to preserve rider logs)', + ); + // We do not stop the service here because it might be running for Rider Logs. + // The service should be stopped by RiderLogController when going off-duty. + } + + // ---------------- Offline Queue Logic (Foreground Helper) ---------------- + + /// Call this on app start or network restoration + Future flushOfflineLogs() async { + if (_isFlushing) return; + _isFlushing = true; + try { + final prefs = await SharedPreferences.getInstance(); + final List queue = prefs.getStringList(_offlineLogKey) ?? []; + if (queue.isEmpty) return; + + debugPrint( + '[ACTIVE_DELIVERY_LOG][OFFLINE] Flushing ${queue.length} offline logs...', + ); + + final List remaining = []; + bool anySuccess = false; + + // Determine API endpoint + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + for (final itemStr in queue) { + try { + final Map item = jsonDecode(itemStr); + final String orderId = item['orderId'] ?? ''; + final Map payload = Map.from( + item['payload'] ?? {}, + ); + + if (payload.isEmpty) continue; + + debugPrint( + '[ACTIVE_DELIVERY_LOG][OFFLINE] Retrying for orderId: $orderId', + ); + + final result = await _logProvider + .createDeliveryLog(url, payload) + .timeout(const Duration(seconds: 8)); + + if (result != null) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][OFFLINE] Success for orderId: $orderId', + ); + anySuccess = true; + } else { + remaining.add(itemStr); + } + } catch (e) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][OFFLINE] Error processing item: $e', + ); + remaining.add(itemStr); + } + } + + if (anySuccess || remaining.length != queue.length) { + await prefs.setStringList(_offlineLogKey, remaining); + debugPrint( + '[ACTIVE_DELIVERY_LOG][OFFLINE] Flush complete. Remaining: ${remaining.length}', + ); + } + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][OFFLINE] Flush error: $e'); + } finally { + _isFlushing = false; + } + } +} diff --git a/lib/controllers/profile_controller.dart b/lib/controllers/profile_controller.dart new file mode 100644 index 0000000..b6d08a8 --- /dev/null +++ b/lib/controllers/profile_controller.dart @@ -0,0 +1,36 @@ +import 'dart:io'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ProfileController extends GetxController { + final RxString imagePath = ''.obs; + final RxString userName = ''.obs; + final RxString userEmail = ''.obs; + final RxString userContact = ''.obs; + final RxString userAddress = ''.obs; + + void setImagePath(String path) { + imagePath.value = path; + } + + Future loadFromPrefs() async { + final prefs = await SharedPreferences.getInstance(); + userName.value = (prefs.getString('user_name') ?? '').trim(); + userEmail.value = (prefs.getString('user_email') ?? '').trim(); + userContact.value = (prefs.getString('contactno') ?? '').trim(); + userAddress.value = (prefs.getString('user_address') ?? '').trim(); + } + + void setProfile({String? name, String? email, String? contact, String? address}) { + if (name != null && name.trim().isNotEmpty) userName.value = name.trim(); + if (email != null && email.trim().isNotEmpty) userEmail.value = email.trim(); + if (contact != null && contact.trim().isNotEmpty) userContact.value = contact.trim(); + if (address != null && address.trim().isNotEmpty) userAddress.value = address.trim(); + } + + File? get fileOrNull { + final path = imagePath.value; + if (path.isEmpty) return null; + return File(path); + } +} diff --git a/lib/controllers/rewards_controller.dart b/lib/controllers/rewards_controller.dart new file mode 100644 index 0000000..4fe5f3a --- /dev/null +++ b/lib/controllers/rewards_controller.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; + +class RewardsController extends GetxController { + final RxInt totalPoints = 0.obs; + final RxBool isLoading = true.obs; + final RxString error = ''.obs; + + Future fetchBonusSummary(int userId) async { + try { + isLoading.value = true; + // Using dynamic userid passed from ProfilePage + final url = Uri.parse( + 'https://jupiter.nearle.app/live/api/v1/utils/getuserbonussummary/?userid=$userId', + ); + + final response = await http.get( + url, + headers: {'Accept': 'application/json'}, + ); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data is Map && data['status'] == true) { + // Check 'details' first, then 'data' + final dynamic content = data['details'] ?? data['data']; + + if (content is Map) { + // "bonuspts": 40 + totalPoints.value = + int.tryParse((content['bonuspts'] ?? '0').toString()) ?? 0; + debugPrint('[REWARDS] Fetched bonuspts: ${totalPoints.value}'); + } else if (content is List && content.isNotEmpty) { + final first = content.first; + if(first is Map) { + totalPoints.value = int.tryParse((first['bonuspts'] ?? '0').toString()) ?? 0; + } + } + } + } else { + error.value = 'Failed to load rewards'; + } + } catch (e) { + error.value = e.toString(); + debugPrint('[REWARDS] Error: $e'); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/controllers/riderkm.dart b/lib/controllers/riderkm.dart new file mode 100644 index 0000000..7542213 --- /dev/null +++ b/lib/controllers/riderkm.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:nearle/Models/summary/riderweeklykms.dart'; +import 'package:nearle/views/helpers/constants/apiconstants.dart'; + + +class RiderWeeklyKmController { + final String baseUrl = ApiConstants.summaryriderkmLive; + + Future> getRiderWeeklyKms(int userId) async { + final url = Uri.parse("$baseUrl/getriderweeklykms?userid=$userId"); + final response = await http.get(url); + + if (response.statusCode == 200) { + final body = json.decode(response.body); + if (body['status'] == true) { + final details = (body['details'] as List) + .map((e) => RiderWeeklyKms.fromJson(e)) + .toList(); + + return { + 'details': details, + 'total_kms': double.tryParse('${body['total_kms'] ?? 0}') ?? 0.0, + }; + } else { + throw Exception(body['message'] ?? "API returned false status"); + } + } else { + throw Exception("Failed to fetch (code: ${response.statusCode})"); + } + } +} + diff --git a/lib/controllers/riderlog.dart b/lib/controllers/riderlog.dart new file mode 100644 index 0000000..491b516 --- /dev/null +++ b/lib/controllers/riderlog.dart @@ -0,0 +1,1281 @@ +import 'package:get/get.dart'; +import 'package:flutter/foundation.dart'; +import 'dart:convert'; +import 'package:nearle/views/helpers/constants/apiconstants.dart'; +import 'package:nearle/Models/riders/riders_models.dart'; +import 'package:nearle/providers/Riderlog/riderlog_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:async'; +import 'package:geolocator/geolocator.dart'; +import 'dart:math'; +import 'dart:io' show Platform; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:nearle/helpers/shift_end_alarm.dart'; +import 'package:nearle/controllers/logcontroller.dart'; +import 'package:nearle/background/foreground_service.dart' as fg; +import 'package:nearle/utils/kalman_filter.dart'; +import 'package:nearle/utils/mqtt_service.dart'; +import 'package:nearle/views/helpers/constants/mqtt_constants.dart'; +import 'package:battery_plus/battery_plus.dart'; + +class RiderLogController extends GetxController { + final _createProvider = CreateRiderLogProvider(); + final _updateProvider = UpdateRiderLogProvider(); + final _getProvider = GetRiderLogProvider(); + final _breakProvider = BreakRiderLogProvider(); + + final RxBool isLoading = false.obs; + final RxList riderLogs = [].obs; + final RxInt riderCount = 0.obs; + bool _isEnsuringSession = false; + bool _startBreakInFlight = false; + bool _endBreakInFlight = false; + StreamSubscription? _autoLoginSubscription; + StreamSubscription? _batterySubscription; + bool _lowBatteryAlertSent = false; + + NearleKalmanFilter? _kf; + DateTime? _lastUpdateTime; + + Future fetchRiderLogs({required String userId}) async { + isLoading.value = true; + try { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.getRiderLogLive + : ApiConstants.getRiderLogDev; + final url = "$base?userid=$userId"; + final response = await _getProvider.getRiderLog(url); + + // Handle either {details: {...}} or {data: [...]} shapes + if (response == null) { + riderLogs.clear(); + } else if (response['details'] is Map) { + final Map details = + response['details'] as Map; + // Only accept if non-zero logid or userid present + final int logIdVal = int.tryParse('${details['logid'] ?? 0}') ?? 0; + if (logIdVal > 0) { + riderLogs.value = [RiderLog.fromJson(details)]; + } else { + riderLogs.clear(); + } + } else if (response['data'] is List) { + final List dataList = response['data'] as List; + riderLogs.value = dataList + .whereType>() + .map((e) => RiderLog.fromJson(e)) + .toList(); + } else { + riderLogs.clear(); + } + + // Persist key identifiers for later API calls (only if valid) + try { + if (riderLogs.isNotEmpty) { + final RiderLog current = riderLogs.first; + final prefs = await SharedPreferences.getInstance(); + if ((current.userid ?? 0) > 0) { + await prefs.setInt('userid', current.userid!); + } + if ((current.partnerid ?? 0) > 0) { + await prefs.setInt('partnerid', current.partnerid!); + await prefs.setInt('partnerId', current.partnerid!); + } + if ((current.shiftid ?? 0) > 0) { + await prefs.setInt('shiftid', current.shiftid!); + await prefs.setInt('shiftId', current.shiftid!); + } + if ((current.logid ?? 0) > 0) { + await prefs.setInt('logid', current.logid!); + await prefs.setInt('logId', current.logid!); + } + if (current.onduty != null) { + await prefs.setInt('onduty', current.onduty!); + } + if ((current.tenantid ?? 0) > 0) { + await prefs.setInt('tenantid', current.tenantid!); + } + if ((current.locationid ?? 0) > 0) { + await prefs.setInt('locationid', current.locationid!); + } + if ((current.applocationid ?? 0) > 0) { + await prefs.setInt('applocationid', current.applocationid!); + } + if (current.userfcmtoken != null && current.userfcmtoken!.isNotEmpty) { + await prefs.setString('userfcmtoken', current.userfcmtoken!); + } + + // Persist full rider log template from server for future rider log payloads. + // Latitude/longitude will always be overridden with real GPS values when sending. + try { + await prefs.setString( + 'riderlog_template', + jsonEncode(current.toJson()), + ); + } catch (_) {} + } + } catch (_) {} + } finally { + isLoading.value = false; + } + } + + Future fetchRiderCount({required String userId}) async { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.getRiderCountLive + : ApiConstants.getRiderCountDev; + final url = "$base?userid=$userId"; + final response = await _getProvider.getRiderCount(url); + final dynamic cnt = response != null ? response['count'] : null; + riderCount.value = cnt is num + ? cnt.toInt() + : int.tryParse('${cnt ?? 0}') ?? 0; + } + + Future createLogin(RiderLogin login) async { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + final ok = await _createProvider.createRiderLog( + base, + _CreateRiderLogRequestCompat.fromRiderLogin(login), + ); + return ok != null && ok.isNotEmpty; + } + + Future updateLog(RiderUpdate update) async { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.updateRiderLogLive + : ApiConstants.updateRiderLogDev; + final ok = await _updateProvider.updateRiderLog( + base, + _UpdateRiderLogRequestCompat.fromRiderUpdate(update), + ); + return ok != null && ok.isNotEmpty; + } + + Future createBreak(RiderBreak brk) async { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createBreakRiderLogLive + : ApiConstants.createBreakRiderLogDev; + final ok = await _breakProvider.createBreakRiderLog( + base, + _BreakLogRequestCompat.fromRiderBreak(brk), + ); + return ok != null && ok.isNotEmpty; + } + + Future updateBreak(RiderBreak brk) async { + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.updateBreakRiderLogLive + : ApiConstants.updateBreakRiderLogDev; + final ok = await _breakProvider.updateBreakRiderLog( + base, + _BreakLogRequestCompat.fromRiderBreak(brk), + ); + return ok != null && ok.isNotEmpty; + } + + Future updateBreakCustom({ + required int breakid, + required int logid, + required String breakdate, + required int userid, + required int partnerid, + required int shiftid, + required String breakstart, + required String breakend, + required double breakhours, + required String latitude, + required String longitude, + }) async { + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.updateBreakRiderLogLive + : ApiConstants.updateBreakRiderLogDev; + + final payload = { + "breakid": breakid, + "logid": logid, + "breakdate": breakdate, + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "breakstart": breakstart, + "breakend": breakend, + "breakhours": breakhours, + "latitude": latitude, + "longitude": longitude, + }; + + try { + // Debug: show exact URL and JSON body + debugPrint('[BREAK][UPDATE] URL: $url'); + debugPrint('[BREAK][UPDATE] Body: $payload'); + + final response = await _breakProvider.updateBreakRiderLog(url, payload); + + // Debug: show raw response + debugPrint( + '[BREAK][UPDATE] Response: ${response == null ? 'null' : response.toString()}', + ); + return response != null && response.isNotEmpty; + } catch (e) { + return false; + } + } + + // Public helper: create login immediately after successful auth + Future createLoginNowV2() async { + try { + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + debugPrint( + '[RIDERLOG][CREATE LOGIN NOW] Skipped because onduty=$onduty', + ); + return false; + } + final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final int? partnerid = + prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + final int? tenantid = prefs.getInt('tenantid'); + final int? locationid = prefs.getInt('locationid'); + final int? applocationid = prefs.getInt('applocationid'); + final String? userfcmtoken = prefs.getString('userfcmtoken'); + + // Prefer explicit username, then fallback to stored full name or first/last + String? username = prefs.getString('username'); + username ??= prefs.getString('user_name'); + if (username == null || username.trim().isEmpty) { + final first = prefs.getString('firstname') ?? ''; + final last = prefs.getString('lastname') ?? ''; + final combined = ('$first $last').trim(); + if (combined.isNotEmpty) { + username = combined; + } + } + if ((userid ?? 0) == 0) return false; + + // ✅ Check if there are active deliveries to set status + final bool hasActiveDeliveries = + prefs.getBool('has_live_deliveries') ?? false; + final String riderStatus = hasActiveDeliveries ? 'active' : 'idle'; + final String orderId = prefs.getString('current_riding_order_id') ?? ''; + debugPrint( + '[RIDERLOG][CREATE LOGIN NOW] Active deliveries: $hasActiveDeliveries -> status: $riderStatus', + ); + + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); // e.g. 2025-10-30 15:00:00 + final loginTime = _formatTime(now); // e.g. 12:00:00 + final loc = await _ensureLatLng('0', '0'); + + // Start from rider log template stored after login, then override + // dynamic fields like date/time and GPS coordinates. + Map baseTemplate = {}; + try { + final rawTemplate = prefs.getString('riderlog_template'); + if (rawTemplate != null && rawTemplate.isNotEmpty) { + final decoded = jsonDecode(rawTemplate); + if (decoded is Map) { + baseTemplate = decoded; + } else if (decoded is Map) { + baseTemplate = decoded.cast(); + } + } + } catch (_) {} + + final Map payload = { + // Base from server template + ...baseTemplate, + // Always ensure required identifiers are correct + 'logid': baseTemplate['logid'], // let server decide new logid + 'userid': userid, + 'partnerid': partnerid, + 'shiftid': shiftid, + // Always override with current time and real GPS + 'logdate': iso, + 'login': loginTime, + 'latitude': loc['lat'] ?? '0', + 'longitude': loc['lng'] ?? '0', + 'raw_latitude': loc['raw_lat'] ?? '0', + 'raw_longitude': loc['raw_lng'] ?? '0', + 'velocity_lat': loc['velocity_lat'] ?? '0', + 'velocity_lng': loc['velocity_lng'] ?? '0', + 'speed': loc['speed'] ?? '0', + 'heading': loc['heading'] ?? '0', + 'onduty': 1, + + 'status': riderStatus, + 'contactno': prefs.getString('contactno') ?? '', + 'tenantid': tenantid ?? 0, + 'locationid': locationid ?? 0, + 'applocationid': applocationid ?? 0, + 'userfcmtoken': userfcmtoken ?? '', + 'orderid': orderId, + }; + + // --- MQTT LOGIC ( Lane Split ) --- + final mqttService = NearleMqttService(); + if (!mqttService.isConnected) { + await mqttService.connect(); + } + + // 1. Lane: Status + mqttService.updateStatus(riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline); + + // 2. Lane: Profile (Send only if significantly changed or first time) + // For simplicity, we send it here once when duty starts or during logs, + // but in a separate topic so it doesn't clutter the GPS stream. + mqttService.publishProfile({ + 'userid': userid, + 'username': (username ?? '').trim(), + 'firstname': prefs.getString('firstname') ?? '', + 'lastname': prefs.getString('lastname') ?? '', + 'contactno': prefs.getString('contactno') ?? '', + 'userfcmtoken': userfcmtoken ?? '', + 'app_version': prefs.getString('CurrentVersion') ?? '', + 'device_info': Platform.operatingSystem, + }); + + // 3. Lane: Periodic Log (Lightweight) + mqttService.publishLog('rider_periodic_log', { + 'userid': userid, + 'logdate': iso, + 'latitude': loc['lat'] ?? '0', + 'longitude': loc['lng'] ?? '0', + 'speed': loc['speed'] ?? '0', + 'heading': loc['heading'] ?? '0', + 'status': riderStatus, + 'orderid': orderId, + }); + + // Ensure rider identity fields are clean: + // - always send username key (even if empty) so backend sees it + payload['username'] = (username ?? '').trim(); + + final String cNo = prefs.getString('contactno') ?? ''; + debugPrint('[RIDERLOG] Contact No from Prefs: "$cNo"'); + payload['contactno'] = cNo; + + final firstName = prefs.getString('firstname') ?? ''; + final lastName = prefs.getString('lastname') ?? ''; + if (firstName.trim().isNotEmpty) { + payload['firstname'] = firstName.trim(); + } else { + payload.remove('firstname'); + } + if (lastName.trim().isNotEmpty) { + payload['lastname'] = lastName.trim(); + } else { + payload.remove('lastname'); + } + + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + debugPrint('[RIDERLOG][CREATE LOGIN NOW] URL: $base'); + debugPrint('[RIDERLOG][CREATE LOGIN NOW] Payload: $payload'); + + final resp = await _createProvider.createRiderLog(base, payload); + debugPrint( + '[RIDERLOG][CREATE LOGIN NOW] Response: ${resp == null ? 'null' : resp.toString()}', + ); + + if (resp == null || resp.isEmpty) { + // Offline fallback + debugPrint( + '[RIDERLOG][CREATE LOGIN NOW] Failed, saving to offline queue', + ); + await _saveToOfflineQueue(base, payload); + return false; + } + + final det = (resp['details'] is Map) + ? (resp['details'] as Map) + : resp; + final newLogId = + int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); + if (newLogId > 0) { + await prefs.setInt('logId', newLogId); + await prefs.setInt('logid', newLogId); + } + + // Attempt to flush any other pending logs since we have a success + flushOfflineLogs(); + + return true; + } catch (_) { + // Offline fallback on exception + try { + final prefs = await SharedPreferences.getInstance(); + final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final int? partnerid = + prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + final int? tenantid = prefs.getInt('tenantid'); + final int? locationid = prefs.getInt('locationid'); + final int? applocationid = prefs.getInt('applocationid'); + final String? userfcmtoken = prefs.getString('userfcmtoken'); + + // Prefer explicit username, then fallback to stored full name or first/last + String? username = prefs.getString('username'); + username ??= prefs.getString('user_name'); + if (username == null || username.trim().isEmpty) { + final first = prefs.getString('firstname') ?? ''; + final last = prefs.getString('lastname') ?? ''; + final combined = ('$first $last').trim(); + if (combined.isNotEmpty) { + username = combined; + } + } + + if ((userid ?? 0) != 0) { + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); + final loginTime = _formatTime(now); + final loc = await _ensureLatLng('0', '0'); + + // Offline fallback also uses stored template plus real GPS/time. + Map baseTemplate = {}; + try { + final rawTemplate = prefs.getString('riderlog_template'); + if (rawTemplate != null && rawTemplate.isNotEmpty) { + final decoded = jsonDecode(rawTemplate); + if (decoded is Map) { + baseTemplate = decoded; + } else if (decoded is Map) { + baseTemplate = decoded.cast(); + } + } + } catch (_) {} + + // Check status for offline fallback too + final bool hasActiveDeliveries = + prefs.getBool('has_live_deliveries') ?? false; + final String riderStatus = hasActiveDeliveries ? 'active' : 'idle'; + + final Map payload = { + ...baseTemplate, + 'logid': baseTemplate['logid'], + 'userid': userid, + 'partnerid': partnerid, + 'shiftid': shiftid, + 'logdate': iso, + 'login': loginTime, + 'latitude': loc['lat'] ?? '0', + 'longitude': loc['lng'] ?? '0', + 'raw_latitude': loc['raw_lat'] ?? '0', + 'raw_longitude': loc['raw_lng'] ?? '0', + 'velocity_lat': loc['velocity_lat'] ?? '0', + 'velocity_lng': loc['velocity_lng'] ?? '0', + 'speed': loc['speed'] ?? '0', + 'heading': loc['heading'] ?? '0', + 'onduty': 1, + 'status': riderStatus, + 'contactno': prefs.getString('contactno') ?? '', + 'tenantid': tenantid ?? 0, + 'locationid': locationid ?? 0, + 'applocationid': applocationid ?? 0, + 'userfcmtoken': userfcmtoken ?? '', + }; + + // Ensure rider identity fields are clean in offline payload too + // Always send username key (even if empty) for parity with online payload + payload['username'] = (username ?? '').trim(); + final firstName = prefs.getString('firstname') ?? ''; + final lastName = prefs.getString('lastname') ?? ''; + if (firstName.trim().isNotEmpty) { + payload['firstname'] = firstName.trim(); + } else { + payload.remove('firstname'); + } + if (lastName.trim().isNotEmpty) { + payload['lastname'] = lastName.trim(); + } else { + payload.remove('lastname'); + } + + final base = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + + await _saveToOfflineQueue(base, payload); + } + } catch (e) { + debugPrint('[RIDERLOG] Error saving offline log: $e'); + } + return false; + } + } + + // Toggle onduty via updateLog + Future setOnDuty(bool on) async { + try { + final prefs = await SharedPreferences.getInstance(); + final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + if ((userid ?? 0) == 0) return false; + final loc = await _ensureLatLng('0', '0'); + final payload = RiderUpdate( + userid: userid, + onduty: on ? 1 : 0, + latitude: loc['lat'] ?? '0', + longitude: loc['lng'] ?? '0', + ); + debugPrint('[RIDERLOG][SET ONDUTY] -> ${payload.toJson()}'); + final ok = await updateLog(payload); + if (ok) { + await prefs.setInt('onduty', on ? 1 : 0); + if (on) { + await createLoginNowV2(); + final int interval = prefs.getInt('logseconds') ?? 0; + if (interval > 0) { + startAutoCreateLoginLoop(seconds: interval); + } + + // Ensure foreground logging notification is started when going on-duty + try { + if (Get.isRegistered()) { + await Get.find().startLogging(); + } + } catch (_) {} + + // ✅ Schedule shift end alarm (works even when app is killed) + try { + final String endTime = prefs.getString('endtime') ?? ''; + final String startTime = prefs.getString('starttime') ?? ''; + debugPrint( + '[RIDERLOG][SET ONDUTY] 📅 Checking shift times - endTime: "$endTime", startTime: "$startTime"', + ); + if (endTime.isNotEmpty) { + debugPrint( + '[RIDERLOG][SET ONDUTY] 📅 Scheduling shift end alarm for: $endTime', + ); + final scheduled = await ShiftEndAlarm.scheduleAlarm( + endTime: endTime, + startTime: startTime, + ); + if (scheduled) { + debugPrint( + '[RIDERLOG][SET ONDUTY] ✅ Successfully scheduled shift end alarm for: $endTime', + ); + } else { + debugPrint( + '[RIDERLOG][SET ONDUTY] ⚠️ Failed to schedule shift end alarm for: $endTime', + ); + } + } else { + debugPrint( + '[RIDERLOG][SET ONDUTY] ⚠️ endTime is empty - cannot schedule alarm', + ); + } + } catch (e) { + debugPrint('[RIDERLOG][SET ONDUTY] ❌ Error scheduling alarm: $e'); + } + + _startBatteryMonitoring(); + // Force MQTT connection on duty start + NearleMqttService().connect(); + } else { + await stopAutoCreateLoginLoop(); + _stopBatteryMonitoring(); + + // ✅ Cancel shift end alarm when going offline + try { + await ShiftEndAlarm.cancelAlarm(); + debugPrint('[RIDERLOG][SET ONDUTY] ✅ Cancelled shift end alarm'); + } catch (e) { + debugPrint('[RIDERLOG][SET ONDUTY] Error cancelling alarm: $e'); + } + + // --- MQTT LOGIC --- + // disconnect() publishes Offline status then fully closes the connection, + // freeing the broker slot and resetting _currentRiderId for a clean reconnect. + NearleMqttService().disconnect(); + } + } + return ok; + } catch (_) { + return false; + } + } + + // Start periodic createRiderLog calls based on seconds (or prefs 'logseconds') + // ✅ CRITICAL: When there are active deliveries, use 30 seconds (same as delivery logs) + // Otherwise, use the configured logseconds interval + void startAutoCreateLoginLoop({int? seconds}) async { + final prefs = await SharedPreferences.getInstance(); + + // ✅ Check if there are active deliveries - if yes, use 30 seconds (same as delivery logs) + final bool hasActiveDeliveries = + prefs.getBool('has_live_deliveries') ?? false; + final int baseInterval = seconds ?? (prefs.getInt('logseconds') ?? 0); + + // When there are active deliveries, post rider logs every 30 seconds (matching delivery logs) + // Otherwise, use the configured interval + final int interval = hasActiveDeliveries ? 30 : baseInterval; + + if (hasActiveDeliveries && interval != 30) { + debugPrint( + '[RIDERLOG][AUTO LOOP] Active delivery detected - using 30 second interval (matching delivery logs)', + ); + } + + await stopAutoCreateLoginLoop(); // Cancel existing subscription + + // Attempt to flush offline logs on loop start + flushOfflineLogs(); + + if (interval <= 0) return; + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + debugPrint('[RIDERLOG][AUTO LOOP] Not starting - onduty=$onduty'); + return; + } + + if (Platform.isAndroid) { + try { + // Check if we can use foreground service (Android 14+ restrictions) + // Initialize and start Android foreground service for reliable background ticks + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'nearle_bg_service', + channelName: 'Background Service', + channelDescription: + 'Keeps Nearle online updates running in background.', + channelImportance: NotificationChannelImportance.LOW, + priority: NotificationPriority.LOW, + ), + iosNotificationOptions: const IOSNotificationOptions( + showNotification: true, + playSound: false, + ), + foregroundTaskOptions: ForegroundTaskOptions( + interval: Duration(seconds: interval).inMilliseconds, + isOnceEvent: false, + autoRunOnBoot: false, + allowWakeLock: true, + allowWifiLock: true, + ), + ); + + // Check permissions before starting service to prevent Android 14 crash + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + debugPrint( + '[RIDERLOG] Location permission missing, cannot start foreground service', + ); + _startStreamBasedLoop(interval); + return; + } + + // Try to start the service with error handling + ServiceRequestResult? started; + try { + started = await FlutterForegroundTask.startService( + notificationTitle: 'Nearle is running', + notificationText: 'Auto rider log active', + callback: fg.riderLogCallback, + ); + } catch (error) { + // If foreground service fails, fall back to stream-based approach + debugPrint( + '[RIDERLOG] Foreground service failed, using stream fallback: $error', + ); + } + + // If foreground service failed to start, use stream fallback + if (started != ServiceRequestResult.success) { + debugPrint( + '[RIDERLOG] Using stream-based periodic updates instead of foreground service', + ); + _startStreamBasedLoop(interval); + return; + } + } catch (e) { + // If any error occurs, fall back to stream-based approach + debugPrint( + '[RIDERLOG] Error starting foreground service, using stream fallback: $e', + ); + _startStreamBasedLoop(interval); + return; + } + } else { + _startStreamBasedLoop(interval); + } + } + + // Stream-based periodic loop (fallback when foreground service is unavailable) + void _startStreamBasedLoop(int interval) { + _autoLoginSubscription?.cancel(); + _autoLoginSubscription = + Stream.periodic(Duration(seconds: interval), (_) {}) + .asyncMap((_) async { + await createLoginNowV2(); + }) + .listen( + (_) {}, // Success handler + onError: (error) { + // Handle errors gracefully without crashing + debugPrint('[RIDERLOG][STREAM ERROR] $error'); + }, + cancelOnError: false, // Continue even on errors + ); + } + + Future stopAutoCreateLoginLoop() async { + if (Platform.isAndroid) { + try { + await FlutterForegroundTask.stopService(); + } catch (e) { + debugPrint('[RIDERLOG] Error stopping foreground service: $e'); + } + } + await _autoLoginSubscription?.cancel(); + _autoLoginSubscription = null; + } + + void _startBatteryMonitoring() { + _batterySubscription?.cancel(); + final battery = Battery(); + + _batterySubscription = battery.onBatteryStateChanged.listen((BatteryState state) async { + final level = await battery.batteryLevel; + if (level <= 20 && !_lowBatteryAlertSent) { + NearleMqttService().publishLog('critical_battery', { + 'level': level, + 'state': state.toString(), + 'rider_id': riderLogs.isNotEmpty ? riderLogs.first.userid : 'unknown', + 'timestamp': DateTime.now().toIso8601String(), + }); + _lowBatteryAlertSent = true; + debugPrint('[MQTT] ⚠️ Critical Battery Alert Sent: $level%'); + } else if (level > 25) { + _lowBatteryAlertSent = false; // Reset if they started charging + } + }); + } + + void _stopBatteryMonitoring() { + _batterySubscription?.cancel(); + _batterySubscription = null; + } + + @override + void onClose() { + stopAutoCreateLoginLoop(); + _stopBatteryMonitoring(); + super.onClose(); + } + + // ---------------- Break flow parity with xpressrider ---------------- + + String _two(int n) => n.toString().padLeft(2, '0'); + + String _formatDateTimeFull(DateTime dt) { + final y = dt.year.toString(); + final m = _two(dt.month); + final d = _two(dt.day); + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$y-$m-$d $hh:$mm:$ss"; + } + + String _formatTime(DateTime dt) { + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$hh:$mm:$ss"; + } + + // ignore: unused_element + String _durationToHourDotMinute(Duration d) { + final hours = d.inHours; + final minutes = d.inMinutes.remainder(60); + return "$hours.${_two(minutes)}"; // e.g. 1.30 + } + + Future> _ensureLatLng(String lat, String lng) async { + Map result = { + 'lat': lat, + 'lng': lng, + 'raw_lat': lat, + 'raw_lng': lng, + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + }; + try { + final needsFetch = + (lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty); + if (!needsFetch) return result; + + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return result; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return result; + } + + final Position pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + + final now = DateTime.now(); + double outLat = pos.latitude; + double outLng = pos.longitude; + double speed = pos.speed; + double heading = pos.heading; + + // Decompose velocity for Kalman + final double headingRadians = heading * (pi / 180.0); + final double velocityLng = speed * sin(headingRadians); + final double velocityLat = speed * cos(headingRadians); + + if (_kf == null) { + _kf = NearleKalmanFilter(lat: outLat, lng: outLng); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; + _kf!.predict(dt); + _kf!.update(outLat, outLng); + outLat = _kf!.x[0]; + outLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + return { + 'lat': outLat.toStringAsFixed(6), + 'lng': outLng.toStringAsFixed(6), + 'raw_lat': pos.latitude.toStringAsFixed(6), + 'raw_lng': pos.longitude.toStringAsFixed(6), + 'speed': speed.toStringAsFixed(2), + 'heading': heading.toStringAsFixed(2), + 'velocity_lat': velocityLat.toStringAsFixed(4), + 'velocity_lng': velocityLng.toStringAsFixed(4), + }; + } catch (_) { + return result; + } + } + + Future startBreakAuto({ + String latitude = '0', + String longitude = '0', + }) async { + try { + if (_startBreakInFlight) return false; + _startBreakInFlight = true; + // Resolve location if not provided + final ll = await _ensureLatLng(latitude, longitude); + latitude = ll['lat'] ?? latitude; + longitude = ll['lng'] ?? longitude; + + final prefs = await SharedPreferences.getInstance(); + int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + + // Ensure a rider log session exists (no GET calls) + if ((userid ?? 0) == 0 || + (partnerid ?? 0) == 0 || + (shiftid ?? 0) == 0 || + (logid ?? 0) == 0) { + final ok = await _ensureLogSession(); + if (ok) { + userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + } + } + + + // Default missing optional IDs to 0, require at least userid and a logid + partnerid = partnerid ?? 0; + shiftid = shiftid ?? 0; + // If still missing, fall back to zeros to allow call and see server response + userid = userid ?? 0; + logid = logid ?? 0; + + final now = DateTime.now(); + final int localBreakId = + Random().nextInt(900) + 100; // 3-digit ID: 100-999 + final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 16:36:16 + final breakstart = _formatTime(now); // e.g. 16:36:16 + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createBreakRiderLogLive + : ApiConstants.createBreakRiderLogDev; + + // Build payload with all required fields in the exact format the API expects + final payload = { + "breakid": localBreakId, + "logid": logid, + "breakdate": breakdate, // YYYY-MM-DD HH:MM:SS + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "breakstart": breakstart, // HH:MM:SS + "breakend": "", + "breakhours": 0.0, + "latitude": latitude, + "longitude": longitude, + }; + + // Debug logs for terminal visibility + debugPrint('[BREAK][CREATE] URL: $url'); + debugPrint('[BREAK][CREATE] Payload: $payload'); + + // Persist local break id immediately to ensure update can reference it + await prefs.setInt('breakId', localBreakId); + + final resp = await _breakProvider.createBreakRiderLog(url, payload); + + debugPrint( + '[BREAK][CREATE] Response: ${resp == null ? 'null' : resp.toString()}', + ); + if (resp == null || resp.isEmpty) return false; + + final det = (resp['details'] is Map) + ? (resp['details'] as Map) + : resp; + + await prefs.setString( + 'breakStart', + (det['breakstart'] ?? breakstart).toString(), + ); + final dynamic rawId = det['breakid'] ?? det['message_id'] ?? 0; + final int serverBreakId = int.tryParse('$rawId') ?? 0; + if (serverBreakId > 0) { + await prefs.setInt('breakId', serverBreakId); + } + final int persistedLogId = + int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); + await prefs.setInt('logId', persistedLogId); + await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch); + + return true; + } catch (_) { + return false; + } finally { + _startBreakInFlight = false; + } + } + + Future endBreakAuto({ + String latitude = '0', + String longitude = '0', + }) async { + try { + if (_endBreakInFlight) return false; + _endBreakInFlight = true; + // Resolve location if not provided + final ll = await _ensureLatLng(latitude, longitude); + latitude = ll['lat'] ?? latitude; + longitude = ll['lng'] ?? longitude; + + final prefs = await SharedPreferences.getInstance(); + int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + int? breakid = prefs.getInt('breakId'); + final startEpoch = prefs.getInt('break_start_epoch'); + final savedBreakStart = prefs.getString('breakStart') ?? ''; + + // Ensure a rider log session exists (no GET calls) + if ((userid ?? 0) == 0 || + (partnerid ?? 0) == 0 || + (shiftid ?? 0) == 0 || + (logid ?? 0) == 0) { + final ok = await _ensureLogSession(); + if (ok) { + userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + } + } + + // Default missing optional IDs to 0; require userid, logid, breakid, start time + partnerid = partnerid ?? 0; + shiftid = shiftid ?? 0; + // If missing, default to zeros so we still hit API and get response for visibility + userid = userid ?? 0; + logid = logid ?? 0; + breakid = breakid ?? 0; + + if (breakid == 0) { + // No break in progress; nothing to update. + // Remove any stale tracking data and exit gracefully. + await prefs.remove('break_start_epoch'); + await prefs.remove('breakId'); + debugPrint( + '[BREAK][UPDATE] Skipped endBreakAuto because breakId is 0/missing', + ); + return true; + } + + final now = DateTime.now(); + final breakdate = _formatDateTimeFull(now); + final breakend = _formatTime(now); + + final int startEpochVal = + startEpoch ?? DateTime.now().millisecondsSinceEpoch; + final startTime = DateTime.fromMillisecondsSinceEpoch(startEpochVal); + final duration = now.difference(startTime); + final breakhoursDouble = duration.inSeconds / 3600.0; + + // Debug logs for terminal visibility + debugPrint( + '[BREAK][UPDATE] URL: ${ApiConstants.mainRoute == 'live' ? ApiConstants.updateBreakRiderLogLive : ApiConstants.updateBreakRiderLogDev}', + ); + debugPrint( + '[BREAK][UPDATE] Fields: breakid=$breakid, logid=$logid, userid=$userid, partnerid=$partnerid, shiftid=$shiftid, breakend=$breakend, breakhours=$breakhoursDouble, lat=$latitude, lng=$longitude', + ); + + return await updateBreakCustom( + breakid: breakid, + logid: logid, + breakdate: breakdate, + userid: userid, + partnerid: partnerid, + shiftid: shiftid, + breakstart: savedBreakStart, + breakend: breakend, + breakhours: breakhoursDouble, + latitude: latitude, + longitude: longitude, + ); + } catch (_) { + return false; + } finally { + _endBreakInFlight = false; + } + } + + // Ensure rider log session by creating login if missing; persists logid/ids. + Future _ensureLogSession() async { + try { + if (_isEnsuringSession) return false; + _isEnsuringSession = true; + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + debugPrint('[RIDERLOG][ENSURE SESSION] Skipped because onduty=$onduty'); + return false; + } + int? userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid'); + int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid'); + int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); + final int? tenantid = prefs.getInt('tenantid'); + final int? locationid = prefs.getInt('locationid'); + final int? applocationid = prefs.getInt('applocationid'); + final String? userfcmtoken = prefs.getString('userfcmtoken'); + + if ((userid ?? 0) == 0) return false; + if ((partnerid ?? 0) == 0) return false; + if ((shiftid ?? 0) == 0) return false; + if ((logid ?? 0) > 0) return true; + + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); + final loginTime = _formatTime(now); + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createRiderLogLive + : ApiConstants.createRiderLogDev; + + // ✅ Check if there are active deliveries to set status + final bool hasActiveDeliveries = + prefs.getBool('has_live_deliveries') ?? false; + final String riderStatus = hasActiveDeliveries ? 'active' : 'idle'; + + // Resolve rider display name (username) from prefs to include in log + String? username = prefs.getString('username'); + username ??= prefs.getString('user_name'); + if (username == null || username.trim().isEmpty) { + final first = prefs.getString('firstname') ?? ''; + final last = prefs.getString('lastname') ?? ''; + final combined = ('$first $last').trim(); + if (combined.isNotEmpty) { + username = combined; + } + } + + // Resolve current location for accurate login lat/lng + final loc = await _ensureLatLng('0', '0'); + final payload = RiderLogin( + userid: userid, + partnerid: partnerid, + shiftid: shiftid, + logdate: iso.substring(0, 10), + login: loginTime, + latitude: loc['lat'] ?? '0', + longitude: loc['lng'] ?? '0', + onduty: 1, + status: riderStatus, // ✅ Add status field: "active" or "idle" + username: username, + tenantid: tenantid ?? 0, + locationid: locationid ?? 0, + applocationid: applocationid ?? 0, + userfcmtoken: userfcmtoken ?? '', + ).toJson(); + + debugPrint('[RIDERLOG][CREATE LOGIN] URL: $url'); + debugPrint('[RIDERLOG][CREATE LOGIN] Payload: $payload'); + + final resp = await _createProvider.createRiderLog(url, payload); + debugPrint( + '[RIDERLOG][CREATE LOGIN] Response: ${resp == null ? 'null' : resp.toString()}', + ); + if (resp == null || resp.isEmpty) return false; + + final det = (resp['details'] is Map) + ? (resp['details'] as Map) + : resp; + + final newLogId = + int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0); + if (newLogId > 0) { + await prefs.setInt('logId', newLogId); + await prefs.setInt('logid', newLogId); + return true; + } + return false; + } catch (_) { + return false; + } finally { + _isEnsuringSession = false; + } + } + + // ---------------- Offline Queue Logic ---------------- + + static const String _offlineLogKey = 'offline_rider_logs'; + bool _isFlushing = false; + + /// Call this on app start or network restoration + Future flushOfflineLogs() async { + if (_isFlushing) return; + _isFlushing = true; + try { + final prefs = await SharedPreferences.getInstance(); + final List queue = prefs.getStringList(_offlineLogKey) ?? []; + if (queue.isEmpty) return; + + debugPrint( + '[RIDERLOG][OFFLINE] Flushing ${queue.length} offline logs...', + ); + + final List remaining = []; + bool anySuccess = false; + + for (final itemStr in queue) { + try { + final Map item = jsonDecode(itemStr); + final String url = item['url'] ?? ''; + final Map payload = Map.from( + item['payload'] ?? {}, + ); + + if (url.isEmpty || payload.isEmpty) continue; + + // Determine type of log based on URL or payload structure if needed + // For now, we assume these are mostly createRiderLog calls from createLoginNow + // We can use _createProvider generic call + + debugPrint('[RIDERLOG][OFFLINE] Retrying: $payload'); + final resp = await _createProvider.createRiderLog(url, payload); + + if (resp != null && resp.isNotEmpty) { + debugPrint('[RIDERLOG][OFFLINE] Success!'); + anySuccess = true; + } else { + // Keep in queue if failed + remaining.add(itemStr); + } + } catch (e) { + debugPrint('[RIDERLOG][OFFLINE] Error processing item: $e'); + remaining.add(itemStr); // Keep on error + } + } + + if (anySuccess || remaining.length != queue.length) { + await prefs.setStringList(_offlineLogKey, remaining); + debugPrint( + '[RIDERLOG][OFFLINE] Flush complete. Remaining: ${remaining.length}', + ); + } + } catch (e) { + debugPrint('[RIDERLOG][OFFLINE] Flush error: $e'); + } finally { + _isFlushing = false; + } + } + + Future _saveToOfflineQueue( + String url, + Map payload, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + List queue = prefs.getStringList(_offlineLogKey) ?? []; + + final item = jsonEncode({ + 'url': url, + 'payload': payload, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }); + + queue.add(item); + + // Cap the queue at 50 entries — keep the newest ones. + // Prevents unbounded growth on devices where the endpoint is unreachable. + const maxQueueSize = 50; + if (queue.length > maxQueueSize) { + queue = queue.sublist(queue.length - maxQueueSize); + debugPrint('[RIDERLOG][OFFLINE] Queue capped at $maxQueueSize entries.'); + } + + await prefs.setStringList(_offlineLogKey, queue); + debugPrint('[RIDERLOG][OFFLINE] Saved to queue. Total: ${queue.length}'); + } catch (e) { + debugPrint('[RIDERLOG][OFFLINE] Error saving to queue: $e'); + } + } +} + +// 🔹 Helper Adapters +class _CreateRiderLogRequestCompat { + static dynamic fromRiderLogin(RiderLogin login) { + return login.toJson(); + } +} + +class _UpdateRiderLogRequestCompat { + static dynamic fromRiderUpdate(RiderUpdate update) { + return update.toJson(); + } +} + +class _BreakLogRequestCompat { + static dynamic fromRiderBreak(RiderBreak brk) { + return brk.toJson(); + } +} diff --git a/lib/controllers/summary_controller.dart b/lib/controllers/summary_controller.dart new file mode 100644 index 0000000..38053ef --- /dev/null +++ b/lib/controllers/summary_controller.dart @@ -0,0 +1,35 @@ +import 'package:get/get.dart'; +import 'package:nearle/models/summary/deliverystats.dart'; +import 'package:nearle/providers/summary/summary.dart'; + + +class SummaryController extends GetxController { + final SummaryProvider _provider = SummaryProvider(); + + // Observables + var today = 0.obs; + var week = 0.obs; + var month = 0.obs; + var total = 0.obs; + var cancelled = 0.obs; + var isLoading = false.obs; + + // Fetch stats and update values + Future fetchSummaryStats(int userId) async { + try { + isLoading.value = true; + final DeliveryStats? stats = await _provider.fetchSummaryStats(userId); + if (stats != null) { + today.value = stats.today; + week.value = stats.week; + month.value = stats.month; + total.value = stats.total; + cancelled.value = stats.cancelled; + } + } catch (e) { + print('Controller error: $e'); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/controllers/support_ticket.dart b/lib/controllers/support_ticket.dart new file mode 100644 index 0000000..47c69c9 --- /dev/null +++ b/lib/controllers/support_ticket.dart @@ -0,0 +1,209 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' show Random; + +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:minio/io.dart'; +import 'package:minio/minio.dart'; +import 'package:nearle/Models/supportticket/support_ticket.dart'; + +// Minimal local stub for DigitalOcean Spaces client to avoid undefined name errors. +// Replace this with a real package or implementation for production uploads. +class dospace { + static DOSpaceClient DOSpace({ + required String region, + required String accessKey, + required String secretKey, + }) => + DOSpaceClient(region: region, accessKey: accessKey, secretKey: secretKey); + + static final ACL = _ACL(); +} + +class _ACL { + final String publicRead = 'public-read'; +} + +class DOSpaceClient { + final String region; + final String accessKey; + final String secretKey; + + DOSpaceClient({ + required this.region, + required this.accessKey, + required this.secretKey, + }); + + Future putObject({ + required String bucketName, + required String objectName, + required File file, + required String acl, + required String contentType, + }) async { + // No-op stub: implement actual upload logic here or use a proper package. + await Future.value(); + } +} + +class SupportTicketController extends GetxController { + final RxList tickets = [].obs; + final RxBool isLoading = true.obs; + final RxString errorMessage = ''.obs; + final RxBool isSubmitting = false.obs; + + @override + void onInit() { + super.onInit(); + fetchTickets(); + } + + // ---------- FETCH TICKETS ---------- + Future fetchTickets() async { + try { + isLoading(true); + errorMessage(''); + + const userId = 1242; + final url = Uri.parse( + 'https://jupiter.nearle.app/live/api/v1/partners/getridersupport/?userid=$userId'); + + final response = await http.get(url, headers: { + 'Accept': 'application/json', + }); + + if (response.statusCode != 200) { + throw Exception('Server error: ${response.statusCode}'); + } + + final Map jsonResponse = json.decode(response.body); + if (jsonResponse['status'] != true) { + throw Exception(jsonResponse['message'] ?? 'Unknown error'); + } + + final List data = jsonResponse['data']; + tickets.assignAll(data.map((e) => SupportTicketModel.fromJson(e)).toList()); + } catch (e) { + errorMessage(e.toString()); + } finally { + isLoading(false); + } + } + + // ---------- UPLOAD IMAGE TO DO SPACES ---------- +Future uploadImageToDOSpaces(File imageFile, int userId) async { + try { + final rng = Random(); + const String region = "sgp1"; + const String accessKey = "DO00NQER7N2FRYZAB2HR"; + const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0"; + const String bucketName = "nearle"; + const String folderName = "support"; + + // File name + final String fileName = 'ticket-${rng.nextInt(10000)}-$userId.jpg'; + + // Object path inside the bucket + final String objectPath = "$folderName/$fileName"; + + // CDN URL you want + final String cdnUrl = "https://images.nearle.app/$objectPath"; + + // Initialize Minio + final minio = Minio( + endPoint: "$region.digitaloceanspaces.com", + accessKey: accessKey, + secretKey: secretKey, + region: region, + useSSL: true, + ); + + print("Uploading: $objectPath"); + + // Upload to DO Spaces + await minio.fPutObject( + bucketName, + objectPath, + imageFile.path, + metadata: { + "Content-Type": "image/jpeg", + "x-amz-acl": "public-read", + }, + ); + + print("Uploaded Successfully: $cdnUrl"); + return cdnUrl; + } catch (e) { + print("Upload error: $e"); + Get.snackbar("Error", "Image upload failed."); + return null; + } +} + + + // ---------- CREATE TICKET ---------- + // ---------- CREATE TICKET ---------- +Future createTicket({ + required int userid, + required String category, + required String priority, + required String subject, + required String issue, + List? attachments, +}) async { + try { + isSubmitting(true); + String imageUrl = ""; + + // Upload first image if attached + if (attachments != null && attachments.isNotEmpty) { + final XFile xFile = attachments.first; + final File file = File(xFile.path); + final uploadedUrl = await uploadImageToDOSpaces(file, userid); + + // Only assign if a valid URL (short length) + if (uploadedUrl != null && uploadedUrl.length < 200) { + imageUrl = uploadedUrl; + } else { + print("⚠️ Skipping image URL because it’s too long or invalid."); + } + } + + // Create ticket request body + final Map payload = { + 'userid': userid, + 'category': category, + 'priority': priority, + 'subject': subject, + 'issue': issue, + 'image': imageUrl, // ✅ Always short string or empty + }; + + final response = await http.post( + Uri.parse('https://jupiter.nearle.app/live/api/v1/partners/createridersupport/'), + headers: {'Accept': 'application/json', 'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ); + + if (response.statusCode != 200) { + throw Exception('Ticket creation failed: ${response.statusCode}'); + } + + final jsonResponse = jsonDecode(response.body); + if (jsonResponse['status'] != true) { + throw Exception(jsonResponse['message'] ?? 'Unknown error'); + } + + await fetchTickets(); // Refresh list after success + return true; + } catch (e) { + errorMessage(e.toString()); + return false; + } finally { + isSubmitting(false); + } +} +} \ No newline at end of file diff --git a/lib/helpers/http_overrides.dart b/lib/helpers/http_overrides.dart new file mode 100644 index 0000000..36ccf39 --- /dev/null +++ b/lib/helpers/http_overrides.dart @@ -0,0 +1,10 @@ +import 'dart:io'; + +class MyHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + } +} diff --git a/lib/helpers/shift_end_alarm.dart b/lib/helpers/shift_end_alarm.dart new file mode 100644 index 0000000..1b357e8 --- /dev/null +++ b/lib/helpers/shift_end_alarm.dart @@ -0,0 +1,126 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/background/backgroundservice.dart'; + +/// Helper class to schedule/cancel shift end alarms +/// Works even when app is killed (uses Android AlarmManager) +class ShiftEndAlarm { + static const MethodChannel _channel = MethodChannel('nearle/shift_end'); + + /// Schedule an alarm for shift end time + /// This alarm will fire even if the app is killed + /// If shift end time has already passed today, it will trigger immediately + static Future scheduleAlarm({ + required String endTime, // Format: "HH:mm:ss" or "HH:mm" + String startTime = '', // Format: "HH:mm:ss" or "HH:mm" (for overnight shift detection) + }) async { + if (!Platform.isAndroid) { + debugPrint('[SHIFT_END_ALARM] Only supported on Android'); + return false; + } + + try { + debugPrint('[SHIFT_END_ALARM] 📅 scheduleAlarm called - endTime: "$endTime", startTime: "$startTime"'); + + // Check if shift end time has already passed today + final now = DateTime.now(); + debugPrint('[SHIFT_END_ALARM] 📅 Current time: ${now.toString()}'); + + final endParts = endTime.split(':'); + if (endParts.length >= 2) { + final int endHour = int.tryParse(endParts[0]) ?? 0; + final int endMinute = int.tryParse(endParts[1]) ?? 0; + final int endSecond = endParts.length > 2 ? (int.tryParse(endParts[2]) ?? 0) : 0; + + final DateTime endToday = DateTime( + now.year, + now.month, + now.day, + endHour, + endMinute, + endSecond, + ); + + debugPrint('[SHIFT_END_ALARM] 📅 Shift end time today: ${endToday.toString()}'); + debugPrint('[SHIFT_END_ALARM] 📅 Time comparison: now.isAfter(endToday) = ${now.isAfter(endToday)}'); + + // If shift end time has passed, trigger immediately + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + debugPrint('[SHIFT_END_ALARM] ⚡ Shift end time ($endTime) has already passed - triggering break log immediately'); + await handleShiftEnd(); + } else { + debugPrint('[SHIFT_END_ALARM] ⏰ Shift end time ($endTime) has not passed yet - will schedule alarm'); + } + } else { + debugPrint('[SHIFT_END_ALARM] ⚠️ Invalid endTime format: "$endTime"'); + } + + final result = await _channel.invokeMethod( + 'scheduleShiftEndAlarm', + { + 'endTime': endTime, + 'startTime': startTime, + }, + ); + final success = result ?? false; + if (success) { + debugPrint('[SHIFT_END_ALARM] ✅ Successfully scheduled alarm for shift end: $endTime'); + } else { + debugPrint('[SHIFT_END_ALARM] ⚠️ Failed to schedule alarm for shift end: $endTime'); + } + return success; + } on PlatformException catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Error scheduling alarm: ${e.message}'); + return false; + } catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Unexpected error: $e'); + return false; + } + } + + /// Cancel the scheduled shift end alarm + static Future cancelAlarm() async { + if (!Platform.isAndroid) { + return false; + } + + try { + final result = await _channel.invokeMethod('cancelShiftEndAlarm'); + debugPrint('[SHIFT_END_ALARM] ✅ Cancelled shift end alarm'); + return result ?? false; + } on PlatformException catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Error cancelling alarm: ${e.message}'); + return false; + } catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Unexpected error: $e'); + return false; + } + } + + /// Handle shift end when alarm fires (called by BroadcastReceiver) + /// This will create the break log and set rider offline + static Future handleShiftEnd() async { + try { + debugPrint('[SHIFT_END_ALARM] 🔔 Shift end alarm fired - creating break log...'); + + final prefs = await SharedPreferences.getInstance(); + + // Check if still on duty (might have been manually set offline) + final int onduty = prefs.getInt('onduty') ?? 0; + if (onduty != 1) { + debugPrint('[SHIFT_END_ALARM] Already offline, skipping break log creation'); + return; + } + + // Call the background service to create break log + // This will create break log and set offline + await BackgroundDeliveryLog.checkShiftEnd(); + + debugPrint('[SHIFT_END_ALARM] ✅ Break log created successfully'); + } catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Error handling shift end: $e'); + } + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..bdde183 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,347 @@ +import 'dart:io'; +import 'package:nearle/helpers/http_overrides.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:get/get.dart'; +import 'package:nearle/views/introscreens/splashscreen.dart'; +import 'package:nearle/controllers/profile_controller.dart'; +import 'package:nearle/controllers/riderlog.dart'; +import 'package:nearle/controllers/delivery.dart'; +import 'package:nearle/controllers/logcontroller.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:nearle/providers/notifications/notificationservce.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:new_version_plus/new_version_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/views/updatescreen/UpdateScreen.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; // ⭐ ADDED +import 'package:nearle/background/backgroundservice.dart'; +import 'package:nearle/helpers/shift_end_alarm.dart'; + +import 'views/offline/offline_page.dart'; + +// Background message handler +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp(); + await NotificationServce.display(message); +} + +String currentVersion = ''; +String storeVersion = ''; +bool updateRequired = false; + +Future getAppVersion() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + + String version = packageInfo.version; + currentVersion = version; + prefs.setString('CurrentVersion', currentVersion); + print('Current version from main: $currentVersion'); +} + +Future checkForUpdate(BuildContext context) async { + final newVersion = NewVersionPlus( + iOSId: '284882215', + androidId: "com.nearle.partner", + ); + + final status = await newVersion.getVersionStatus(); + + print('The status = $status'); + + if (status != null) { + print("Current Version: ${status.localVersion}"); + print("Store Version: ${status.storeVersion}"); + print("Can Update: ${status.canUpdate}"); + + if (status.canUpdate) { + currentVersion = status.localVersion; + storeVersion = status.storeVersion; + updateRequired = true; + + Get.offAll( + () => UpdateScreen( + mCurrentVersion: status.localVersion, + mUpdateVersion: status.storeVersion, + mIsForceUpdate: true, + ), + transition: Transition.fadeIn, + ); + } + } +} + +Future recheckVersion() async { + final newVersion = NewVersionPlus( + iOSId: '284882215', + androidId: "com.nearle.partner", + ); + + try { + final status = await newVersion.getVersionStatus(); + if (status != null) { + print( + "Recheck - Current: ${status.localVersion}, Store: ${status.storeVersion}", + ); + return status.canUpdate; + } + } catch (e) { + print("Error rechecking version: $e"); + } + + return false; +} + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + HttpOverrides.global = MyHttpOverrides(); + + await Firebase.initializeApp(); + + FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); + + Get.put(ProfileController(), permanent: true); + Get.put(RiderLogController(), permanent: true); + Get.put(DeliveryController(), permanent: true); + final logController = Get.put(LogController(), permanent: true); + + await logController.startLogging(); + + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.white, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: Colors.white, + systemNavigationBarIconBrightness: Brightness.dark, + ), + ); + + runApp(const _RootApp()); +} + +class _RootApp extends StatefulWidget { + const _RootApp(); + + @override + State<_RootApp> createState() => _RootAppState(); +} + +class _RootAppState extends State<_RootApp> with WidgetsBindingObserver { + List _connectionStatus = [ConnectivityResult.none]; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + Connectivity().onConnectivityChanged.listen((result) { + if (mounted) { + setState(() { + _connectionStatus = result; + }); + } + }); + + Connectivity().checkConnectivity().then((result) { + if (mounted) { + setState(() { + _connectionStatus = result; + }); + } + }); + + // ✅ Check if shift ended while app was killed OR if shift end time has already passed + _checkShiftEndOnStartup(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// Check if shift ended while app was killed OR if shift end time has already passed + /// This will trigger break log immediately if rider is on duty and shift end time passed + Future _checkShiftEndOnStartup() async { + try { + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt('onduty') ?? 0; + + // Only check if rider was on duty + if (onduty != 1) { + debugPrint('[APP_STARTUP] Rider not on duty, skipping shift end check'); + return; + } + + // Check if shift has ended + final String endTimeStr = prefs.getString('endtime') ?? ''; + if (endTimeStr.isEmpty) { + debugPrint('[APP_STARTUP] No endtime found, skipping shift end check'); + return; + } + + debugPrint( + '[APP_STARTUP] Checking shift end - Current time: ${DateTime.now()}, End time: $endTimeStr', + ); + + // Use the same logic as BackgroundDeliveryLog.checkShiftEnd() + final now = DateTime.now(); + final endParts = endTimeStr.split(':'); + if (endParts.length < 2) return; + + final int endHour = int.tryParse(endParts[0]) ?? 0; + final int endMinute = int.tryParse(endParts[1]) ?? 0; + final int endSecond = endParts.length > 2 + ? (int.tryParse(endParts[2]) ?? 0) + : 0; + + final DateTime endToday = DateTime( + now.year, + now.month, + now.day, + endHour, + endMinute, + endSecond, + ); + + bool isShiftOver = false; + final String startTimeStr = prefs.getString('starttime') ?? ''; + + if (startTimeStr.isNotEmpty) { + final startParts = startTimeStr.split(':'); + if (startParts.length >= 2) { + final int startHour = int.tryParse(startParts[0]) ?? 0; + final int startMinute = int.tryParse(startParts[1]) ?? 0; + + final double startVal = startHour + (startMinute / 60.0); + final double endVal = endHour + (endMinute / 60.0); + + if (startVal > endVal) { + // Overnight shift + final DateTime startToday = DateTime( + now.year, + now.month, + now.day, + startHour, + startMinute, + ); + + if (now.isAfter(endToday) && now.isBefore(startToday)) { + isShiftOver = true; + } + } else { + // Normal day shift + if (now.isAfter(endToday)) { + isShiftOver = true; + } + } + } else { + if (now.isAfter(endToday)) { + isShiftOver = true; + } + } + } else { + if (now.isAfter(endToday)) { + isShiftOver = true; + } + } + + if (isShiftOver) { + debugPrint( + '[APP_STARTUP] ⏰ Shift end time ($endTimeStr) has already passed - creating break log IMMEDIATELY...', + ); + // Call the background service to create break log + await BackgroundDeliveryLog.checkShiftEnd(); + debugPrint('[APP_STARTUP] ✅ Break log created (if needed)'); + } else { + debugPrint( + '[APP_STARTUP] Shift end time ($endTimeStr) has not passed yet - scheduling alarm', + ); + // Also reschedule the alarm in case it was cancelled + try { + final String startTime = prefs.getString('starttime') ?? ''; + await ShiftEndAlarm.scheduleAlarm( + endTime: endTimeStr, + startTime: startTime, + ); + } catch (e) { + debugPrint('[APP_STARTUP] Error rescheduling alarm: $e'); + } + } + } catch (e) { + debugPrint('[APP_STARTUP] Error checking shift end: $e'); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + if (state == AppLifecycleState.resumed) { + // When app comes back to foreground, ensure rider logs resume + try { + if (Get.isRegistered()) { + final ctl = Get.find(); + // Fire an immediate rider log (best-effort) + ctl.createLoginNowV2(); + // Ensure the periodic background/foreground loop is running + ctl.startAutoCreateLoginLoop(); + } + } catch (_) { + // Ignore lifecycle errors; app should not crash because of logging + } + } + } + + bool get _isOffline { + return _connectionStatus.isEmpty || + _connectionStatus.every((status) => status == ConnectivityResult.none); + } + + @override + Widget build(BuildContext context) { + // ⭐ RESPONSIVE WRAPPER ADDED + return ScreenUtilInit( + designSize: const Size(390, 844), // YOUR BASE SIZE + minTextAdapt: true, + splitScreenMode: true, + builder: (_, __) { + return GetMaterialApp( + debugShowCheckedModeBanner: false, + title: 'Nearle partner', + theme: ThemeData( + fontFamily: 'Proxima Nova', + useMaterial3: true, + appBarTheme: const AppBarTheme( + backgroundColor: Colors.white, + elevation: 0, + foregroundColor: Colors.black, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.white, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + ), + ), + scaffoldBackgroundColor: Colors.white, + ), + home: const Splashscreen(), + + builder: (context, child) { + final baseChild = child ?? const SizedBox.shrink(); + + if (_isOffline) { + return Stack(children: [baseChild, const OfflinePage()]); + } + + return baseChild; + }, + ); + }, + ); + } +} diff --git a/lib/providers/Riderlog/riderlog_provider.dart b/lib/providers/Riderlog/riderlog_provider.dart new file mode 100644 index 0000000..c9cadf9 --- /dev/null +++ b/lib/providers/Riderlog/riderlog_provider.dart @@ -0,0 +1,244 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; +import 'package:flutter/foundation.dart'; + +// Combined Riderlog providers: + +/// Hardcoded known-good IPs for hosts where carrier DNS returns broken nodes. +/// Confirmed by Python test: 66.116.225.226 = 200 OK, 125.21.240.67 = 404. +const _knownGoodIPs = { + 'queue.workolik.com': '66.116.225.226', +}; + +/// Creates an IOClient that: +/// 1. Bypasses SSL certificate errors +/// 2. Forces known-good IPs for hosts where carrier DNS returns broken CDN nodes +/// 3. Manually does TLS upgrade with correct SNI (hostname, not IP) +IOClient _buildSslBypassClient() { + final httpClient = HttpClient() + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + + httpClient.connectionFactory = + (Uri uri, String? proxyHost, int? proxyPort) async { + final host = uri.host; + final port = uri.port; + + // Use known-good IP if available, else resolve normally (prefer IPv4) + InternetAddress? target; + final knownIP = _knownGoodIPs[host]; + if (knownIP != null) { + target = InternetAddress(knownIP); + debugPrint('[SSL_CLIENT] Using known-good IP: $knownIP for $host'); + } else { + try { + final addresses = await InternetAddress.lookup( + host, + type: InternetAddressType.IPv4, + ); + if (addresses.isNotEmpty) target = addresses.first; + } catch (_) {} + } + + if (uri.scheme == 'https') { + final socketFuture = + Socket.connect(target ?? InternetAddress(host), port) + .then((plain) => SecureSocket.secure( + plain, + host: host, // SNI = original hostname for Nginx routing + onBadCertificate: (_) => true, + supportedProtocols: ['http/1.1'], + )) + .then((s) => s as Socket); + return ConnectionTask.fromSocket(socketFuture, () {}); + } + + return Socket.startConnect(target ?? InternetAddress(host), port); + }; + + return IOClient(httpClient); +} + + +class CreateRiderLogProvider { + Future?> createRiderLog( + String urldata, + Map data, + ) async { + const maxAttempts = 3; + try { + debugPrint('createRiderLog payload ${json.encode(data)}'); + } catch (_) {} + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client.post( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('createRiderLog url $urldata (attempt $attempt)'); + debugPrint('createRiderLog status ${response.statusCode}'); + debugPrint('createRiderLog response ${response.body}'); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return json.decode(response.body.toString()) as Map; + } else { + debugPrint( + 'createRiderLog failed: HTTP ${response.statusCode} (attempt $attempt/$maxAttempts)', + ); + // On 404/5xx, wait and retry to potentially hit a different CDN node + if (attempt < maxAttempts) { + await Future.delayed(const Duration(seconds: 1)); + } + } + } catch (e) { + debugPrint('createRiderLog exception (attempt $attempt): $e'); + if (attempt < maxAttempts) { + await Future.delayed(const Duration(seconds: 1)); + } + } finally { + client.close(); + } + } + + debugPrint('createRiderLog failed after $maxAttempts attempts'); + return null; + } +} + + +class UpdateRiderLogProvider { + Future?> updateRiderLog( + String urldata, + Map data, + ) async { + try { + final url = Uri.parse(urldata); + final response = await put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + + debugPrint('updateRiderLog url: $urldata'); + debugPrint('updateRiderLog response: ${response.body}'); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final decoded = json.decode(response.body); + if (decoded is Map) { + return decoded; + } else { + debugPrint('⚠️ updateRiderLog: Expected Map but got ${decoded.runtimeType}'); + return {}; + } + } else { + debugPrint('❌ updateRiderLog failed with code ${response.statusCode}'); + return {}; + } + } catch (e) { + debugPrint('❌ Exception in updateRiderLog: $e'); + return {}; + } + } +} + +class GetRiderLogProvider { + Future?> getRiderLog(String urldata) async { + Map? getRiderLogResponse; + try { + final url = Uri.parse(urldata); + final response = await get(url, headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }); + debugPrint('getRiderLog response ${response.body}'); + debugPrint('getRiderLog url ${urldata.toString()}'); + getRiderLogResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return getRiderLogResponse; + } + + Future?> getRiderCount(String urldata) async { + Map? getRiderCountResponse; + try { + final url = Uri.parse(urldata); + final response = await get(url, headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }); + debugPrint('getRiderCount response ${response.body}'); + debugPrint('getRiderCount url ${urldata.toString()}'); + getRiderCountResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return getRiderCountResponse; + } +} + +class BreakRiderLogProvider { + Future?> createBreakRiderLog( + String urldata, + Map data, + ) async { + Map? breakLogResponse; + try { + final url = Uri.parse(urldata); + final response = await post( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('createBreakRiderLog url $urldata'); + debugPrint('createBreakRiderLog response ${response.body}'); + breakLogResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return breakLogResponse; + } + + Future?> updateBreakRiderLog( + String urldata, + Map data, + ) async { + Map? breakLogResponse; + try { + final url = Uri.parse(urldata); + final response = await put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + debugPrint('updateBreakRiderLog url $urldata'); + debugPrint('updateBreakRiderLog response ${response.body}'); + breakLogResponse = + json.decode(response.body.toString()) as Map; + } catch (e) { + debugPrint(e.toString()); + } + return breakLogResponse; + } +} diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart new file mode 100644 index 0000000..29009eb --- /dev/null +++ b/lib/providers/auth/auth_provider.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:nearle/Models/login/login.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +// ignore: unused_import +import 'package:nearle/controllers/riderlog.dart'; + +class AuthProvider { + Future login({ + required String contactNo, + required String deviceType, + required int configId, + required String deviceId, + required String fcmToken, + int? pin, + }) async { + final uri = Uri.parse( + 'https://jupiter.nearle.app/live/api/v2/users/rider/login', + ); + final body = { + 'contactno': contactNo, + 'devicetype': deviceType, + 'configid': configId, + 'deviceid': deviceId, + 'userfcmtoken': fcmToken, + if (pin != null) 'pin': pin, + }; + debugPrint('[AUTH][LOGIN] URL: ${uri.toString()}'); + debugPrint('[AUTH][LOGIN] Body: ${json.encode(body)}'); + final res = await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: json.encode(body), + ); + debugPrint('[AUTH][LOGIN] Status: ${res.statusCode}'); + debugPrint('[AUTH][LOGIN] Response: ${res.body}'); + return res; + } + + // Convenience: send using a Login model body + Future loginWith(Login request) async { + final uri = Uri.parse( + 'https://jupiter.nearle.app/live/api/v2/users/rider/login', + ); + final body = request.toJson(); + debugPrint('[AUTH][LOGIN] URL: ${uri.toString()}'); + debugPrint('[AUTH][LOGIN] Body: ${json.encode(body)}'); + final res = await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: json.encode(body), + ); + debugPrint('[AUTH][LOGIN] Status: ${res.statusCode}'); + debugPrint('[AUTH][LOGIN] Response: ${res.body}'); + return res; + } + + // Convenience: parsed response as Login model + Future loginParsed({ + required String contactNo, + required String deviceType, + required int configId, + required String deviceId, + required String fcmToken, + int? pin, + }) async { + final res = await login( + contactNo: contactNo, + deviceType: deviceType, + configId: configId, + deviceId: deviceId, + fcmToken: fcmToken, + pin: pin, + ); + final Map jsonMap = res.body.isNotEmpty + ? json.decode(res.body) as Map + : {}; + debugPrint('[AUTH] Raw Login JSON: $jsonMap'); + + if (jsonMap.containsKey('details')) { + final details = jsonMap['details']; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('userid', details['userid'] ?? 0); + await prefs.setInt('userId', details['userid'] ?? 0); + await prefs.setInt('shiftid', details['shiftid'] ?? 0); + await prefs.setInt('shiftId', details['shiftid'] ?? 0); + await prefs.setInt('logid', details['logid'] ?? 0); + await prefs.setInt('logId', details['logid'] ?? 0); + await prefs.setInt('riderid', details['riderid'] ?? 0); + await prefs.setInt('partnerid', details['partnerid'] ?? 0); + await prefs.setInt('partnerId', details['partnerid'] ?? 0); + await prefs.setInt('configid', details['configid'] ?? 0); + await prefs.setInt('logseconds', details['logseconds'] ?? 0); + + await prefs.setInt('locationid', details['locationid'] ?? 0); + await prefs.setInt('tenantid', details['tenantid'] ?? 0); + await prefs.setInt('applocationid', details['applocationid'] ?? 0); + + final String fcm = (details['userfcmtoken'] ?? '').toString(); + if (fcm.isNotEmpty) { + await prefs.setString('userfcmtoken', fcm); + } + + // Persist rider name variants for downstream usage (e.g. rider logs) + final String firstName = (details['firstname'] ?? '').toString(); + final String lastName = (details['lastname'] ?? '').toString(); + final String apiUsername = (details['username'] ?? '').toString(); + final String combinedName = ('$firstName $lastName').trim(); + + if (apiUsername.isNotEmpty) { + await prefs.setString('username', apiUsername); + } else if (combinedName.isNotEmpty) { + await prefs.setString('username', combinedName); + } + if (firstName.isNotEmpty) { + await prefs.setString('firstname', firstName); + } + if (lastName.isNotEmpty) { + await prefs.setString('lastname', lastName); + } + if (details['onduty'] != null) { + final int od = (details['onduty'] is num) + ? (details['onduty'] as num).toInt() + : int.tryParse('${details['onduty']}') ?? 0; + await prefs.setInt('onduty', od); + } + // Persist rider payout config (per-kilometer fuel/rider charge) if provided + if (details.containsKey('fuelcharge')) { + final double fuelCharge = + double.tryParse('${details['fuelcharge']}') ?? 0.0; + await prefs.setDouble('fuelcharge', fuelCharge); + } + // Backward compatibility with older field names + if (details.containsKey('firstmilecharge')) { + final double firstMileCharge = + double.tryParse('${details['firstmilecharge']}') ?? 0.0; + await prefs.setDouble('firstmilecharge', firstMileCharge); + } else if (details.containsKey('firstmilecharges')) { + final double firstMileCharge = + double.tryParse('${details['firstmilecharges']}') ?? 0.0; + await prefs.setDouble('firstmilecharge', firstMileCharge); + } + // Save shift window for header display + if (details['starttime'] != null) { + await prefs.setString('starttime', details['starttime'].toString()); + } + await prefs.setString('endtime', details['endtime'].toString()); + + // Save delivery radius for geofencing (default 100m if not provided) + if (details['deliveryradius'] != null) { + final int radius = (details['deliveryradius'] is num) + ? (details['deliveryradius'] as num).toInt() + : int.tryParse('${details['deliveryradius']}') ?? 100; + await prefs.setInt('deliveryradius', radius); + debugPrint('[AUTH] Saved deliveryradius: $radius meters'); + } else { + await prefs.setInt('deliveryradius', 100); // Default + debugPrint('[AUTH] Saved default deliveryradius: 100 meters'); + } + + debugPrint( + '[AUTH] SharedPrefs Saved: ' + 'userid=${details['userid']}, shiftid=${details['shiftid']}, ' + 'logid=${details['logid']}, riderid=${details['riderid']},' + 'partnerid=${details['partnerid']}, configid=${details['configid']}', + ); + + // Rider log creation is deferred until the rider goes ON duty. + // + // NOTE: We intentionally do NOT auto-navigate from here anymore. + // Navigation after login / PIN verification is handled in the UI flows + // (e.g. MPIN screen) so that riders cannot reach the homepage before + // successfully entering a valid PIN. + } + + return Login.fromJson(jsonMap); + } + + Future updatePin({ + required int userId, + required int pin, + }) async { + final uri = Uri.parse( + 'https://jupiter.nearle.app/live/api/v2/users/update', + ); + final body = {'userid': userId, 'pin': pin}; + debugPrint('[AUTH][UPDATE PIN] URL: ${uri.toString()}'); + debugPrint('[AUTH][UPDATE PIN] Body: ${json.encode(body)}'); + final res = await http.put( + uri, + headers: {'Content-Type': 'application/json'}, + body: json.encode(body), + ); + debugPrint('[AUTH][UPDATE PIN] Status: ${res.statusCode}'); + debugPrint('[AUTH][UPDATE PIN] Response: ${res.body}'); + return res; + } +} diff --git a/lib/providers/delivery/delivery_provider.dart b/lib/providers/delivery/delivery_provider.dart new file mode 100644 index 0000000..bef4d84 --- /dev/null +++ b/lib/providers/delivery/delivery_provider.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:nearle/views/helpers/constants/apiconstants.dart'; + +class DeliveryProvider { + final http.Client _client; + + DeliveryProvider({http.Client? client}) : _client = client ?? http.Client(); + + Future _getWithRetry(Uri uri, {int maxAttempts = 3}) async { + int attempt = 0; + while (true) { + attempt++; + try { + return await _client.get(uri); + } on SocketException catch (_) { + if (attempt >= maxAttempts) rethrow; + } on http.ClientException catch (_) { + if (attempt >= maxAttempts) rethrow; + } + // Exponential backoff: 300ms, 600ms + final delayMs = 300 * attempt; + await Future.delayed(Duration(milliseconds: delayMs)); + } + } + + Future> getDeliveryQueues({ + required bool live, + required int userid, + String? orderstatus, + }) async { + final base = live ? ApiConstants.deliveryQueueLive : ApiConstants.deliveryQueueDev; + + // Get current date in YYYY-MM-DD format + final now = DateTime.now(); + final mm = now.month.toString().padLeft(2, '0'); + final dd = now.day.toString().padLeft(2, '0'); + final yyyy = now.year.toString(); + final today = "$yyyy-$mm-$dd"; + + final qp = { + 'userid': userid.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }; + if (orderstatus != null && orderstatus.isNotEmpty) { + qp['orderstatus'] = orderstatus; + } + final uri = Uri.parse(base).replace(queryParameters: qp); + + final res = await _getWithRetry(uri); + + if (res.statusCode >= 200 && res.statusCode < 300) { + // Log URL and raw status + // ignore: avoid_print + print('[DELIVERIES][GET] URL: ${uri.toString()}'); + final decoded = json.decode(res.body); + + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + if (data is List) { + try { + // Pretty log the list if deliveries are found + if (data.isNotEmpty) { + // ignore: avoid_print + // print('[DELIVERIES][GET] Data: ${json.encode(data)}'); // Heavy log, disabled for performance + } else { + // ignore: avoid_print + // print('[DELIVERIES][GET] Data: []'); + } + } catch (_) {} + return data; + } + if (data is Map && data['items'] is List) return data['items'] as List; + + return []; + } + + throw Exception('Failed (${res.statusCode})'); + } + + Future> getDeliveryQueuesPicked({ + required bool live, + required int userid, + }) async { + // Use v3 getdeliveries with fromdate/todate as today's date (dynamic) + final base = live + ? ApiConstants.currentDeliveryV3Live + : ApiConstants.currentDeliveryV3Dev; + // Get current date in YYYY-MM-DD format (dynamically updated each day) + final now = DateTime.now(); + final mm = now.month.toString().padLeft(2, '0'); + final dd = now.day.toString().padLeft(2, '0'); + final yyyy = now.year.toString(); + final today = "$yyyy-$mm-$dd"; + + final qp = { + 'userid': userid.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }; + final uri = Uri.parse(base).replace(queryParameters: qp); + + final res = await _getWithRetry(uri); + + if (res.statusCode >= 200 && res.statusCode < 300) { + // ignore: avoid_print + print('[DELIVERIES][GET_PICKED] URL: ${uri.toString()}'); + final decoded = json.decode(res.body); + + // The API returns {"code":200,"details":[...],"message":"Success","status":true} + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + if (data is List) { + try { + if (data.isNotEmpty) { + // ignore: avoid_print + // print('[DELIVERIES][GET_PICKED] Data: ${json.encode(data)}'); // Heavy log, disabled + } else { + // ignore: avoid_print + // print('[DELIVERIES][GET_PICKED] Data: []'); + } + } catch (_) {} + return data; + } + if (data is Map && data['items'] is List) return data['items'] as List; + + return []; + } + + throw Exception('Failed (${res.statusCode})'); + } +} diff --git a/lib/providers/deliverylog/deliverylog_provider.dart b/lib/providers/deliverylog/deliverylog_provider.dart new file mode 100644 index 0000000..0372a9b --- /dev/null +++ b/lib/providers/deliverylog/deliverylog_provider.dart @@ -0,0 +1,268 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; +import 'package:flutter/foundation.dart'; + +// Combined Deliverylog providers: + +/// Hardcoded known-good IPs for hosts where carrier DNS returns broken CDN nodes. +/// Confirmed: 66.116.225.226 = 200 OK, 125.21.240.67 = 404/405. +const _knownGoodIPs = { + 'queue.workolik.com': '66.116.225.226', +}; + +/// Creates an IOClient that: +/// 1. Bypasses SSL certificate errors +/// 2. Forces known-good IPs to avoid broken CDN nodes from carrier DNS +/// 3. Manually does TLS upgrade with correct SNI (hostname, not IP) +IOClient _buildSslBypassClient() { + final httpClient = HttpClient() + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + + httpClient.connectionFactory = + (Uri uri, String? proxyHost, int? proxyPort) async { + final host = uri.host; + final port = uri.port; + + InternetAddress? target; + final knownIP = _knownGoodIPs[host]; + if (knownIP != null) { + target = InternetAddress(knownIP); + } else { + try { + final addresses = await InternetAddress.lookup( + host, + type: InternetAddressType.IPv4, + ); + if (addresses.isNotEmpty) target = addresses.first; + } catch (_) {} + } + + if (uri.scheme == 'https') { + final socketFuture = + Socket.connect(target ?? InternetAddress(host), port) + .then((plain) => SecureSocket.secure( + plain, + host: host, + onBadCertificate: (_) => true, + supportedProtocols: ['http/1.1'], + )) + .then((s) => s as Socket); + return ConnectionTask.fromSocket(socketFuture, () {}); + } + + return Socket.startConnect(target ?? InternetAddress(host), port); + }; + + return IOClient(httpClient); +} + +class CreateDeliveryLogProvider { + Future?> createDeliveryLog( + String urldata, + Map data, { + bool wrapInArray = true, + }) async { + Map? result; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final body = json.encode(wrapInArray ? [data] : data); + final response = await client.post( + url, + body: body, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 10)); + debugPrint('createDeliveryLog url $urldata'); + debugPrint(body); + debugPrint('createDeliveryLog response ${response.body}'); + if (response.statusCode >= 200 && response.statusCode < 300) { + result = json.decode(response.body.toString()) as Map; + debugPrint('createDeliveryLog parsed ${result.toString()}'); + } else { + debugPrint('createDeliveryLog failed: HTTP ${response.statusCode}'); + } + } on TimeoutException catch (e) { + debugPrint('createDeliveryLog timeout: $e'); + } catch (e) { + debugPrint('createDeliveryLog error: $e'); + } finally { + client.close(); + } + return result; + } +} + +class UpdateDeliveryProvider { + Future?> updateDelivery( + Map data, + String urldata, + ) async { + Map? updateDeliveryResponse; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client.put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 10)); + debugPrint('updateDelivery url $urldata'); + debugPrint('updateDelivery status ${response.statusCode}'); + debugPrint('updateDelivery response ${response.body}'); + debugPrint(json.encode(data)); + if (response.statusCode >= 200 && response.statusCode < 300) { + updateDeliveryResponse = + json.decode(response.body) as Map; + } else { + debugPrint('updateDelivery failed: HTTP ${response.statusCode}'); + } + } on TimeoutException catch (e) { + debugPrint('updateDelivery timeout: $e'); + } catch (e) { + debugPrint('updateDelivery error: $e'); + } finally { + client.close(); + } + return updateDeliveryResponse; + } + + Future?> updateArrivedDelivery( + Map data, + String urldata, + ) async { + Map? updateDeliveryResponse; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client.put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 10)); + debugPrint('updateArrived url $urldata'); + debugPrint('updateArrived status ${response.statusCode}'); + debugPrint(json.encode(data)); + if (response.statusCode >= 200 && response.statusCode < 300) { + updateDeliveryResponse = + json.decode(response.body) as Map; + } else { + debugPrint('updateArrived failed: HTTP ${response.statusCode}'); + } + } on TimeoutException catch (e) { + debugPrint('updateArrived timeout: $e'); + } catch (e) { + debugPrint('updateArrived error: $e'); + } finally { + client.close(); + } + return updateDeliveryResponse; + } + + Future?> updatePickedDelivery( + Map data, + String urldata, + ) async { + Map? updateDeliveryResponse; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client.put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 10)); + debugPrint('updatePicked url $urldata'); + debugPrint('updatePicked status ${response.statusCode}'); + debugPrint(json.encode(data)); + if (response.statusCode >= 200 && response.statusCode < 300) { + updateDeliveryResponse = + json.decode(response.body) as Map; + } else { + debugPrint('updatePicked failed: HTTP ${response.statusCode}'); + } + } on TimeoutException catch (e) { + debugPrint('updatePicked timeout: $e'); + } catch (e) { + debugPrint('updatePicked error: $e'); + } finally { + client.close(); + } + return updateDeliveryResponse; + } + + Future?> updateActiveDelivery( + Map data, + String urldata, + ) async { + Map? updateDeliveryResponse; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client.put( + url, + body: json.encode(data), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 10)); + debugPrint('updateActive url $urldata'); + debugPrint('updateActive status ${response.statusCode}'); + debugPrint(json.encode(data)); + if (response.statusCode >= 200 && response.statusCode < 300) { + updateDeliveryResponse = + json.decode(response.body) as Map; + } else { + debugPrint('updateActive failed: HTTP ${response.statusCode}'); + } + } on TimeoutException catch (e) { + debugPrint('updateActive timeout: $e'); + } catch (e) { + debugPrint('updateActive error: $e'); + } finally { + client.close(); + } + return updateDeliveryResponse; + } +} + +class GetDeliveryLogProvider { + Future?> getDeliveryLog(String urldata) async { + Map? result; + final client = _buildSslBypassClient(); + try { + final url = Uri.parse(urldata); + final response = await client.get(url, headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }); + debugPrint('getDeliveryLog url $urldata'); + debugPrint('getDeliveryLog response ${response.body}'); + if (response.statusCode >= 200 && response.statusCode < 300) { + result = + json.decode(response.body.toString()) as Map; + } + } catch (e) { + debugPrint('getDeliveryLog error: $e'); + } finally { + client.close(); + } + return result; + } +} diff --git a/lib/providers/notifications/notificationservce.dart b/lib/providers/notifications/notificationservce.dart new file mode 100644 index 0000000..8643d64 --- /dev/null +++ b/lib/providers/notifications/notificationservce.dart @@ -0,0 +1,402 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:nearle/helpers/http_overrides.dart'; + +// Top-level background handler required by Firebase Messaging +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + HttpOverrides.global = MyHttpOverrides(); + try { + await Firebase.initializeApp(); + } catch (_) {} + + await NotificationServce.display(message); +} + +class NotificationServce { + static final FirebaseMessaging _firebaseMessaging = + FirebaseMessaging.instance; + static final FlutterLocalNotificationsPlugin _notificationsPlugin = + FlutterLocalNotificationsPlugin(); + static final AudioPlayer _player = AudioPlayer(); + static String _channelId = 'Nearle'; + static bool _isPlaying = false; + + static const AndroidNotificationChannel channel = AndroidNotificationChannel( + 'Nearle', + 'Nearle Notification', + description: 'Channel for Nearle notifications', + importance: Importance.max, + playSound: true, + enableVibration: true, + showBadge: true, + ); + + static Future initialize(BuildContext context) async { + try { + final prefs = await SharedPreferences.getInstance(); + final alreadyInit = prefs.getBool('notifications_init_done') ?? false; + if (alreadyInit) { + return; + } + + await FirebaseMessaging.instance.requestPermission( + alert: true, + badge: true, + sound: true, + ); + + final existing = (prefs.getString('order_alert_sound') ?? '').trim(); + if (existing.isEmpty) { + await prefs.setString('order_alert_sound', 'assets/audio/alert-1.mp3'); + } + await prefs.setBool('notifications_init_done', true); + } catch (_) {} + + await _notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(channel); + + await _applyChannelSoundFromPrefs(); + + const InitializationSettings initializationSettings = + InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings( + requestSoundPermission: true, + requestBadgePermission: true, + requestAlertPermission: true, + defaultPresentSound: true, + defaultPresentBadge: true, + defaultPresentBanner: true, + defaultPresentAlert: true, + defaultPresentList: true, + ), + ); + + await _notificationsPlugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: (NotificationResponse response) async {}, + ); + + RemoteMessage? initialMessage = + await _firebaseMessaging.getInitialMessage(); + if (initialMessage != null) { + await _handleInitialMessage(initialMessage); + } + + FirebaseMessaging.onMessage.listen((RemoteMessage message) async { + await _handleMessage(message); + }); + + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) async { + await _handleMessageOpenedApp(message); + }); + } + + // ✅ Removed duplicated background handler (this was breaking your notifications) + + static Future _handleInitialMessage(RemoteMessage message) async { + if (message.notification != null) { + await display(message); + } + } + + static Future _handleMessage(RemoteMessage message) async { + if (message.notification != null) { + await display(message); + } + } + + static Future _handleMessageOpenedApp(RemoteMessage message) async {} + + static Future _applyChannelSoundFromPrefs() async { + try { + final prefs = await SharedPreferences.getInstance(); + String sel = (prefs.getString('order_alert_sound') ?? '').trim(); + if (sel.isEmpty) { + sel = 'assets/audio/alert-1.mp3'; + } + + final fileName = sel.split('/').last; + final base = fileName.split('.').first; + final rawName = base.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_'); + + final androidImpl = _notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + + if (androidImpl != null) { + _channelId = 'Nearle_$rawName'; + + final custom = AndroidNotificationChannel( + _channelId, + 'Nearle Notification', + description: 'Channel for Nearle notifications', + importance: Importance.max, + playSound: true, + sound: RawResourceAndroidNotificationSound(rawName), + enableVibration: true, + showBadge: true, + ); + + await androidImpl.createNotificationChannel(custom); + } + } catch (_) {} + } + + static Future _downloadAndSaveImage( + String imageUrl, String fileName) async { + try { + final directory = await getTemporaryDirectory(); + final filePath = '${directory.path}/$fileName'; + final response = await http.get(Uri.parse(imageUrl)); + if (response.statusCode == 200) { + final file = File(filePath); + await file.writeAsBytes(response.bodyBytes); + return filePath; + } + return null; + } catch (_) { + return null; + } + } + + static String? _extractImageUrl(RemoteMessage message) { + String? imageUrl = message.data['image'] as String?; + imageUrl ??= message.notification?.android?.imageUrl; + imageUrl ??= message.notification?.apple?.imageUrl; + return imageUrl; + } + + static Future _playSelectedSound({int times = 1}) async { + try { + if (_isPlaying) return; + + _isPlaying = true; + + final prefs = await SharedPreferences.getInstance(); + String selected = (prefs.getString('order_alert_sound') ?? '').trim(); + if (selected.isEmpty) { + selected = 'assets/audio/alert-1.mp3'; + } + + final rel = selected.startsWith('assets/') + ? selected.replaceFirst('assets/', '') + : selected; + + await _player.stop(); + await _player.setReleaseMode(ReleaseMode.stop); + + for (int i = 0; i < times; i++) { + await _player.play(AssetSource(rel)); + try { + await _player.onPlayerComplete.first; + } catch (_) {} + if (i < times - 1) { + await Future.delayed(const Duration(milliseconds: 120)); + } + } + } catch (_) {} finally { + _isPlaying = false; + } + } + + /// Lightweight local notification helper for in-app events (no FCM message). + static Future showLocalNotification({ + required String title, + required String body, + bool playSound = true, + String? payload, + }) async { + try { + final id = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final notificationDetails = NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + 'Nearle Notification', + importance: Importance.max, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + playSound: playSound, + enableVibration: true, + channelShowBadge: true, + ongoing: false, + autoCancel: true, + ), + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: playSound, + presentList: true, + presentBanner: true, + ), + ); + + await _notificationsPlugin.show( + id, + title, + body, + notificationDetails, + payload: payload, + ); + } catch (_) {} + } + + static Future display(RemoteMessage message) async { + final id = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final payload = jsonEncode({'id': id.toString(), 'data': message.data}); + + final appState = WidgetsBinding.instance.lifecycleState; + final bool isForeground = appState == AppLifecycleState.resumed; + + if (isForeground) { + await _playSelectedSound(times: 5); + } + + NotificationDetails notificationDetails; + + final imageUrl = _extractImageUrl(message); + String? persistedImageUrl = imageUrl; + String? persistedImagePath; + + if (imageUrl != null && imageUrl.isNotEmpty) { + final imagePath = await _downloadAndSaveImage( + imageUrl, + 'notification_image.jpg', + ); + persistedImagePath = imagePath; + + notificationDetails = NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + 'Nearle Notification', + importance: Importance.max, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + playSound: !isForeground, + enableVibration: true, + fullScreenIntent: true, + channelShowBadge: true, + ongoing: false, + autoCancel: true, + styleInformation: imagePath != null + ? BigPictureStyleInformation(FilePathAndroidBitmap(imagePath)) + : const DefaultStyleInformation(true, true), + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + presentList: true, + presentBanner: true, + ), + ); + } else { + notificationDetails = NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + 'Nearle Notification', + importance: Importance.max, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + playSound: !isForeground, + enableVibration: true, + fullScreenIntent: true, + channelShowBadge: true, + ongoing: false, + autoCancel: true, + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + presentList: true, + presentBanner: true, + ), + ); + } + + if (isForeground) { + final ctx = Get.context; + if (ctx != null) { + final title = + message.notification?.title ?? message.data['title'] ?? 'Nearle'; + final body = message.notification?.body ?? message.data['body'] ?? ''; + + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.w700)), + if (body.isNotEmpty) Text(body), + ], + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + duration: const Duration(seconds: 3), + ), + ); + } + } else { + await _notificationsPlugin.show( + id, + message.notification?.title ?? message.data['title'] ?? 'Nearle', + message.notification?.body ?? message.data['body'] ?? 'Notification', + notificationDetails, + payload: payload, + ); + } + + try { + final prefs = await SharedPreferences.getInstance(); + final nowIso = DateTime.now().toIso8601String(); + + final title = + message.notification?.title ?? message.data['title'] ?? 'Nearle'; + final body = + message.notification?.body ?? message.data['body'] ?? ''; + + final entry = { + 'id': id, + 'title': title, + 'body': body, + 'time': nowIso, + 'data': message.data, + if (persistedImageUrl != null) 'imageUrl': persistedImageUrl, + if (persistedImagePath != null) 'imagePath': persistedImagePath, + }; + + final existingRaw = prefs.getString('notifications_log'); + List list = []; + + if (existingRaw != null && existingRaw.isNotEmpty) { + try { + list = jsonDecode(existingRaw) as List; + } catch (_) {} + } + + list.insert(0, entry); + + if (list.length > 100) list = list.sublist(0, 100); + + await prefs.setString('notifications_log', jsonEncode(list)); + } catch (_) {} + } +} diff --git a/lib/providers/summary/riderweeklykms.dart b/lib/providers/summary/riderweeklykms.dart new file mode 100644 index 0000000..453e031 --- /dev/null +++ b/lib/providers/summary/riderweeklykms.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:nearle/Models/summary/riderweeklykms.dart'; +import 'package:nearle/controllers/riderkm.dart'; + + +class RiderWeeklyKmProvider extends ChangeNotifier { + final RiderWeeklyKmController _controller = RiderWeeklyKmController(); + + bool isLoading = false; + String? error; + List kmsList = []; + double totalKms = 0.0; + + Future fetchRiderWeeklyKms(int userId) async { + isLoading = true; + error = null; + notifyListeners(); + + try { + final result = await _controller.getRiderWeeklyKms(userId); + kmsList = result['details']; + totalKms = result['total_kms']; + } catch (e) { + error = e.toString(); + } + + isLoading = false; + notifyListeners(); + } +} + + diff --git a/lib/providers/summary/summary.dart b/lib/providers/summary/summary.dart new file mode 100644 index 0000000..83ed804 --- /dev/null +++ b/lib/providers/summary/summary.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:nearle/models/summary/deliverystats.dart'; +import 'package:nearle/views/helpers/constants/apiconstants.dart'; + +class SummaryProvider { + final String baseUrl = ApiConstants.summaryApiLive; + + Future fetchSummaryStats(int userId) async { + final url = Uri.parse('$baseUrl/getdeliverystats?userid=$userId'); + + try { + print(url); + final response = await http.get(url); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + if (decoded['status'] == true && decoded['data'] != null) { + return DeliveryStats.fromJson(decoded['data']); + } else { + print('API returned false status: ${decoded['message']}'); + } + } else { + print('something went wrong'); + } + } catch (e) { + print('something went wrong'); + } + return null; + } +} + + diff --git a/lib/providers/support/support_ticket.dart b/lib/providers/support/support_ticket.dart new file mode 100644 index 0000000..1fcde0f --- /dev/null +++ b/lib/providers/support/support_ticket.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:get/get_rx/src/rx_types/rx_types.dart'; +import 'package:get/get_state_manager/src/simple/get_controllers.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:nearle/Models/supportticket/support_ticket.dart'; + +class SupportTicketController extends GetxController { + final RxList tickets = [].obs; + final RxBool isLoading = true.obs; + final RxString errorMessage = ''.obs; + final RxBool isSubmitting = false.obs; + + @override + void onInit() { + super.onInit(); + fetchTickets(); + } + + Future fetchTickets() async { + try { + isLoading(true); + errorMessage(''); + + const userId = 1242; + final url = Uri.parse( + 'https://jupiter.nearle.app/live/api/v1/partners/getridersupport/?userid=$userId'); + + final response = await http.get(url, headers: { + 'Accept': 'application/json', + }); + + if (response.statusCode != 200) { + throw Exception('Server error: ${response.statusCode}'); + } + + final Map jsonResponse = json.decode(response.body); + if (jsonResponse['status'] != true) { + throw Exception(jsonResponse['message'] ?? 'Unknown error'); + } + + final List data = jsonResponse['data']; + tickets.assignAll(data.map((e) => SupportTicketModel.fromJson(e)).toList()); + } catch (e) { + errorMessage(e.toString()); + } finally { + isLoading(false); + } + } + + Future createTicket({ + required int userid, + required String category, + required String priority, + required String subject, + required String issue, + List? attachments, + }) async { + try { + isSubmitting(true); + + // Step 1: Upload image if attached (adjust endpoint if needed) + String? imageUrl; + if (attachments != null && attachments.isNotEmpty) { + // For simplicity, assume first image; upload to a temp endpoint or your main one + final imageFile = File(attachments.first.path); + final imageBytes = await imageFile.readAsBytes(); + final imageName = attachments.first.name; + + // Example image upload (replace with your actual image upload endpoint) + final uploadUrl = Uri.parse('https://jupiter.nearle.app/live/api/v1/partners/uploadimage/'); // Adjust URL + final imageRequest = http.MultipartRequest('POST', uploadUrl) + ..files.add(http.MultipartFile.fromBytes('image', imageBytes, filename: imageName)); + imageRequest.headers['Accept'] = 'application/json'; + + final imageResponse = await imageRequest.send(); + if (imageResponse.statusCode == 200) { + final imageJson = await http.Response.fromStream(imageResponse); + imageUrl = json.decode(imageJson.body)['image_url']; // Assume response has 'image_url' + } else { + throw Exception('Image upload failed'); + } + } + + // Step 2: Create ticket with POST + final postUrl = Uri.parse('https://jupiter.nearle.app/live/api/v1/partners/createridersupport/'); + final body = json.encode({ + 'userid': userid, + 'category': category, + 'priority': priority, + 'subject': subject, + 'issue': issue, + 'image': imageUrl, // null if no image + }); + + final response = await http.post( + postUrl, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: body, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to create ticket: ${response.statusCode}'); + } + + final Map jsonResponse = json.decode(response.body); + if (jsonResponse['status'] != true) { + throw Exception(jsonResponse['message'] ?? 'Unknown error'); + } + + // Refresh tickets to show new one + await fetchTickets(); + return true; + } catch (e) { + errorMessage(e.toString()); + return false; + } finally { + isSubmitting(false); + } + } +} + diff --git a/lib/utils/device.dart b/lib/utils/device.dart new file mode 100644 index 0000000..10e431e --- /dev/null +++ b/lib/utils/device.dart @@ -0,0 +1,67 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DeviceUtils { + static const String _deviceIdKey = 'deviceId'; + static const String _fcmTokenKey = 'fcmToken'; + + static Future ensureDeviceId(SharedPreferences prefs) async { + final String? existing = prefs.getString(_deviceIdKey); + if (existing != null && existing.isNotEmpty) { + if (kDebugMode) print('[DEVICE] Using cached device ID: $existing'); + return existing; + } + try { + final deviceInfo = DeviceInfoPlugin(); + final android = await deviceInfo.androidInfo; + final String androidId = android.id; + if (androidId.isNotEmpty) { + await prefs.setString(_deviceIdKey, androidId); + return androidId; + } else { + throw Exception('Android ID is empty'); + } + } on PlatformException catch (e) { + throw Exception('Failed to get device ID: ${e.message}'); + } catch (e) { + throw Exception('Failed to get device ID: $e'); + } + } + + static Future ensureFcmToken(SharedPreferences prefs) async { + try { + final String? existing = prefs.getString(_fcmTokenKey); + if (existing != null && existing.isNotEmpty) { + return existing; + } + if (Firebase.apps.isEmpty) { + try { + await Firebase.initializeApp(); + } catch (_) { + return ''; + } + } + final FirebaseMessaging messaging = FirebaseMessaging.instance; + final NotificationSettings settings = await messaging.requestPermission( + alert: true, + badge: true, + sound: true, + ); + if (settings.authorizationStatus == AuthorizationStatus.authorized || + settings.authorizationStatus == AuthorizationStatus.provisional) { + final String? token = await messaging.getToken(); + if (token != null && token.isNotEmpty) { + await prefs.setString(_fcmTokenKey, token); + return token; + } + } + } catch (_) {} + return ''; + } +} + + diff --git a/lib/utils/kalman_filter.dart b/lib/utils/kalman_filter.dart new file mode 100644 index 0000000..a887feb --- /dev/null +++ b/lib/utils/kalman_filter.dart @@ -0,0 +1,231 @@ +import 'dart:math'; + +/// A simple 4D Kalman Filter implementation for GPS smoothing. +/// State vector x = [lat, lng, velocity_lat, velocity_lng] +class NearleKalmanFilter { + late List x; // State estimate + late List> P; // Covariance matrix + late List> F; // State transition matrix + late List> H; // Measurement matrix + late List> R; // Measurement noise covariance + late List> Q; // Process noise covariance + + NearleKalmanFilter({ + required double lat, + required double lng, + }) { + // Initial state: [lat, lng, 0, 0] + x = [lat, lng, 0, 0]; + + // Initial covariance: High uncertainty for initial velocity + P = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1000.0, 0.0], + [0.0, 0.0, 0.0, 1000.0], + ]; + + // State transition matrix (assuming dt = 1 for simplicity, will update in predict) + F = [ + [1.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + + // Measurement matrix: We only measure lat and lng + H = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + ]; + + // Measurement noise: GPS is typically accurate to ~5-10 meters. + // In degrees, this is roughly 0.0001 + R = [ + [0.00001, 0.0], + [0.0, 0.00001], + ]; + + // Process noise: How much we trust our prediction vs measurement + Q = [ + [0.00001, 0.0, 0.0, 0.0], + [0.0, 0.00001, 0.0, 0.0], + [0.0001, 0.0, 0.0001, 0.0], + [0.0, 0.0001, 0.0, 0.0001], + ]; + } + + /// Predict the next state + void predict(double dt) { + // Update F based on dt + F[0][2] = dt; + F[1][3] = dt; + + // x = F * x + final newX = List.filled(4, 0); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + newX[i] += F[i][j] * x[j]; + } + } + x = newX; + + // P = F * P * F^T + Q + final FP = _multiply4x4(F, P); + final F_T = _transpose4x4(F); + final FPF_T = _multiply4x4(FP, F_T); + + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + P[i][j] = FPF_T[i][j] + Q[i][j]; + } + } + } + + /// Update the state with a new measurement + void update(double measuredLat, double measuredLng) { + final z = [measuredLat, measuredLng]; + + // y = z - H * x (Innovation) + final y = [ + z[0] - (H[0][0] * x[0] + H[0][1] * x[1] + H[0][2] * x[2] + H[0][3] * x[3]), + z[1] - (H[1][0] * x[0] + H[1][1] * x[1] + H[1][2] * x[2] + H[1][3] * x[3]), + ]; + + // S = H * P * H^T + R (Innovation covariance) + // H is 2x4, P is 4x4, H^T is 4x2 + final HP = _multiply2x4_4x4(H, P); + final H_T = _transpose2x4(H); + final HPH_T = _multiply2x4_4x2(HP, H_T); + + final S = [ + [HPH_T[0][0] + R[0][0], HPH_T[0][1] + R[0][1]], + [HPH_T[1][0] + R[1][0], HPH_T[1][1] + R[1][1]], + ]; + + // K = P * H^T * S^-1 (Kalman gain) + final Sinv = _inverse2x2(S); + final PH_T = _multiply4x4_4x2(P, H_T); + final K = _multiply4x2_2x2(PH_T, Sinv); + + // x = x + K * y + for (int i = 0; i < 4; i++) { + x[i] += K[i][0] * y[0] + K[i][1] * y[1]; + } + + // P = (I - K * H) * P + final KH = _multiply4x2_2x4(K, H); + final I = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + + final I_KH = List.generate(4, (i) => List.generate(4, (j) => I[i][j] - KH[i][j])); + P = _multiply4x4(I_KH, P); + } + + // --- Helper Math Functions --- + + List> _multiply4x4(List> A, List> B) { + final C = List.generate(4, (_) => List.filled(4, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + for (int k = 0; k < 4; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + return C; + } + + List> _transpose4x4(List> A) { + final C = List.generate(4, (_) => List.filled(4, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + C[i][j] = A[j][i]; + } + } + return C; + } + + List> _multiply2x4_4x4(List> A, List> B) { + final C = List.generate(2, (_) => List.filled(4, 0)); + for (int i = 0; i < 2; i++) { + for (int j = 0; j < 4; j++) { + for (int k = 0; k < 4; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + return C; + } + + List> _transpose2x4(List> A) { + final C = List.generate(4, (_) => List.filled(2, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 2; j++) { + C[i][j] = A[j][i]; + } + } + return C; + } + + List> _multiply2x4_4x2(List> A, List> B) { + final C = List.generate(2, (_) => List.filled(2, 0)); + for (int i = 0; i < 2; i++) { + for (int j = 0; j < 2; j++) { + for (int k = 0; k < 4; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + return C; + } + + List> _inverse2x2(List> A) { + final det = A[0][0] * A[1][1] - A[0][1] * A[1][0]; + if (det == 0) return [[1, 0], [0, 1]]; // Should not happen with noise + return [ + [A[1][1] / det, -A[0][1] / det], + [-A[1][0] / det, A[0][0] / det], + ]; + } + + List> _multiply4x4_4x2(List> A, List> B) { + final C = List.generate(4, (_) => List.filled(2, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 2; j++) { + for (int k = 0; k < 4; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + return C; + } + + List> _multiply4x2_2x2(List> A, List> B) { + final C = List.generate(4, (_) => List.filled(2, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 2; j++) { + for (int k = 0; k < 2; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + return C; + } + + List> _multiply4x2_2x4(List> K, List> H) { + final C = List.generate(4, (_) => List.filled(4, 0)); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + for (int k = 0; k < 2; k++) { + C[i][j] += K[i][k] * H[k][j]; + } + } + } + return C; + } +} diff --git a/lib/utils/mqtt_service.dart b/lib/utils/mqtt_service.dart new file mode 100644 index 0000000..a9c0466 --- /dev/null +++ b/lib/utils/mqtt_service.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:mqtt_client/mqtt_client.dart'; +import 'package:mqtt_client/mqtt_server_client.dart'; +import 'package:nearle/views/helpers/constants/mqtt_constants.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class NearleMqttService { + static final NearleMqttService _instance = NearleMqttService._internal(); + + factory NearleMqttService() { + return _instance; + } + + NearleMqttService._internal(); + + MqttServerClient? _client; + bool _isConnected = false; + bool _isConnecting = false; // Prevents concurrent connection attempts + String? _currentRiderId; + + // Unique per isolate startup — prevents client ID collision between + // main isolate and background foreground-service isolate. + static final String _sessionSuffix = + DateTime.now().millisecondsSinceEpoch.toRadixString(36); + + bool get isConnected => _isConnected; + + Future connect() async { + if (_isConnected || _isConnecting) return; + _isConnecting = true; + + try { + final prefs = await SharedPreferences.getInstance(); + final riderIdLong = prefs.getInt('userId') ?? prefs.getInt('userid'); + if (riderIdLong == null || riderIdLong <= 0) { + debugPrint('[MQTT] Cannot connect: No rider ID found in preferences.'); + return; + } + _currentRiderId = riderIdLong.toString(); + + const host = MqttConstants.brokerHost; + final clientId = 'rider_${_currentRiderId}_$_sessionSuffix'; + + _client = MqttServerClient(host, clientId); + _client!.port = MqttConstants.brokerPort; + _client!.keepAlivePeriod = 30; + _client!.autoReconnect = true; + _client!.logging(on: false); + + final lwtTopic = + MqttConstants.topicRiderStatus.replaceAll('{riderId}', _currentRiderId!); + _client!.onDisconnected = _onDisconnected; + _client!.onConnected = _onConnected; + _client!.onAutoReconnect = _onAutoReconnect; + _client!.onSubscribed = _onSubscribed; + + final connMessage = MqttConnectMessage() + .withClientIdentifier(clientId) + .authenticateAs(MqttConstants.username, MqttConstants.passwordString) + .withWillTopic(lwtTopic) + .withWillMessage(MqttConstants.statusOffline) + .withWillQos(MqttQos.atLeastOnce) + .withWillRetain() + .startClean(); + + _client!.connectionMessage = connMessage; + + debugPrint('[MQTT] Connecting to $host as $clientId...'); + await _client!.connect(); + } catch (e) { + debugPrint('[MQTT] Connection failed: $e'); + _cleanDisconnect(); + } finally { + _isConnecting = false; + } + } + + /// Cleanly disconnects and resets all state. Call on logout or duty end. + void disconnect() { + _publishStatus(MqttConstants.statusOffline); + _cleanDisconnect(); + debugPrint('[MQTT] Disconnected and state cleared.'); + } + + void _cleanDisconnect() { + _client?.disconnect(); + _client = null; + _isConnected = false; + _currentRiderId = null; + } + + void _onConnected() { + _isConnected = true; + debugPrint('[MQTT] Connected successfully.'); + _publishStatus(MqttConstants.statusOnline); + } + + void _onDisconnected() { + _isConnected = false; + debugPrint('[MQTT] Disconnected from broker.'); + } + + void _onAutoReconnect() { + debugPrint('[MQTT] Auto-reconnecting...'); + } + + void _onSubscribed(String topic) { + debugPrint('[MQTT] Subscribed to topic: $topic'); + } + + void _publishStatus(String status) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = + MqttConstants.topicRiderStatus.replaceAll('{riderId}', _currentRiderId!); + final builder = MqttClientPayloadBuilder(); + builder.addString(status); + + _client!.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!, + retain: true); + debugPrint('[MQTT] Published status: $status to $topic'); + } + + // --- PUBLIC API --- + + void updateStatus(String status) { + _publishStatus(status); + } + + void publishProfile(Map profileData) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = + MqttConstants.topicRiderProfile.replaceAll('{riderId}', _currentRiderId!); + final builder = MqttClientPayloadBuilder(); + builder.addString(jsonEncode(profileData)); + + _client!.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!, + retain: true); + debugPrint('[MQTT] Published profile to $topic'); + } + + void publishLocation(Map locationData) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = + MqttConstants.topicRiderLocation.replaceAll('{riderId}', _currentRiderId!); + final builder = MqttClientPayloadBuilder(); + builder.addString(jsonEncode(locationData)); + + _client!.publishMessage(topic, MqttQos.atMostOnce, builder.payload!); + } + + void publishTelemetry(Map telemetryData) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = + MqttConstants.topicRiderTelemetry.replaceAll('{riderId}', _currentRiderId!); + final builder = MqttClientPayloadBuilder(); + builder.addString(jsonEncode(telemetryData)); + + _client!.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!); + debugPrint('[MQTT] Published telemetry data.'); + } + + void publishLog(String eventName, Map data) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = + '${MqttConstants.topicRiderLogs.replaceAll('{riderId}', _currentRiderId!)}/$eventName'; + final builder = MqttClientPayloadBuilder(); + builder.addString(jsonEncode(data)); + + _client!.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!); + debugPrint('[MQTT] Published log: $eventName'); + } + + void publish(String subTopic, dynamic data) { + if (!_isConnected || _currentRiderId == null) return; + + final topic = 'nearle/riders/$_currentRiderId/$subTopic'; + final builder = MqttClientPayloadBuilder(); + if (data is String) { + builder.addString(data); + } else { + builder.addString(jsonEncode(data)); + } + + _client!.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!); + debugPrint('[MQTT] Published to $topic'); + } +} diff --git a/lib/utils/no_internet.json b/lib/utils/no_internet.json new file mode 100644 index 0000000..fcb7dae --- /dev/null +++ b/lib/utils/no_internet.json @@ -0,0 +1 @@ +{"v":"5.5.8","fr":23.9499969482422,"ip":0,"op":120.000229360136,"w":800,"h":1100,"nm":"Comp 1","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":5,"ty":4,"nm":"Recurso-4 contornos","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-7,"ix":10},"p":{"a":0,"k":[324.896,329.642,0],"ix":2},"a":{"a":0,"k":[93.098,96.913,0],"ix":1},"s":{"a":0,"k":[324,324,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.27,"y":0},"t":0,"s":[{"i":[[-27.414,-10],[-8.411,-11.244],[-4.606,-13.604],[0.093,-13.712],[7.084,-12.207],[12.048,-7.832],[14.584,-1.43],[12.782,5.646],[10.395,9.93],[6.853,12.813],[-1.565,14.745],[-25.379,15.571]],"o":[[13.5,4.92],[8.411,11.244],[4.537,13.361],[-0.093,13.712],[-7.358,12.67],[-12.048,7.832],[-14.303,1.406],[-12.782,-5.646],[-10.707,-10.242],[-6.853,-12.813],[3.132,-29.601],[24.871,-15.254]],"v":[[40.531,-92.883],[73.077,-67.646],[92.281,-29.383],[99.538,11.692],[89.363,51.035],[59.716,82.178],[19.23,96.461],[-21.613,89.408],[-56.594,65.352],[-84.075,30.609],[-93.148,-10.887],[-41.348,-78.133]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.27,"y":0},"t":36,"s":[{"i":[[-16.89,-3.382],[-13.011,-10.914],[-3.996,-16.527],[2.535,-16.18],[10.004,-13.445],[16.019,-5.395],[16.148,3.809],[11.309,11.844],[5.652,13.934],[-1.387,15.407],[-9.441,13.183],[-15.953,6.125]],"o":[[16.656,3.329],[13.012,10.91],[3.848,15.918],[-2.586,16.566],[-10.09,13.57],[-15.75,5.3],[-15.945,-3.75],[-10.379,-10.875],[-5.82,-14.324],[1.57,-16.14],[9.848,-13.973],[16.078,-6.183]],"v":[[12.406,-93.938],[58.156,-73.566],[86.25,-32.02],[79.77,15.84],[71.141,64.535],[26.723,87.18],[-21.457,95.746],[-60.043,66.695],[-84.492,29.707],[-93.238,-15.055],[-76.43,-59.82],[-38.375,-92.879]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.27,"y":0},"t":84,"s":[{"i":[[-17.047,-0.609],[-7.139,-4.535],[-7.222,-4.422],[-8.004,-15.773],[0.508,-17.871],[12.336,-17.039],[20.57,-4.313],[19.16,4.742],[10.304,17.699],[1.371,20.184],[-7.746,16.86],[-16.418,8.625]],"o":[[8.473,0.315],[7.139,4.535],[15.098,9.223],[8.078,15.942],[-0.593,21],[-12.34,17.043],[-19.312,4.035],[-19.906,-4.918],[-10.16,-17.489],[-1.211,-18.5],[7.75,-16.859],[15.082,-7.914]],"v":[[1.09,-96.977],[24.153,-88.62],[45.34,-74.102],[87.738,-44.004],[87.23,8.531],[84.492,70.293],[26.742,92.043],[-31.211,94.465],[-83.652,64.766],[-83.027,5.312],[-91.266,-48.551],[-45.062,-77.426]],"c":true}]},{"t":119.999985219045,"s":[{"i":[[-27.414,-10],[-8.411,-11.244],[-4.606,-13.604],[0.093,-13.712],[7.084,-12.207],[12.048,-7.832],[14.584,-1.43],[12.782,5.646],[10.395,9.93],[6.853,12.813],[-1.565,14.745],[-25.379,15.571]],"o":[[13.5,4.92],[8.411,11.244],[4.537,13.361],[-0.093,13.712],[-7.358,12.67],[-12.048,7.832],[-14.303,1.406],[-12.782,-5.646],[-10.707,-10.242],[-6.853,-12.813],[3.132,-29.601],[24.871,-15.254]],"v":[[40.531,-92.883],[73.077,-67.646],[92.281,-29.383],[99.538,11.692],[89.363,51.035],[59.716,82.178],[19.23,96.461],[-21.613,89.408],[-56.594,65.352],[-84.075,30.609],[-93.148,-10.887],[-41.348,-78.133]],"c":true}]}],"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.05],"y":[1]},"o":{"x":[0.15],"y":[0]},"t":0,"s":[0.901960790157,0.917647063732,0.92549020052,1]},{"t":119.999985219045,"s":[0.901960790157,0.917647063732,0.92549020052,1]}],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[93.5,97],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3404.99956612796,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Capa de formas 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,550,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"personaje 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,550,0],"ix":2},"a":{"a":0,"k":[400,550,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.9,1.268],[-2.281,7.988],[-7.905,-0.305],[-0.87,-5.147],[3.744,-4.083],[1.712,-0.428]],"o":[[0,0],[2.28,-7.988],[5.123,0.197],[0.923,5.462],[-5.189,5.658],[-2.432,0.609]],"v":[[-11.649,15.253],[-13.549,-2.625],[6.973,-18.6],[14.907,-8.949],[9.949,6.101],[-2.908,18.296]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[514.832,480.61],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 5","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.926,0.896],[0,0],[-1.556,0],[0,0],[0,0],[-1.385,-1.04],[0,0],[0.835,-0.865],[0,0],[2.171,0],[0,0],[-0.237,0.709],[0,0],[0,0]],"o":[[0,0],[1.119,-1.081],[0,0],[0,0],[1.732,0],[0,0],[0.962,0.722],[0,0],[-1.508,1.563],[0,0],[-0.748,0],[0,0],[0,0],[0.407,-1.222]],"v":[[-68.842,27.355],[-5.488,-33.88],[-1.318,-35.565],[-1.084,-35.565],[64.276,-35.565],[69.079,-33.964],[71.215,-32.359],[71.454,-29.372],[11.157,33.121],[5.4,35.565],[-71.012,35.565],[-72.052,34.123],[-71.899,33.664],[-70.872,30.579]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.803921987496,0.83529399797,0.854902020623,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[476.143,468.285],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 6","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[15.324,5.024],[0,0]],"o":[[0,0],[-11.085,6.021],[0,0],[0,0]],"v":[[13.769,-14.683],[19.781,6.978],[-14.318,9.659],[-19.781,-14.605]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[412.75,380.856],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 7","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[44.299,-5.466],[1.222,-14.421],[-6.337,-0.231],[0,0],[0,0],[-10.12,-4.136],[-4.647,3.813],[-8.467,-9.575],[-1.553,-10.834],[15.77,-16.455]],"o":[[-27.277,3.365],[0,0],[2.61,1.934],[0,0],[-0.154,-10.99],[10.121,4.135],[4.647,-3.814],[3.963,4.481],[15.336,-23.7],[-27.513,6.367]],"v":[[-23.511,-43.171],[-63.5,-6.424],[-59.845,33.105],[-49.304,51.552],[-45.65,52.598],[-30.009,25.297],[-20.881,-2.492],[41.489,3.557],[40.907,37.414],[50.412,-36.143]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.588234994926,0.36470600203,0.317646998985,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[413.459,268.428],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 8","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.254,-14.17],[10.39,-4.544],[12.978,6.984],[4.811,19.228],[5.553,10.624],[-12.412,-1.526],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[-0.254,14.17],[-12.705,1.914],[-16.464,-8.684],[-7.053,0.581],[-4.208,-14.519],[0,0],[0,0],[0,0],[0,0],[0,0],[-0.019,7.907]],"v":[[51.972,35.106],[20.485,66.448],[-12.401,59.804],[-40.844,25.156],[-57.024,12.612],[-47.31,-4.785],[-37.5,13.453],[-42.19,-22.705],[-8.194,-68.362],[61.232,-48.491],[52.347,-6.328]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[401.655,306.527],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 9","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-4.26,62.228],[0,0],[0,0],[-11.782,0.38],[0,0],[6.081,-66.186],[0,0],[4.94,12.552],[-0.075,-39.851],[0.351,-0.074],[30.912,10.646],[0.76,0.945],[0.225,0.908]],"o":[[2.406,-7.916],[0,0],[0,0],[11.781,-0.38],[50.926,10.271],[0,0],[0,-20.921],[-20.565,63.912],[0.001,0.358],[-30.156,6.392],[-0.882,-0.303],[-0.76,-0.945],[-6.117,-24.716]],"v":[[-26.502,-4.753],[-13.96,-41.268],[16.444,-43.171],[32.786,-43.171],[50.648,-45.833],[101.194,39.753],[77.631,39.753],[72.691,7.42],[59.388,126.234],[58.784,126.987],[-26.122,126.479],[-28.782,124.958],[-29.883,122.294]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4117647058823529,0.3411764705882353,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[381.846,433.668],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 10","np":2,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-3.729,3.076],[1.371,8.788],[2.632,-1.318],[1.919,1.922],[0.548,-2.746],[3.564,-0.275],[1.645,-2.746],[-6.169,1.686],[-1.736,3.661]],"o":[[3.015,-7.964],[-0.731,-0.549],[-3.289,1.648],[-1.919,-1.922],[-0.548,2.746],[-3.564,0.274],[-8.443,14.094],[4.825,-1.318],[2.193,-0.366]],"v":[[28.21,4.486],[29.032,-19.68],[22.726,-18.581],[15.05,-22.151],[0.794,-22.151],[-3.867,-7.322],[-22.783,10.802],[0.794,22.335],[16.969,10.802]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4117647058823529,0.3411764705882353,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[359.663,835.492],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 11","np":2,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[31.757,9.095],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[-32.348,7.023],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[34.584,-123.433],[-47.506,-125.716],[-37.245,122.292],[-14.442,122.292],[-6.081,-67.138],[24.323,125.716],[47.506,125.716]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.1450980392156863,0.15294117647058825,0.27058823529411763,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[402.85,681.678],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 12","np":2,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[3.729,3.076],[-1.371,8.787],[-2.632,-1.318],[-1.919,1.922],[-0.548,-2.746],[-3.564,-0.275],[-1.645,-2.746],[6.169,1.685],[1.736,3.661]],"o":[[-3.015,-7.964],[0.731,-0.55],[3.29,1.648],[1.919,-1.922],[0.548,2.746],[3.564,0.274],[8.442,14.094],[-4.825,-1.319],[-2.194,-0.366]],"v":[[-28.21,4.487],[-29.032,-19.678],[-22.727,-18.581],[-15.05,-22.15],[-0.794,-22.15],[3.866,-7.321],[22.783,10.802],[-0.794,22.336],[-16.969,10.802]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4117647058823529,0.3411764705882353,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[457.439,838.535],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 13","np":2,"cix":2,"bm":0,"ix":9,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[8.779,12.521],[8.603,-15.467],[-8.779,-15.816],[-8.002,15.817]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[375.867,809.741],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 14","np":2,"cix":2,"bm":0,"ix":10,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-6.973,14.451],[-10.079,-13.365],[7.144,-15.753],[10.08,15.753]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[439.06,809.556],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 15","np":2,"cix":2,"bm":0,"ix":11,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"wifi_icon","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":43,"s":[45]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":81,"s":[-31]},{"t":119.000190751185,"s":[0]}],"ix":10},"p":{"a":0,"k":[198.575,-239.681,0],"ix":2},"a":{"a":0,"k":[198.575,-239.681,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.417,0],[-0.417,0],[-0.834,0.827],[0,1.24],[0.834,0.827],[1.668,-1.654],[0.105,-0.207],[0.209,-0.206],[0,-0.414],[-0.834,-0.828]],"o":[[0.417,0.413],[1.251,0],[0.834,-0.828],[0,-0.827],[-1.668,-1.654],[-0.208,0.207],[-0.104,0.207],[-0.417,0.414],[0,1.24],[0.417,0.413]],"v":[[7.693,37.023],[9.362,37.435],[12.281,36.195],[13.532,33.301],[12.281,30.406],[6.442,30.406],[6.025,31.026],[5.609,31.646],[5.191,33.301],[6.442,36.195]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.251,0],[0.834,0.413],[7.09,-4.963],[1.251,1.654],[-1.668,1.241],[-10.009,-7.03],[1.668,-2.068]],"o":[[-0.834,0],[-7.09,-4.963],[-2.085,1.24],[-1.251,-2.068],[10.009,-7.03],[2.085,1.241],[-0.417,1.24]],"v":[[23.958,21.308],[21.457,20.481],[-2.733,20.481],[-8.571,19.654],[-7.736,13.864],[26.043,13.864],[26.878,19.654]],"c":true},"ix":2},"nm":"Trazado 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[18.23,-14.865],[-1.669,-1.654],[-1.251,0],[-0.417,1.241],[-15.43,-12.82],[-1.315,0.421]],"o":[[-18.658,-14.017],[-1.668,1.241],[0.834,1.24],[0.834,0],[15.431,-12.82],[1.069,1.061],[0,0]],"v":[[39.848,-2.297],[-22.75,-1.024],[-23.167,4.766],[-19.831,6.419],[-17.329,5.179],[36.052,5.179],[39.848,6.029]],"c":true},"ix":2},"nm":"Trazado 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,0],[21.704,-18.832],[-1.668,-1.654],[-1.252,0],[-0.834,0.413],[-22.824,-12.68]],"o":[[-25.104,-11.795],[-1.668,1.655],[0.834,0.827],[1.251,0],[20.027,-17.732],[0,0]],"v":[[39.848,-25.641],[-37.764,-15.086],[-38.181,-9.295],[-34.844,-8.055],[-31.924,-8.881],[39.848,-16.459]],"c":true},"ix":2},"nm":"Trazado 4","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Combinar trazados 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.207843002619,0.341175991881,0.411765005074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[185.041,-243.891],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 1","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-5.083,0],[0,0],[0,-5.083],[0,0],[5.083,0],[0,5.083],[0,0]],"o":[[0,0],[5.083,0],[0,0],[0,5.083],[-5.083,0],[0,0],[0,-5.083]],"v":[[-0.001,-9.313],[-0.001,-9.313],[9.204,-0.109],[9.204,0.108],[-0.001,9.313],[-9.204,0.108],[-9.204,-0.109]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.207843002619,0.341175991881,0.411765005074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[242.147,-204.502],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-5.083,0],[0,0],[0,-5.083],[0,0],[5.083,0],[0,5.083],[0,0]],"o":[[0,0],[5.083,0],[0,0],[0,5.083],[-5.083,0],[0,0],[0,-5.083]],"v":[[-0.001,-26.902],[-0.001,-26.902],[9.204,-17.699],[9.204,17.699],[-0.001,26.902],[-9.204,17.699],[-9.204,-17.699]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.207843002619,0.341175991881,0.411765005074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[242.147,-257.271],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.000229360136,"st":17.9999977064033,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"burbuja","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":43,"s":[-45]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.18],"y":[0]},"t":81,"s":[31]},{"t":119.000227448802,"s":[0]}],"ix":10},"p":{"a":0,"k":[534.277,421.219,0],"ix":2},"a":{"a":0,"k":[101.277,-141.781,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-15.958,0],[0,52.655],[52.609,0],[0,-52.655],[-6.732,-13.072],[2.195,-5.158],[0,0],[-3.508,1.495],[0,0],[-5.004,-2.629]],"o":[[52.609,0],[0,-52.655],[-52.609,0],[0,15.719],[2.566,4.983],[0,0],[-1.494,3.511],[0,0],[5.2,-2.216],[13.213,6.942]],"v":[[1.724,93.868],[96.981,-1.472],[1.724,-96.813],[-93.534,-1.472],[-83.001,42.15],[-82.039,58.154],[-95.487,89.759],[-89.932,95.318],[-58.606,81.964],[-42.489,82.999]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,0.663,0.678,0.729,0.5,0.739,0.767,0.804,1,0.816,0.855,0.878],"ix":9}},"s":{"a":0,"k":[64,-66],"ix":5},"e":{"a":0,"k":[-62,38],"ix":6},"t":2,"h":{"a":0,"k":0.7,"ix":7},"a":{"a":0,"k":234,"ix":8},"nm":"Relleno de degradado 1","mn":"ADBE Vector Graphic - G-Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[196.706,-238.209],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 4","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"brazo","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.22],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.22],"y":[0]},"t":20,"s":[-11]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.22],"y":[0]},"t":48,"s":[0]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.22],"y":[0]},"t":92,"s":[-11]},{"t":119.999985219045,"s":[0]}],"ix":10},"p":{"a":0,"k":[374.034,417.646,0],"ix":2},"a":{"a":0,"k":[374.909,419.896,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.921,13.447],[25.176,53.268],[-3.379,3.533],[-8.475,6.474],[5.321,-12.784],[4.236,-3.527],[-24.878,-4.934],[7.695,-18.024]],"o":[[-34.472,-21.175],[-2.089,-4.421],[9.19,-9.611],[12.542,-3.466],[-3.643,2.928],[13.303,42.17],[0,0],[-7.695,18.024]],"v":[[-26.502,-4.753],[-105.186,-96.689],[-103.079,-109.921],[-77.048,-133.658],[-57.286,-113.69],[-69.067,-104.031],[-13.96,-41.269],[8.224,-8.442]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4117647058823529,0.3411764705882353,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[381.846,433.668],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 10","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[14.062,-13.694],[24.703,-26.246],[0,0],[-24.159,11.492],[-5.186,-1.617],[0,0]],"o":[[0,0],[-4.542,-1.803],[-26.177,6.526],[0,0],[26.324,-22.481],[0,0],[5.186,1.617],[0,0]],"v":[[50.197,-32.902],[45.205,-36.596],[24.893,-10.08],[-57.197,41.271],[-32.494,46.596],[32.114,3.233],[39.227,2.983],[56.147,-26.711]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.850979973288,0.760783954695,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[340.712,281.899],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 16","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"direcciones","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[216.489,620.463,0],"ix":2},"a":{"a":0,"k":[216.489,616.463,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-72.652,41.979],[-85.257,-11.821],[57.865,-41.979],[85.257,-18.157],[72.955,7.807]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.7607843137254902,0.19607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[229.262,629.861],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 17","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[79.268,33.382],[84.972,-21.584],[-60.81,-33.382],[-84.972,-6.284],[-69.489,17.915]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.9372549019607843,0.1803921568627451,0.3568627450980392,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[203.43,528.396],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 18","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-71.052,43.768],[-85.137,-9.664],[57.097,-43.768],[85.137,-20.713],[73.557,5.582]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.9725490196078431,0.6941176470588235,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[209.912,440.108],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 19","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15.005,-236.359],[-32.21,-234.631],[15.006,236.359],[32.21,234.631]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.337254991718,0.254901990704,0.172548989689,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[220.064,616.463],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 21","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"piso","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,1008,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[790,181.7,100],"ix":6}},"ao":0,"shapes":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-35,0]],"o":[[0,0],[0,0],[0,0],[0,0],[35,0]],"v":[[50,-50],[50,50],[-50,50],[-50,-50],[0.25,-98.913]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,0.769,0.769,0.769,0.5,0.776,0.802,0.818,1,0.784,0.835,0.867,0.717,0,0.859,0.4,1,0.8],"ix":9}},"s":{"a":0,"k":[0,0],"ix":5},"e":{"a":0,"k":[100,0],"ix":6},"t":2,"h":{"a":0,"k":0,"ix":7},"a":{"a":0,"k":0,"ix":8},"nm":"Relleno de degradado 1","mn":"ADBE Vector Graphic - G-Fill","hd":false}],"ip":0,"op":120.000229360136,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"blobs","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,550,0],"ix":2},"a":{"a":0,"k":[400,550,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":1100,"ip":0,"op":120.000229360136,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/lib/views/Dashboard/Cart/cartpage.dart b/lib/views/Dashboard/Cart/cartpage.dart new file mode 100644 index 0000000..1075fb5 --- /dev/null +++ b/lib/views/Dashboard/Cart/cartpage.dart @@ -0,0 +1,995 @@ +part of '../deliveries/deliveries.dart'; + +// ------------------------------------------------------------------------- +// CART PAGE (Active Deliveries) +// ------------------------------------------------------------------------- +class Cartpage extends StatefulWidget { + const Cartpage({super.key}); + + @override + State createState() => _CartpageState(); +} + +class _CartpageState extends State with AutomaticKeepAliveClientMixin { + final DeliveryProvider _provider = DeliveryProvider(); + final CreateDeliveryLogProvider _deliveryLogProvider = CreateDeliveryLogProvider(); + + List> _activeDeliveries = >[]; + StreamSubscription? _pollerSubscription; + bool _fetching = false; + final Map _deliveryTimers = {}; + final Map> _deliveryBasePayload = >{}; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _fetchActive(); + _startPolling(); + } + + @override + void dispose() { + _pollerSubscription?.cancel(); + _stopAllTimers(); + super.dispose(); + } + + void _startPolling() { + _pollerSubscription?.cancel(); + _pollerSubscription = Stream.periodic( + const Duration(seconds: 10), + (_) {}, + ).asyncMap((_) async { + if (!_fetching && mounted) { + await _fetchActive(); + } + }).listen( + (_) {}, + onError: (error) { + debugPrint('[CART][STREAM ERROR] $error'); + }, + cancelOnError: false, + ); + } + + Future _fetchActive() async { + if (_fetching) return; + + _fetching = true; + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + + if (userId == 0) { + debugPrint('[CART] No user ID found'); + _fetching = false; + return; + } + + // Get current date in YYYY-MM-DD format + final now = DateTime.now(); + final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + + // Hardcoded API endpoint for cart page only: v2/deliveries/getdeliveries + final bool isLive = ApiConstants.mainRoute == 'live'; + final baseUrl = isLive + ? 'https://jupiter.nearle.app/live/api/v2/deliveries/getdeliveries' + : 'https://jupiter.nearle.app/dev/api/v2/deliveries/getdeliveries'; + + final uri = Uri.parse(baseUrl).replace(queryParameters: { + 'userid': userId.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }); + + debugPrint('[CART] Fetching from: $uri'); + + // Fetch deliveries from API directly using http + // Hardcoded endpoint for cart page: v2/deliveries/getdeliveries + final httpClient = http.Client(); + List items = []; + try { + final response = await httpClient.get(uri); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final decoded = json.decode(response.body); + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + items = data is List + ? data + : (data is Map && data['items'] is List ? data['items'] as List : []); + } else { + debugPrint('[CART] API error: ${response.statusCode}'); + } + } catch (e) { + debugPrint('[CART] Error fetching from API: $e'); + } finally { + httpClient.close(); + } + + debugPrint('[CART] Raw API items count: ${items.length}'); + + // Debug: Print all order statuses to see what we're getting + for (final item in items) { + if (item is Map) { + final orderId = (item['orderid'] ?? '').toString(); + final status = (item['orderstatus'] ?? '').toString(); + debugPrint('[CART] Order $orderId has status: "$status" (raw: ${item['orderstatus']})'); + } + } + + // Filter for ACTIVE status only + final activeOrders = items + .whereType>() + .where((order) { + final status = (order['orderstatus']?.toString().toLowerCase() ?? '').trim(); + final isActive = status == 'active'; + if (isActive) { + debugPrint('[CART] ✅ Found active order: ${order['orderid']}'); + } else { + debugPrint('[CART] ❌ Order ${order['orderid']} has status: "$status" (not active)'); + } + return isActive; + }) + .toList(); + + debugPrint('[CART] Found ${activeOrders.length} active deliveries out of ${items.length} total'); + + // Sort by order ID or step number if available + activeOrders.sort((a, b) { + final stepA = (a['step'] ?? a['Step'] ?? 0).toString(); + final stepB = (b['step'] ?? b['Step'] ?? 0).toString(); + final stepAInt = int.tryParse(stepA) ?? 0; + final stepBInt = int.tryParse(stepB) ?? 0; + if (stepAInt != stepBInt) return stepAInt.compareTo(stepBInt); + + final orderIdA = (a['orderid'] ?? '').toString(); + final orderIdB = (b['orderid'] ?? '').toString(); + return orderIdA.compareTo(orderIdB); + }); + + if (mounted) { + setState(() { + _activeDeliveries = activeOrders; + }); + } + + // Get all active order IDs + final activeOrderIds = activeOrders + .map((o) => (o['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // ✅ CRITICAL: Start/restart timers for ALL active deliveries + // This ensures every active delivery posts logs every 30 seconds + for (final order in activeOrders) { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) { + debugPrint('[CART] ⚠️ Skipping order with empty orderId'); + continue; + } + + // Always restart timer to ensure it's running (handles edge cases) + if (_deliveryTimers.containsKey(orderId)) { + debugPrint('[CART] 🔄 Restarting timer for active delivery: $orderId'); + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers.remove(orderId); + // Also clear base payload to force reload + _deliveryBasePayload.remove(orderId); + } + + debugPrint('[CART] ▶️ Starting timer for active delivery: $orderId'); + await _startDeliveryPosting(order); + debugPrint('[CART] ✅ Timer started successfully for: $orderId'); + } + + // ✅ Stop timers for deliveries that are no longer active + final timersToStop = _deliveryTimers.keys + .where((id) => !activeOrderIds.contains(id)) + .toList(); + + for (final id in timersToStop) { + debugPrint('[CART] Stopping timer for orderId: $id (no longer active)'); + _stopDeliveryPosting(id); + } + + debugPrint('[CART] Active timers: ${_deliveryTimers.keys.toList()}'); + } catch (e) { + debugPrint('[CART] Error fetching active deliveries: $e'); + } finally { + _fetching = false; + } + } + + Future _startDeliveryPosting(Map order) async { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) { + debugPrint('[CART][DELIVERYLOG] ⚠️ Cannot start timer: empty orderId'); + return; + } + + // Safety check: If timer already exists, cancel it first (shouldn't happen after cleanup above) + if (_deliveryTimers.containsKey(orderId)) { + debugPrint('[CART][DELIVERYLOG] ⚠️ Timer already exists for $orderId, canceling old one'); + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers.remove(orderId); + } + + debugPrint('[CART][DELIVERYLOG] 🚀 Starting 30-second timer for orderId: $orderId'); + + // Get starttime from SharedPreferences (saved when order became active via updateActiveStatus) + // If not found, use activetime from order data, or current time as fallback + String startTime = ''; + try { + final prefs = await SharedPreferences.getInstance(); + final deliveryId = (order['deliveryid'] ?? 0).toString(); + + // Method 1: Get from SharedPreferences (saved when order became active) + startTime = prefs.getString('delivery_starttime_$deliveryId') ?? ''; + if (startTime.isNotEmpty) { + debugPrint('[CART][DELIVERYLOG] ✅ Loaded starttime from SharedPreferences: $startTime'); + } + + // Method 2: Fallback - try to get from order data (starttime field) + if (startTime.isEmpty) { + startTime = (order['starttime'] ?? order['startTime'] ?? '').toString(); + if (startTime.isNotEmpty) { + debugPrint('[CART][DELIVERYLOG] ✅ Loaded starttime from order data: $startTime'); + } + } + + // Method 3: Fallback - try activetime from order data + if (startTime.isEmpty) { + final activetime = (order['activetime'] ?? order['activTime'] ?? '').toString(); + if (activetime.isNotEmpty) { + startTime = activetime; + debugPrint('[CART][DELIVERYLOG] ✅ Loaded starttime from activetime: $startTime'); + } + } + + // Method 4: Last fallback - current time (shouldn't happen if updateActiveStatus was called) + if (startTime.isEmpty) { + final now = DateTime.now(); + startTime = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + debugPrint('[CART][DELIVERYLOG] ⚠️ Using current time as starttime fallback: $startTime'); + } + } catch (e) { + debugPrint('[CART][DELIVERYLOG] ❌ Error getting starttime: $e'); + // Set a fallback starttime even on error + final now = DateTime.now(); + startTime = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + } + + // CRITICAL: Ensure starttime is never empty + if (startTime.isEmpty) { + final now = DateTime.now(); + startTime = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + debugPrint('[CART][DELIVERYLOG] ⚠️ Final fallback: starttime was empty, using: $startTime'); + } + + debugPrint('[CART][DELIVERYLOG] 📝 Final starttime for orderId $orderId: $startTime'); + + // Create base payload with starttime + final base = { + 'logid': 0, + 'tenantid': order['tenantid'] ?? 0, + 'partnerid': order['partnerid'] ?? 0, + 'locationid': order['locationid'] ?? 0, + 'orderheaderid': order['orderheaderid'] ?? 0, + 'deliveryid': order['deliveryid'] ?? 0, + 'userid': order['userid'] ?? 0, + 'orderid': orderId, + 'orderstatus': 'active', + 'starttime': startTime, // Include starttime in base payload + }; + + _deliveryBasePayload[orderId] = base; + + // Save to SharedPreferences for persistence + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('deliverylog_${orderId}_tenantid', (base['tenantid'] ?? 0).toString()); + await prefs.setString('deliverylog_${orderId}_partnerid', (base['partnerid'] ?? 0).toString()); + await prefs.setString('deliverylog_${orderId}_locationid', (base['locationid'] ?? 0).toString()); + await prefs.setString('deliverylog_${orderId}_orderheaderid', (base['orderheaderid'] ?? 0).toString()); + await prefs.setString('deliverylog_${orderId}_deliveryid', (base['deliveryid'] ?? 0).toString()); + await prefs.setString('deliverylog_${orderId}_userid', (base['userid'] ?? 0).toString()); + await prefs.setString('deliverylog_${orderId}_orderid', orderId); + await prefs.setString('deliverylog_${orderId}_orderstatus', 'active'); + await prefs.setString('deliverylog_${orderId}_starttime', startTime); // Save starttime + } catch (e) { + debugPrint('[CART][DELIVERYLOG] Error saving payload: $e'); + } + + // Post once immediately (don't await - let it run in background) + _postDeliveryLog(orderId, order); + debugPrint('[CART][DELIVERYLOG] 📤 Posted initial log for orderId: $orderId'); + + // Then every 30 seconds - CRITICAL: This ensures logs are posted every 30 seconds + final timer = Timer.periodic(const Duration(seconds: 30), (t) { + debugPrint('[CART][DELIVERYLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)'); + _postDeliveryLog(orderId, order); + }); + + _deliveryTimers[orderId] = timer; + debugPrint('[CART][DELIVERYLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)'); + } + + void _postDeliveryLog(String orderId, Map order) { + if (!mounted) { + debugPrint('[CART][DELIVERYLOG][POST] Widget disposed, skipping'); + return; + } + + debugPrint('[CART][DELIVERYLOG][POST] ⏰ Posting log for orderId: $orderId at ${DateTime.now()}'); + + // Use Future.microtask to ensure the async operation runs independently + Future.microtask(() => _performPost(orderId)); + } + + Future _performPost(String orderId) async { + try { + debugPrint('[CART][DELIVERYLOG][POST] 🔄 Starting _performPost for orderId: $orderId'); + + Map? base = _deliveryBasePayload[orderId]; + + if (base == null) { + debugPrint('[CART][DELIVERYLOG][POST] Base is null, loading from SharedPreferences'); + try { + final prefs = await SharedPreferences.getInstance(); + if (!mounted) { + debugPrint('[CART][DELIVERYLOG][POST] Widget unmounted after prefs load'); + return; + } + + base = { + 'logid': 0, + 'tenantid': int.tryParse(prefs.getString('deliverylog_${orderId}_tenantid') ?? '0') ?? 0, + 'partnerid': int.tryParse(prefs.getString('deliverylog_${orderId}_partnerid') ?? '0') ?? 0, + 'locationid': int.tryParse(prefs.getString('deliverylog_${orderId}_locationid') ?? '0') ?? 0, + 'orderheaderid': int.tryParse(prefs.getString('deliverylog_${orderId}_orderheaderid') ?? '0') ?? 0, + 'deliveryid': int.tryParse(prefs.getString('deliverylog_${orderId}_deliveryid') ?? '0') ?? 0, + 'userid': int.tryParse(prefs.getString('deliverylog_${orderId}_userid') ?? '0') ?? 0, + 'orderid': prefs.getString('deliverylog_${orderId}_orderid') ?? orderId, + 'orderstatus': prefs.getString('deliverylog_${orderId}_orderstatus') ?? 'active', + 'starttime': prefs.getString('deliverylog_${orderId}_starttime') ?? '', // Load starttime + }; + debugPrint('[CART][DELIVERYLOG][POST] Base loaded from prefs: $base'); + } catch (e) { + debugPrint('[CART][DELIVERYLOG][POST] Error loading base: $e'); + return; + } + } + + // At this point, base is guaranteed to be non-null (either from cache or created above) + final basePayload = base; // Flow analysis ensures base is non-null here + + debugPrint('[CART][DELIVERYLOG][POST] Getting coordinates...'); + + // CRITICAL: Get coordinates with retry logic - NEVER post with null or '0' coordinates + final coords = await _getValidCoordinates().timeout( + const Duration(seconds: 10), // Increased timeout to allow retries + onTimeout: () { + debugPrint('[CART][DELIVERYLOG][POST] ❌ Coordinate timeout after retries'); + return null; + }, + ); + + if (!mounted) { + debugPrint('[CART][DELIVERYLOG][POST] Widget unmounted after coords'); + return; + } + + // CRITICAL: Validate coordinates - NEVER post with null, '0', or invalid coordinates + if (coords == null || coords.$1.isEmpty || coords.$2.isEmpty || + coords.$1 == '0' || coords.$2 == '0') { + debugPrint('[CART][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})'); + debugPrint('[CART][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)'); + return; // Skip this post - don't send invalid coordinates + } + + // Validate coordinate ranges + final latDouble = double.tryParse(coords.$1) ?? 0.0; + final lngDouble = double.tryParse(coords.$2) ?? 0.0; + if (latDouble == 0 || lngDouble == 0 || + latDouble.abs() > 90 || lngDouble.abs() > 180) { + debugPrint('[CART][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)'); + debugPrint('[CART][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)'); + return; // Skip this post - don't send invalid coordinates + } + + debugPrint('[CART][DELIVERYLOG][POST] ✅ Valid coordinates: lat=${coords.$1}, lng=${coords.$2}'); + + // Cumulative KM is tracked exclusively by LiveTrackingService (high-frequency, every 3s). + // Do not accumulate here to avoid race conditions with concurrent writers. + + final now = DateTime.now(); + final logdate = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + + // CRITICAL: Ensure starttime is always included in payload + final starttimeValue = basePayload['starttime']?.toString() ?? ''; + if (starttimeValue.isEmpty) { + debugPrint('[CART][DELIVERYLOG][POST] ⚠️ WARNING: starttime is empty in basePayload, using fallback'); + } + + // CRITICAL: Use validated coordinates - guaranteed to be non-null and valid at this point + final payload = { + ...basePayload, + 'logdate': logdate, + 'latitude': coords.$1, // Guaranteed non-null and valid + 'longitude': coords.$2, // Guaranteed non-null and valid + 'starttime': starttimeValue.isNotEmpty ? starttimeValue : '', // CRITICAL: Always include starttime + }; + + // Validate payload has all required fields + final requiredFields = ['tenantid', 'partnerid', 'locationid', 'orderheaderid', 'deliveryid', 'userid', 'orderid', 'orderstatus', 'starttime']; + final missingFields = requiredFields.where((field) => payload[field] == null || payload[field] == '').toList(); + if (missingFields.isNotEmpty) { + debugPrint('[CART][DELIVERYLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields'); + } + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + debugPrint('[CART][DELIVERYLOG][POST] 📤 Sending to API: $url'); + debugPrint('[CART][DELIVERYLOG][POST] 📦 Payload: $payload'); + debugPrint('[CART][DELIVERYLOG][POST] ✅ starttime in payload: "${payload['starttime']}"'); + + await _deliveryLogProvider + .createDeliveryLog(url, payload) + .timeout( + const Duration(seconds: 8), + onTimeout: () { + debugPrint('[CART][DELIVERYLOG][POST] ⚠️ API timeout for orderId: $orderId'); + throw TimeoutException('API timeout', const Duration(seconds: 8)); + }, + ); + + debugPrint('[CART][DELIVERYLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}'); + } catch (e, stackTrace) { + debugPrint('[CART][DELIVERYLOG][POST] ❌ ERROR for orderId: $orderId - $e'); + debugPrint('[CART][DELIVERYLOG][POST] Stack trace: $stackTrace'); + } + } + + Future<(String lat, String lng)?> _getValidCoordinates({int retryCount = 0}) async { + const maxRetries = 3; + + try { + final bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[CART][COORDS] Location service disabled, trying last known position'); + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && lastPos.latitude != 0 && lastPos.longitude != 0) { + final lat = lastPos.latitude.toString(); + final lng = lastPos.longitude.toString(); + debugPrint('[CART][COORDS] ✅ Using last known position: $lat, $lng'); + return (lat, lng); + } + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.deniedForever || + permission == LocationPermission.denied) { + debugPrint('[CART][COORDS] Permission denied, trying last known position'); + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && lastPos.latitude != 0 && lastPos.longitude != 0) { + final lat = lastPos.latitude.toString(); + final lng = lastPos.longitude.toString(); + debugPrint('[CART][COORDS] ✅ Using last known position: $lat, $lng'); + return (lat, lng); + } + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + + Position? position; + try { + // Try to get current position with higher accuracy + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, // Changed to high for better accuracy + timeLimit: Duration(seconds: 8), // Increased timeout + ), + ).timeout(const Duration(seconds: 8)); + } catch (e) { + debugPrint('[CART][COORDS] Timeout getting current position: $e, trying last known'); + position = await Geolocator.getLastKnownPosition(); + } + + if (position != null && position.latitude != 0 && position.longitude != 0) { + final lat = position.latitude.toString(); + final lng = position.longitude.toString(); + + // Validate coordinates are within valid GPS ranges + final latDouble = double.tryParse(lat) ?? 0.0; + final lngDouble = double.tryParse(lng) ?? 0.0; + if (latDouble.abs() <= 90 && lngDouble.abs() <= 180) { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lat); + await prefs.setString('last_lng', lng); + } catch (_) {} + debugPrint('[CART][COORDS] ✅ Got valid coordinates: $lat, $lng'); + return (lat, lng); + } else { + debugPrint('[CART][COORDS] ⚠️ Invalid coordinate ranges: $lat, $lng'); + } + } + + // Fallback to SharedPreferences cached coordinates + try { + final prefs = await SharedPreferences.getInstance(); + final lat = (prefs.getString('last_lat') ?? '').trim(); + final lng = (prefs.getString('last_lng') ?? '').trim(); + if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') { + final latDouble = double.tryParse(lat) ?? 0.0; + final lngDouble = double.tryParse(lng) ?? 0.0; + if (latDouble != 0 && lngDouble != 0 && latDouble.abs() <= 90 && lngDouble.abs() <= 180) { + debugPrint('[CART][COORDS] ✅ Using cached coordinates: $lat, $lng'); + return (lat, lng); + } + } + } catch (_) {} + + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + debugPrint('[CART][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates'); + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + + debugPrint('[CART][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries'); + return null; + } catch (e) { + debugPrint('[CART][COORDS] ❌ Error getting coordinates: $e'); + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + } + + void _stopDeliveryPosting(String orderId) { + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers.remove(orderId); + _deliveryBasePayload.remove(orderId); + debugPrint('[CART][DELIVERYLOG] Stopped timer for orderId: $orderId'); + } + + void _stopAllTimers() { + for (final timer in _deliveryTimers.values) { + timer.cancel(); + } + _deliveryTimers.clear(); + _deliveryBasePayload.clear(); + debugPrint('[CART][DELIVERYLOG] Stopped all timers'); + } + + // Method to stop delivery posting for a specific order (called when order is completed) + void stopDeliveryPostingForOrder(String orderId) { + _stopDeliveryPosting(orderId); + // Refresh the list to remove completed orders + if (mounted) { + _fetchActive(); + } + } + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + double _haversineKm(double lat1, double lon1, double lat2, double lon2) { + const double R = 6371.0; + final double dLat = _toRadians(lat2 - lat1); + final double dLon = _toRadians(lon2 - lon1); + final double a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_toRadians(lat1)) * + math.cos(_toRadians(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + final double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + return R * c; + } + + double _toRadians(double degrees) { + return degrees * math.pi / 180.0; + } + + String _distanceKmDisplay(Map m) { + // Try API distance first + final String apiKmsStr = (m['actualkms'] ?? m['distance'] ?? '0').toString(); + final double apiKms = double.tryParse(apiKmsStr) ?? 0.0; + if (apiKms > 0) { + return apiKms < 10 ? apiKmsStr : apiKms.toStringAsFixed(0); + } + + // Try rider location to delivery location + final double rLat = _parseD(m['riderslat']); + final double rLon = _parseD(m['riderslon']); + final double dLat = _parseD(m['droplat'] ?? m['deliverylat']); + final double dLon = _parseD(m['droplon'] ?? m['deliverylong']); + + if (rLat != 0 && rLon != 0 && dLat != 0 && dLon != 0) { + final double km = _haversineKm(rLat, rLon, dLat, dLon); + return km.toStringAsFixed(km < 10 ? 1 : 0); + } + + // Fallback: pickup to delivery + final double pLat = _parseD(m['pickuplat']); + final double pLon = _parseD(m['pickuplon']); + if (pLat != 0 && pLon != 0 && dLat != 0 && dLon != 0) { + final double km = _haversineKm(pLat, pLon, dLat, dLon); + return km.toStringAsFixed(km < 10 ? 1 : 0); + } + + return '0'; + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return Scaffold( + backgroundColor: Colors.grey.shade200, + appBar: AppBar( + backgroundColor: Colors.grey.shade200, + elevation: 0, + centerTitle: false, + toolbarHeight: 70, + title: Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + "Active Deliveries", + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black, + ), + ), + ), + bottom: const PreferredSize( + preferredSize: Size.fromHeight(1), + child: Divider(height: 1, color: Colors.grey), + ), + ), + body: SafeArea( + child: _activeDeliveries.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 80), + Transform.translate( + offset: const Offset(0, -11), + child: Image.asset( + "assets/images/Nearle Bike.png", + errorBuilder: (c, e, s) => const Icon( + Icons.delivery_dining, + size: 120, + color: Colors.grey, + ), + ), + ), + const SizedBox(height: 16), + Transform.translate( + offset: const Offset(0, -11), + child: Text( + "No Active Deliveries", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + fontFamily: FontConstants.fontFamily, + color: Colors.grey, + ), + ), + ), + ], + ), + ) + : RefreshIndicator( + onRefresh: _fetchActive, + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 20), + itemCount: _activeDeliveries.length, + itemBuilder: (context, index) { + final item = _activeDeliveries[index]; + final customerName = (item['deliverycustomer'] ?? item['customer'] ?? 'Customer').toString(); + final address = (item['deliveryaddress'] ?? item['address'] ?? 'Address not available').toString(); + final storeName = (item['pickupcustomer'] ?? item['store'] ?? 'Store').toString(); + final orderId = (item['orderid'] ?? '').toString(); + final distance = _distanceKmDisplay(item); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ------------------------------------------- + // TOP CUSTOMER DETAILS + // ------------------------------------------- + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + ), + ), + Container( + width: 2, + height: 30, + color: Colors.grey.shade300, + ), + Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Transform.translate( + offset: const Offset(0, -3), + child: Text( + customerName, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + const SizedBox(height: 8), + Transform.translate( + offset: const Offset(0, 8), + child: Text( + address, + style: TextStyle( + fontSize: 18, + color: Colors.black87, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Distance: $distance km', + style: TextStyle( + fontSize: 16, + color: Colors.blueGrey, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + InkWell( + onTap: () async { + // Just launch dialer; PiP is handled only from navigation screen + final phone = + (item['deliverycontactno'] ?? '').toString(); + final bool success = await launchPhoneDialer( + phone.isNotEmpty ? phone : '9876543210', + ); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not launch dialer'), + ), + ); + } + }, + child: Image.asset( + 'assets/images/phone-call .png', + height: 27, + width: 27, + errorBuilder: (c, e, s) => + const Icon(Icons.phone, size: 27, color: Colors.green), + ), + ), + ], + ), + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 8), + // ------------------------------------------- + // STORE DETAILS + // ------------------------------------------- + Row( + children: [ + Image.asset( + 'assets/images/shoppingbag.png', + height: 32, + width: 32, + errorBuilder: (c, e, s) => const Icon( + Icons.shopping_bag, + size: 32, + color: Colors.orange, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + storeName, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + InkWell( + onTap: () async { + _showMyOptionsSheet( + context, + item, + null, // No parent state for cart page + ); + // Refresh cart page after skip (order will no longer be active) + if (mounted) { + await Future.delayed(const Duration(seconds: 1)); + _fetchActive(); + } + }, + child: Transform.translate( + offset: const Offset(0, -5), + child: Text( + 'Skip>>', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primaryColor, + ), + ), + ), + ), + ], + ), + Text( + "Order ID: #$orderId", + style: TextStyle( + fontSize: 18, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 15), + // ------------------------------------------- + // SLIDER BUTTON + // ------------------------------------------- + SliderButton( + properties: SliderButtonProperties( + height: 50, + buttonSize: 45, + width: MediaQuery.of(context).size.width - 56, + backgroundColor: ColorConstants.primaryColor, + dismissThresholds: 0.90, + action: () async { + await Future.delayed(const Duration(milliseconds: 400)); + if (!context.mounted) return false; + + // Navigate to delivery map screen (same as deliveries page) + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => _DeliveryMapScreen( + delivery: item, + parentState: null, // No parent state for cart page + ), + ), + ); + + // Refresh cart page when returning from map screen + // (in case delivery was completed/cancelled) + if (mounted) { + await Future.delayed(const Duration(milliseconds: 500)); + _fetchActive(); + } + + return false; + }, + label: const Text( + 'Slide to start Delivery', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + icon: ClipOval( + child: Material( + color: Colors.white, + child: SizedBox( + width: 45, + height: 45, + child: Center( + child: Text( + '${index + 1}', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), +); + } +} diff --git a/lib/views/Dashboard/deliveries/card.dart b/lib/views/Dashboard/deliveries/card.dart new file mode 100644 index 0000000..a8771f0 --- /dev/null +++ b/lib/views/Dashboard/deliveries/card.dart @@ -0,0 +1,329 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// DELIVERY CARD +// ------------------------------------------------------------------------- +class DeliveryCard extends StatelessWidget { + final Map item; + final int displayStep; + final String distanceStr; + final bool enabled; + final bool isSkipped; + + const DeliveryCard({ + super.key, + required this.item, + required this.displayStep, + required this.distanceStr, + this.enabled = true, + this.isSkipped = false, + }); + + @override + Widget build(BuildContext context) { + final String customerName = (item['deliverycustomer'] ?? 'Customer') + .toString(); + final String address = (item['deliveryaddress'] ?? 'Address not available') + .toString(); + final String tenantName = (item['tenantname'] ?? 'Store').toString(); + final String orderId = (item['orderid'] ?? '').toString(); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: isSkipped ? Border.all(color: Colors.orange, width: 2) : null, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isSkipped) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'SKIPPED', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.orange.shade900, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (isSkipped) const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + ), + ), + Container( + width: 2, + height: 30, + color: Colors.grey.shade300, + ), + Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Transform.translate( + offset: const Offset(0, -3), + child: Text( + customerName, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 17.5, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + const SizedBox(height: 8), + Transform.translate( + offset: const Offset(0, 8), + child: Text( + address, + style: TextStyle( + fontSize: 18, + color: Colors.black87, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Distance: $distanceStr km', + style: TextStyle( + fontSize: 17, + color: Colors.blueGrey, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + InkWell( + onTap: () async { + // Just launch dialer; PiP is handled only from navigation screen + final phone = (item['deliverycontactno'] ?? '').toString(); + final bool success = await launchPhoneDialer( + phone.isNotEmpty ? phone : '9876543210', + ); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not launch dialer'), + ), + ); + } + }, + child: Image.asset( + 'assets/images/phone-call .png', + height: 27, + width: 27, + errorBuilder: (c, e, s) => + const Icon(Icons.phone, size: 27, color: Colors.green), + ), + ), + ], + ), + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 8), + Row( + children: [ + Image.asset( + 'assets/images/shoppingbag.png', + height: 32, + width: 32, + errorBuilder: (c, e, s) => const Icon( + Icons.shopping_bag, + size: 32, + color: Colors.orange, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + tenantName, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + if (!isSkipped) + InkWell( + onTap: () { + final parentState = context + .findAncestorStateOfType< + _MyDeliveriesState + >(); + if (parentState != null) { + _showMyOptionsSheet( + context, + item, + parentState, + ); + } + }, + child: Transform.translate( + offset: const Offset(0, -5), + child: Text( + 'Skip>>', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primaryColor, + ), + ), + ), + ), + ], + ), + Text( + 'Order ID: #$orderId', + style: TextStyle( + fontSize: 18, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 15), + SliderButton( + properties: SliderButtonProperties( + height: 50, + buttonSize: 45, + width: MediaQuery.of(context).size.width - 56, + backgroundColor: enabled + ? ColorConstants.primaryColor + : Colors.grey.shade400, + dismissThresholds: 0.90, + action: enabled + ? () async { + // Reduce delay to make it feel snappier + await Future.delayed(const Duration(milliseconds: 50)); + if (!context.mounted) return false; + final parentState = context + .findAncestorStateOfType<_MyDeliveriesState>(); + + // ✅ BLOCK: Check if there's already an active delivery (and this isn't it) + if (parentState != null) { + final currentOrderId = (item['orderid'] ?? '') + .toString(); + final activeOrderIds = parentState._activeDeliveries + .map((d) => (d['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // Block if there's a different active delivery: just ignore the swipe + if (parentState._activeDeliveries.isNotEmpty && + !activeOrderIds.contains(currentOrderId)) { + return false; + } + } + + // Navigate to delivery map screen + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => _DeliveryMapScreen( + delivery: item, + parentState: parentState, + ), + ), + ); + } + return false; + } + : () async => null, + label: Transform.translate( + offset: const Offset(-0.5, 0), + child: Text( + isSkipped + ? 'Slide to resume Delivery' + : (enabled + ? 'Slide to start Delivery' + : 'Complete previous delivery'), + style: const TextStyle( + fontSize: 18.5, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ), + icon: ClipOval( + child: Material( + color: Colors.white, + child: SizedBox( + width: 45, + height: 45, + child: Center( + child: Text( + '$displayStep', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: enabled + ? const ui.Color.fromARGB(255, 0, 0, 0) + : Colors.grey, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/deliveries/deliveries.dart b/lib/views/Dashboard/deliveries/deliveries.dart new file mode 100644 index 0000000..3d6c18e --- /dev/null +++ b/lib/views/Dashboard/deliveries/deliveries.dart @@ -0,0 +1,1762 @@ +// ignore_for_file: unuse, unused_element, duplicate_ignore, unnecessary_cast +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter_polyline_points/flutter_polyline_points.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:get/get.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:lottie/lottie.dart' hide Marker; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart'; +import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:nearle/providers/delivery/delivery_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/controllers/deliveries_controller.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:nearle/widget/Bottom_page.dart'; +import 'package:circular_countdown_timer/circular_countdown_timer.dart'; +import 'dart:math' as math; +import 'package:nearle/controllers/riderlog.dart'; +import 'package:nearle/providers/deliverylog/deliverylog_provider.dart'; +import 'package:nearle/views/helpers/constants/apiconstants.dart'; +import 'dart:io' show Platform, File; +import 'package:image_picker/image_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:floating/floating.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:http/http.dart' as http; +import 'package:scratcher/scratcher.dart'; +import 'package:confetti/confetti.dart'; + +part 'card.dart'; +part 'map.dart'; +part 'nav.dart'; +part 'pip.dart'; +part 'sheet.dart'; +part 'map_btn.dart'; +part 'multi_map.dart'; +part 'done.dart'; +part 'skip_sheet.dart'; +part '../Cart/cartpage.dart'; + +/// Helper function to launch phone dialer - works in both debug and release builds +/// In release builds, canLaunchUrl may fail due to R8/ProGuard, so we always try to launch +Future launchPhoneDialer(String phoneNumber) async { + try { + // Sanitize phone number: keep only digits and '+' + final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), ''); + + if (phone.isEmpty) { + debugPrint( + '[PHONE] Empty phone number after sanitization, skipping dial', + ); + return false; + } + + final Uri uri = Uri(scheme: 'tel', path: phone); + + // Try canLaunchUrl first (works in debug, may fail in release) + bool canLaunch = false; + try { + canLaunch = await canLaunchUrl(uri); + debugPrint('[PHONE] canLaunchUrl result: $canLaunch'); + } catch (e) { + debugPrint('[PHONE] canLaunchUrl check failed (common in release): $e'); + // Continue anyway - launch might still work + } + + // Always attempt to launch, even if canLaunchUrl returned false + // Using LaunchMode.platformDefault is often safer for system intents like dialing + try { + final launched = await launchUrl(uri, mode: LaunchMode.platformDefault); + if (launched) { + debugPrint('[PHONE] Successfully launched dialer for: $phone'); + return true; + } else { + debugPrint('[PHONE] launchUrl returned false for: $phone'); + } + } catch (e) { + debugPrint('[PHONE] Failed to launch dialer: $e'); + } + return false; + } catch (e) { + debugPrint('[PHONE] Error in launchPhoneDialer: $e'); + return false; + } +} + +class MyDeliveries extends StatefulWidget { + const MyDeliveries({super.key}); + + @override + State createState() => _MyDeliveriesState(); + + // ✅ Public static method to navigate to delivery map screen from outside (e.g., home page) + static Future navigateToDeliveryMap( + BuildContext context, + Map delivery, + ) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => _RiderNavigationScreen( + delivery: delivery, + parentState: null, // No parent state when called from outside + ), + ), + ); + } +} + +class _MyDeliveriesState extends State + with AutomaticKeepAliveClientMixin, WidgetsBindingObserver { + final DeliveryProvider _provider = DeliveryProvider(); + final CreateDeliveryLogProvider _deliveryLogProvider = + CreateDeliveryLogProvider(); + List> _picked = >[]; + List> _activeDeliveries = + >[]; // Track active deliveries for banner + + StreamSubscription? _pollerSubscription; + bool _fetching = false; + Position? _currentLocation; + final Map _deliveryTimers = {}; + final Map> _deliveryBasePayload = + >{}; + // Preserve original step numbers so they don't change when deliveries are completed + final Map _preservedStepNumbers = {}; + String? _activeDeliveryOrderId; + // Cache for skipped orders + final Map _skippedOrdersCache = {}; + final Map _skippedOrderTimestamps = {}; + + Future _saveSkippedOrdersCache() async { + // Implementation can be empty if we rely on API now, + // or strictly local. For now, we'll keep it simple or empty + // to satisfy the interface expected by child widgets. + // If child widgets call this, they expect it to exist. + } + + @override + bool get wantKeepAlive => true; + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + double _haversineKm(double lat1, double lon1, double lat2, double lon2) { + const double R = 6371.0; + final double dLat = _toRadians(lat2 - lat1); + final double dLon = _toRadians(lon2 - lon1); + + final double a = + math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_toRadians(lat1)) * + math.cos(_toRadians(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + + final double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + return R * c; + } + + double _toRadians(double degrees) { + return degrees * math.pi / 180.0; + } + + int _getStepNumber(Map order) { + final dynamic raw = order['step'] ?? order['Step']; + final int step = raw == null + ? 0 + : (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0); + return step; + } + + String _getOrderKey(Map order) { + // Use deliveryId as primary key, fallback to orderId + final deliveryId = (order['deliveryid'] ?? '').toString(); + final orderId = (order['orderid'] ?? '').toString(); + return deliveryId.isNotEmpty ? 'delivery_$deliveryId' : 'order_$orderId'; + } + + int _getPreservedOrCurrentStep(Map order) { + final orderKey = _getOrderKey(order); + // If we have a preserved step number, use it; otherwise use current API step + if (_preservedStepNumbers.containsKey(orderKey)) { + return _preservedStepNumbers[orderKey]!; + } + final currentStep = _getStepNumber(order); + // Preserve the step number if it's valid (> 0) + if (currentStep > 0) { + _preservedStepNumbers[orderKey] = currentStep; + } + return currentStep; + } + + String _distanceKmDisplay(Map m) { + final String apiKmsStr = (m['kms'] ?? '').toString().trim(); + final double apiKms = double.tryParse(apiKmsStr) ?? 0.0; + if (apiKms > 0) { + return apiKms < 10 ? apiKmsStr : apiKms.toStringAsFixed(0); + } + + final double rLat = _parseD(m['riderslat']); + final double rLon = _parseD(m['riderslon']); + final double dLat = _parseD(m['droplat'] ?? m['deliverylat']); + final double dLon = _parseD(m['droplon'] ?? m['deliverylong']); + + if (rLat != 0 && rLon != 0 && dLat != 0 && dLon != 0) { + final double km = _haversineKm(rLat, rLon, dLat, dLon); + return km.toStringAsFixed(km < 10 ? 1 : 0); + } + + final double pLat = _parseD(m['pickuplat']); + final double pLon = _parseD(m['pickuplon']); + if (pLat != 0 && pLon != 0 && dLat != 0 && dLon != 0) { + final double km = _haversineKm(pLat, pLon, dLat, dLon); + return km.toStringAsFixed(km < 10 ? 1 : 0); + } + + return '0'; + } + + double _calculateDistanceFromCurrent(Map order) { + if (_currentLocation == null) return double.infinity; + + final double dropLat = _parseD(order['droplat'] ?? order['deliverylat']); + final double dropLon = _parseD(order['droplon'] ?? order['deliverylong']); + + if (dropLat == 0 || dropLon == 0) return double.infinity; + + return _haversineKm( + _currentLocation!.latitude, + _currentLocation!.longitude, + dropLat, + dropLon, + ); + } + + List> _sortOrders(List> orders) { + if (orders.isEmpty) return orders; + + // Use preserved step numbers for sorting to maintain original order + final ordersWithStep = orders + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .toList(); + final ordersWithoutStep = orders + .where((o) => _getPreservedOrCurrentStep(o) == 0) + .toList(); + + ordersWithStep.sort( + (a, b) => _getPreservedOrCurrentStep( + a, + ).compareTo(_getPreservedOrCurrentStep(b)), + ); + + if (_currentLocation != null && ordersWithoutStep.isNotEmpty) { + ordersWithoutStep.sort((a, b) { + final distA = _calculateDistanceFromCurrent(a); + final distB = _calculateDistanceFromCurrent(b); + return distA.compareTo(distB); + }); + } + + return [...ordersWithStep, ...ordersWithoutStep]; + } + + int _getDisplayStepNumber(List> allOrders, int index) { + final order = allOrders[index]; + // Use preserved step number if available, otherwise get current step + final step = _getPreservedOrCurrentStep(order); + + if (step > 0) return step; + + // For orders without step numbers, calculate based on orders that have step numbers + final ordersWithStep = allOrders + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + return ordersWithStep + (index - ordersWithStep) + 1; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WakelockPlus.enable(); + + // Listen for external refresh triggers + try { + final dc = Get.isRegistered() + ? Get.find() + : Get.put(DeliveriesController()); + + ever(dc.refreshTrigger, (_) { + if (mounted) { + debugPrint('[MYDELIVERIES] External trigger -> Refreshing picked orders'); + _fetchPicked(); + } + }); + } catch (_) {} + + // Run these in parallel + _restoreActiveDelivery(); + _initializeLocation(); // Don't block + _fetchPicked(); + _startPollingStream(); + } + + Future _restoreActiveDelivery() async { + try { + final prefs = await SharedPreferences.getInstance(); + final savedActiveId = prefs.getString('active_delivery_order_id'); + if (savedActiveId != null && savedActiveId.isNotEmpty) { + _activeDeliveryOrderId = savedActiveId; + debugPrint( + '[MYDELIVERIES] Restored active delivery orderId: $savedActiveId', + ); + } + } catch (e) { + debugPrint('[MYDELIVERIES] Error restoring active delivery: $e'); + } + } + + void _startPollingStream() { + _pollerSubscription?.cancel(); + _pollerSubscription = Stream.periodic(const Duration(seconds: 3), (_) {}) + .asyncMap((_) async { + if (mounted && !_fetching) { + await _fetchPicked(); + } + }) + .listen( + (_) {}, // Success handler + onError: (error) { + // Handle errors gracefully without crashing + debugPrint('[MYDELIVERIES][STREAM ERROR] $error'); + }, + cancelOnError: false, // Continue even on errors + ); + } + + Future _initializeLocation() async { + try { + // 1. Try Last Known Position (Instant & Preferred) + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + _currentLocation = lastPos; + }); + // If we have a cached location, don't block waiting for fresh GPS + // We can let the background stream update it later + return; + } + } catch (_) {} + + // 2. Try Current Position (Optimized) + final bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.deniedForever || + permission == LocationPermission.denied) { + return; + } + + // Single attempt with balanced accuracy/timeout + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 3), + ), + ); + + if (mounted) { + setState(() => _currentLocation = position); + } + } catch (_) { + // Fallback to low accuracy if high fails + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.low, + timeLimit: Duration(seconds: 2), + ), + ); + if (mounted) { + setState(() => _currentLocation = position); + } + } catch (_) {} + } + } catch (e) { + debugPrint('[MYDELIVERIES] Error initializing location: $e'); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _pollerSubscription?.cancel(); + for (final timer in _deliveryTimers.values) { + timer.cancel(); + } + _deliveryTimers.clear(); + _deliveryBasePayload.clear(); + // Persist current live deliveries state for other screens + SharedPreferences.getInstance() + .then((p) => p.setBool('has_live_deliveries', _picked.isNotEmpty)) + // ignore: body_might_complete_normally_catch_error + .catchError((_) {}); + WakelockPlus.disable(); + super.dispose(); + } + + bool _shallowMapEquals(Map a, Map b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (b[entry.key] != entry.value) { + return false; + } + } + return true; + } + + // Helper to check if two lists of orders are effectively equal + // This prevents unnecessary rebuilds when polling + bool _areOrdersEqual( + List> oldList, + List> newList, + ) { + if (oldList.length != newList.length) return false; + + for (int i = 0; i < oldList.length; i++) { + final oldItem = oldList[i]; + final newItem = newList[i]; + + // Compare critical fields that affect UI or logic + if (oldItem['orderid'] != newItem['orderid']) return false; + if (oldItem['orderstatus'] != newItem['orderstatus']) return false; + if (oldItem['step'] != newItem['step']) return false; + + // Compare location data (important for map updates) + if (oldItem['riderslat'] != newItem['riderslat']) return false; + if (oldItem['riderslon'] != newItem['riderslon']) return false; + if (oldItem['deliverylat'] != newItem['deliverylat']) return false; + if (oldItem['deliverylong'] != newItem['deliverylong']) return false; + + // Compare notes/instructions if they might change + if (oldItem['notes'] != newItem['notes']) return false; + } + + return true; + } + + // Manually mark skipped to trigger immediate refresh + void markOrderAsSkipped(Map order, String reason) { + if (!mounted) return; + debugPrint('[MYDELIVERIES] Mark skipped -> Refreshing from API...'); + // We don't update local state manually anymore, we trust the API to return the 'skipped' status. + // Just trigger a fetch. + _fetchPicked(); + } + + // ✅ Check if there's an active delivery (blocks all actions) + bool _hasActiveDelivery() { + return _activeDeliveries.isNotEmpty; + } + + // Previously showed a blocking dialog; now we just disable conflicting buttons in the UI. + void _showActiveDeliveryBlockMessage() {} + + Future startDelivery(Map item) async { + if (!mounted) return; + + // ✅ BLOCK: Check if there's already an active delivery (and this isn't it) + final currentOrderId = (item['orderid'] ?? '').toString(); + final activeOrderIds = _activeDeliveries + .map((d) => (d['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // Allow if this IS the active delivery, block if there's a different active delivery + if (_hasActiveDelivery() && !activeOrderIds.contains(currentOrderId)) { + // Just ignore taps on other cards when a different active delivery exists. + return; + } + + // Verify status is updated to ACTIVE before navigating + final dc = Get.put(DeliveriesController()); + final dId = int.tryParse((item['deliveryid'] ?? 0).toString()) ?? 0; + final ohId = int.tryParse((item['orderheaderid'] ?? 0).toString()) ?? 0; + + if (dId > 0 && ohId > 0) { + // We don't block navigation on failure, but we try to update + // This ensures 'starttime' is generated and saved + await dc.updateActiveStatus( + deliveryId: dId, + orderHeaderId: ohId, + orderId: (item['orderid'] ?? '').toString(), + ); + } + + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + _RiderNavigationScreen(delivery: item, parentState: this), + ), + ); + } + + Future resumeDelivery(Map item) async { + // ✅ BLOCK: Check if there's already an active delivery (and this isn't it) + final currentOrderId = (item['orderid'] ?? '').toString(); + final activeOrderIds = _activeDeliveries + .map((d) => (d['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // Allow if this IS the active delivery, block if there's a different active delivery + if (_hasActiveDelivery() && !activeOrderIds.contains(currentOrderId)) { + // Ignore resume taps when some other delivery is active. + return; + } + + // For now, resume behaves same as start (navigates to nav screen) + // You can add specific resume logic here if needed (e.g. un-skip) + await startDelivery(item); + } + + Future _fetchPicked() async { + if (_fetching) return; + + _fetching = true; + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0; + + // ✅ CRITICAL: Fetch from v3 API (picked orders) AND v2 API (all statuses) IN PARALLEL + + // Define V2 fetcher function + Future> fetchV2() async { + try { + final now = DateTime.now(); + final today = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final bool isLive = ApiConstants.mainRoute == 'live'; + final baseUrl = isLive + ? 'https://jupiter.nearle.app/live/api/v2/deliveries/getdeliveries' + : 'https://jupiter.nearle.app/dev/api/v2/deliveries/getdeliveries'; + + final uri = Uri.parse(baseUrl).replace( + queryParameters: { + 'userid': userId.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + + final httpClient = http.Client(); + try { + final response = await httpClient.get(uri); + if (response.statusCode >= 200 && response.statusCode < 300) { + final decoded = json.decode(response.body); + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + return data is List + ? data + : (data is Map && data['items'] is List + ? data['items'] as List + : []); + } + } finally { + httpClient.close(); + } + } catch (e) { + debugPrint('[MYDELIVERIES] Error fetching from v2 API: $e'); + } + return []; + } + + // Execute in parallel + final results = await Future.wait([ + _provider.getDeliveryQueuesPicked(live: true, userid: userId), + fetchV2(), + ]); + + final itemsV3 = results[0] as List; + final itemsV2 = results[1] as List; + + // Merge v3 orders (picked) with v2 orders (all statuses) + final Map> mergedOrders = {}; + + // First add v3 orders (picked) + for (final order in itemsV3.whereType>()) { + final key = _getOrderKey(order); + mergedOrders[key] = order; + } + + // Then add/update with v2 orders - V2 STATUS TAKES PRECEDENCE + for (final order in itemsV2.whereType>()) { + final key = _getOrderKey(order); + final existing = mergedOrders[key]; + + if (existing == null) { + mergedOrders[key] = order; + } else { + // If it exists in V3 (picked) but V2 says something else, trust V2. + // Especially for 'skipped', 'delivered', 'cancelled'. + mergedOrders[key] = { + ...existing, + ...order, // Overwrite with V2 data + }; + } + } + + final items = mergedOrders.values.toList(); + debugPrint('[MYDELIVERIES] Merged items count: ${items.length}'); + + final dedupedList = items; // Already deduped by key map + + // Preserve step numbers for existing orders + for (final order in dedupedList) { + final orderKey = _getOrderKey(order); + final currentStep = _getStepNumber(order); + if (currentStep > 0 && !_preservedStepNumbers.containsKey(orderKey)) { + _preservedStepNumbers[orderKey] = currentStep; + } + } + + // Sort the list + final sortedList = _sortOrders(dedupedList); + + // Filter out ACTIVE orders from top list (they go to banner) + final listForTop = sortedList.where((o) { + final status = (o['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + return status != 'active'; + }).toList(); + + final skippedInFinal = listForTop.where((m) { + final status = (m['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + return status == 'skipped'; + }).length; + + debugPrint( + '[MYDELIVERIES] Final top list: ${listForTop.length} (skipped: $skippedInFinal)', + ); + + // Check if data has actually changed before rebuilding + // This avoids unnecessary setState calls during polling + final bool hasChanged = !_areOrdersEqual(_picked, listForTop); + + if (hasChanged) { + if (mounted) { + setState(() { + _picked = listForTop; + }); + debugPrint( + '[MYDELIVERIES] 🔄 UI Updated: ${listForTop.length} orders ($skippedInFinal skipped)', + ); + } + } else { + // No changes + } + + // Get all order IDs from the current list + final currentOrderIds = sortedList + .map((o) => (o['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // ✅ Find all ACTIVE deliveries and ensure timers are running + final activeOrders = sortedList.where((o) { + final status = (o['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + return status == 'active'; + }).toList(); + + debugPrint( + '[MYDELIVERIES] Found ${activeOrders.length} active deliveries', + ); + + // ✅ CRITICAL: Only set has_live_deliveries to true if there are ACTIVE orders (status = "active") + // Don't set it to true for "picked" or other statuses - only for "active" + try { + final hasActiveOrders = activeOrders.isNotEmpty; + await prefs.setBool('has_live_deliveries', hasActiveOrders); + debugPrint( + '[MYDELIVERIES] Set has_live_deliveries: $hasActiveOrders (${activeOrders.length} active orders)', + ); + + // Clear active_delivery_order_id if no active deliveries + if (!hasActiveOrders) { + final activeOrderId = prefs.getString('active_delivery_order_id'); + if (activeOrderId != null && activeOrderId.isNotEmpty) { + debugPrint( + '[MYDELIVERIES] Clearing stale active_delivery_order_id: $activeOrderId (no active deliveries)', + ); + await prefs.remove('active_delivery_order_id'); + } + } + } catch (_) {} + + // Update active deliveries list for banner display + if (mounted) { + setState(() { + _activeDeliveries = activeOrders; + }); + } + + // Get set of active order IDs + final activeOrderIds = activeOrders + .map((o) => (o['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // ✅ CRITICAL: Start timers ONLY for active deliveries that don't have one yet + // This ensures logs are posted every 30 seconds WITHOUT restarting timers on every fetch + for (final order in activeOrders) { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) continue; + + // ✅ ONLY start timer if it doesn't already exist + // This prevents restarting timers on every _fetchPicked() call (which happens frequently) + if (!_deliveryTimers.containsKey(orderId)) { + debugPrint( + '[MYDELIVERIES] Active delivery: $orderId - Starting delivery log posting (timer not found)', + ); + // ✅ RE-ENABLED: Delivery logs now posted from deliveries page with robust validation + await _startDeliveryPosting(order); + } else { + debugPrint( + '[MYDELIVERIES] Active delivery: $orderId - Timer already running, skipping restart', + ); + } + + _activeDeliveryOrderId = orderId; // Track the active delivery + + // Persist active delivery ID so it survives app restarts + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('active_delivery_order_id', orderId); + } catch (e) { + debugPrint('[MYDELIVERIES] Error saving active delivery ID: $e'); + } + } + + // ✅ Stop timers ONLY for deliveries that are no longer active or in the list + final timersToStop = _deliveryTimers.keys + .where( + (id) => + !activeOrderIds.contains(id) || !currentOrderIds.contains(id), + ) + .toList(); + + for (final id in timersToStop) { + debugPrint( + '[MYDELIVERIES] Stopping timer for orderId: $id (no longer active)', + ); + _stopDeliveryPosting(id); + } + + debugPrint( + '[MYDELIVERIES] Active timers: ${_deliveryTimers.keys.toList()}', + ); + debugPrint( + '[MYDELIVERIES] Active delivery orderId: $_activeDeliveryOrderId', + ); + } catch (e) { + debugPrint('[MYDELIVERIES] Error fetching picked orders: $e'); + } finally { + _fetching = false; + } + } + + // ignore: unused_element + Map _createBasePayload(Map it) { + return { + 'logid': 0, + 'tenantid': it['tenantid'] ?? 0, + 'partnerid': it['partnerid'] ?? 0, + 'locationid': it['locationid'] ?? 0, + 'orderheaderid': it['orderheaderid'] ?? 0, + 'deliveryid': it['deliveryid'] ?? 0, + 'userid': it['userid'] ?? 0, + 'orderid': (it['orderid'] ?? '').toString(), + 'orderstatus': 'active', + }; + } + + // Stop posting logs for a specific delivery + void _stopDeliveryPosting(String orderId) { + final timer = _deliveryTimers.remove(orderId); + if (timer != null) { + timer.cancel(); + debugPrint('[DELIVERYLOG] Stopped posting logs for orderId: $orderId'); + } else { + debugPrint('[DELIVERYLOG] No timer to stop for orderId: $orderId'); + } + } + + Future<(String lat, String lng)?> _getValidCoordinates({ + int retryCount = 0, + }) async { + const maxRetries = 3; + + try { + final bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint( + '[DELIVERIES][COORDS] Location service disabled, trying last known position', + ); + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && + lastPos.latitude != 0 && + lastPos.longitude != 0) { + final lat = lastPos.latitude.toString(); + final lng = lastPos.longitude.toString(); + debugPrint( + '[DELIVERIES][COORDS] ✅ Using last known position: $lat, $lng', + ); + return (lat, lng); + } + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.deniedForever || + permission == LocationPermission.denied) { + debugPrint( + '[DELIVERIES][COORDS] Permission denied, trying last known position', + ); + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && + lastPos.latitude != 0 && + lastPos.longitude != 0) { + final lat = lastPos.latitude.toString(); + final lng = lastPos.longitude.toString(); + debugPrint( + '[DELIVERIES][COORDS] ✅ Using last known position: $lat, $lng', + ); + return (lat, lng); + } + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + + Position? position; + try { + // Try to get current position with higher accuracy + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: + LocationAccuracy.high, // Changed to high for better accuracy + timeLimit: Duration(seconds: 8), // Increased timeout + ), + ).timeout(const Duration(seconds: 8)); + } catch (e) { + debugPrint( + '[DELIVERIES][COORDS] Timeout getting current position: $e, trying last known', + ); + position = await Geolocator.getLastKnownPosition(); + } + + if (position != null && + position.latitude != 0 && + position.longitude != 0) { + final lat = position.latitude.toString(); + final lng = position.longitude.toString(); + + // Validate coordinates are within valid GPS ranges + final latDouble = double.tryParse(lat) ?? 0.0; + final lngDouble = double.tryParse(lng) ?? 0.0; + if (latDouble.abs() <= 90 && lngDouble.abs() <= 180) { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lat); + await prefs.setString('last_lng', lng); + } catch (_) {} + debugPrint( + '[DELIVERIES][COORDS] ✅ Got valid coordinates: $lat, $lng', + ); + return (lat, lng); + } else { + debugPrint( + '[DELIVERIES][COORDS] ⚠️ Invalid coordinate ranges: $lat, $lng', + ); + } + } + + // Fallback to SharedPreferences cached coordinates + try { + final prefs = await SharedPreferences.getInstance(); + final lat = (prefs.getString('last_lat') ?? '').trim(); + final lng = (prefs.getString('last_lng') ?? '').trim(); + if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') { + final latDouble = double.tryParse(lat) ?? 0.0; + final lngDouble = double.tryParse(lng) ?? 0.0; + if (latDouble != 0 && + lngDouble != 0 && + latDouble.abs() <= 90 && + lngDouble.abs() <= 180) { + debugPrint( + '[DELIVERIES][COORDS] ✅ Using cached coordinates: $lat, $lng', + ); + return (lat, lng); + } + } + } catch (_) {} + + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + debugPrint( + '[DELIVERIES][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates', + ); + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + + debugPrint( + '[DELIVERIES][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries', + ); + return null; + } catch (e) { + debugPrint('[DELIVERIES][COORDS] ❌ Error getting coordinates: $e'); + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + return null; + } + } + + Future _startDeliveryPosting(Map order) async { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⚠️ Cannot start timer: empty orderId', + ); + return; + } + + // Safety check: If timer already exists, cancel it first (shouldn't happen after cleanup above) + if (_deliveryTimers.containsKey(orderId)) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⚠️ Timer already exists for $orderId, canceling old one', + ); + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers.remove(orderId); + } + + debugPrint( + '[DELIVERIES][DELIVERYLOG] 🚀 Starting 30-second timer for orderId: $orderId', + ); + + debugPrint( + '[DELIVERIES][DELIVERYLOG] 🚀 Starting 30-second timer for orderId: $orderId', + ); + + // REMOVED: Do not reset cumulative distance here. + // It is already reset in DeliveriesController.updateActiveStatus when the status actually changes. + // Resetting here causes data loss if the app is restarted while a delivery is in progress. + + // START FOREGROUND SERVICE PROTECTION + // Ensure the RiderLogController knows we are active so the Foreground Service stays alive + // This protects THIS timer from being killed by the OS + try { + if (Get.isRegistered()) { + final riderLog = Get.find(); + debugPrint( + '[DELIVERIES][DELIVERYLOG] 🛡️ Activating Foreground Service via RiderLogController...', + ); + // Force 30s interval to match delivery logging + riderLog.startAutoCreateLoginLoop(seconds: 30); + } + } catch (e) { + debugPrint('[DELIVERIES][DELIVERYLOG] ⚠️ Could not start Foreground Service: $e'); + } + + final deliveryId = (order['deliveryid'] ?? 0).toString(); + + // Get starttime from SharedPreferences (saved when order became active via updateActiveStatus) + // If not found, use activetime from order data, or current time as fallback + String startTime = ''; + try { + final prefs = await SharedPreferences.getInstance(); + + // Method 1: Get from SharedPreferences (saved when order became active) + startTime = prefs.getString('delivery_starttime_$deliveryId') ?? ''; + if (startTime.isNotEmpty) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ✅ Loaded starttime from SharedPreferences: $startTime', + ); + } + + // Method 2: Fallback - try to get from order data (starttime field) + if (startTime.isEmpty) { + startTime = (order['starttime'] ?? order['startTime'] ?? '').toString(); + if (startTime.isNotEmpty) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ✅ Loaded starttime from order data: $startTime', + ); + } + } + + // Method 3: Fallback - try activetime from order data + if (startTime.isEmpty) { + final activetime = (order['activetime'] ?? order['activTime'] ?? '') + .toString(); + if (activetime.isNotEmpty) { + startTime = activetime; + debugPrint( + '[DELIVERIES][DELIVERYLOG] ✅ Loaded starttime from activetime: $startTime', + ); + } + } + + // Method 4: Last fallback - current time (shouldn't happen if updateActiveStatus was called) + if (startTime.isEmpty) { + final now = DateTime.now(); + startTime = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⚠️ Using current time as starttime fallback: $startTime', + ); + } + } catch (e) { + debugPrint('[DELIVERIES][DELIVERYLOG] ❌ Error getting starttime: $e'); + // Set a fallback starttime even on error + final now = DateTime.now(); + startTime = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + } + + // CRITICAL: Ensure starttime is never empty + if (startTime.isEmpty) { + final now = DateTime.now(); + startTime = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⚠️ Final fallback: starttime was empty, using: $startTime', + ); + } + + debugPrint( + '[DELIVERIES][DELIVERYLOG] 📝 Final starttime for orderId $orderId: $startTime', + ); + + // Create base payload with starttime + final base = { + 'logid': 0, + 'tenantid': order['tenantid'] ?? 0, + 'partnerid': order['partnerid'] ?? 0, + 'locationid': order['locationid'] ?? 0, + 'orderheaderid': order['orderheaderid'] ?? 0, + 'deliveryid': order['deliveryid'] ?? 0, + 'userid': order['userid'] ?? 0, + 'orderid': orderId, + 'orderstatus': 'active', + 'starttime': startTime, // Include starttime in base payload + }; + + _deliveryBasePayload[orderId] = base; + + // Save to SharedPreferences for persistence + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'deliverylog_${orderId}_tenantid', + (base['tenantid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_partnerid', + (base['partnerid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_locationid', + (base['locationid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_orderheaderid', + (base['orderheaderid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_deliveryid', + (base['deliveryid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_userid', + (base['userid'] ?? 0).toString(), + ); + await prefs.setString('deliverylog_${orderId}_orderid', orderId); + await prefs.setString('deliverylog_${orderId}_orderstatus', 'active'); + await prefs.setString( + 'deliverylog_${orderId}_starttime', + startTime, + ); // Save starttime + } catch (e) { + debugPrint('[DELIVERIES][DELIVERYLOG] Error saving payload: $e'); + } + + // Post once immediately (don't await - let it run in background) + _postDeliveryLog(orderId, order); + debugPrint( + '[DELIVERIES][DELIVERYLOG] 📤 Posted initial log for orderId: $orderId', + ); + + // Then every 30 seconds - CRITICAL: This ensures logs are posted every 30 seconds + final timer = Timer.periodic(const Duration(seconds: 30), (t) { + debugPrint( + '[DELIVERIES][DELIVERYLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)', + ); + _postDeliveryLog(orderId, order); + }); + + _deliveryTimers[orderId] = timer; + debugPrint( + '[DELIVERIES][DELIVERYLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)', + ); + } + + // Reset cumulative distance when order becomes active + Future _resetCumulativeDistance(String deliveryId) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('delivery_tracking_${deliveryId}_lastLat'); + await prefs.remove('delivery_tracking_${deliveryId}_lastLng'); + await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm'); + debugPrint( + '[DELIVERIES] 🧹 Reset cumulative distance tracking for deliveryId: $deliveryId', + ); + } catch (e) { + debugPrint('[DELIVERIES] Error resetting cumulative distance: $e'); + } + } + + void _postDeliveryLog(String orderId, Map order) { + if (!mounted) { + debugPrint('[DELIVERIES][DELIVERYLOG][POST] Widget disposed, skipping'); + return; + } + + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⏰ Posting log for orderId: $orderId at ${DateTime.now()}', + ); + + // Use Future.microtask to ensure the async operation runs independently + Future.microtask(() => _performPost(orderId)); + } + + Future _performPost(String orderId) async { + try { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] 🔄 Starting _performPost for orderId: $orderId', + ); + + Map? base = _deliveryBasePayload[orderId]; + + if (base == null) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] Base is null, loading from SharedPreferences', + ); + try { + final prefs = await SharedPreferences.getInstance(); + if (!mounted) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] Widget unmounted after prefs load', + ); + return; + } + + base = { + 'logid': 0, + 'tenantid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_tenantid') ?? '0', + ) ?? + 0, + 'partnerid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_partnerid') ?? '0', + ) ?? + 0, + 'locationid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_locationid') ?? '0', + ) ?? + 0, + 'orderheaderid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_orderheaderid') ?? + '0', + ) ?? + 0, + 'deliveryid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_deliveryid') ?? '0', + ) ?? + 0, + 'userid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_userid') ?? '0', + ) ?? + 0, + 'orderid': + prefs.getString('deliverylog_${orderId}_orderid') ?? orderId, + 'orderstatus': + prefs.getString('deliverylog_${orderId}_orderstatus') ?? + 'active', + 'starttime': + prefs.getString('deliverylog_${orderId}_starttime') ?? + '', // Load starttime + }; + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] Base loaded from prefs: $base', + ); + } catch (e) { + debugPrint('[DELIVERIES][DELIVERYLOG][POST] Error loading base: $e'); + return; + } + } + + // At this point, base is guaranteed to be non-null (either from cache or created above) + final basePayload = base; // Flow analysis ensures base is non-null here + + debugPrint('[DELIVERIES][DELIVERYLOG][POST] Getting coordinates...'); + + // CRITICAL: Get coordinates with retry logic - NEVER post with null or '0' coordinates + final coords = await _getValidCoordinates().timeout( + const Duration(seconds: 10), // Increased timeout to allow retries + onTimeout: () { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ❌ Coordinate timeout after retries', + ); + return null; + }, + ); + + if (!mounted) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] Widget unmounted after coords', + ); + return; + } + + // CRITICAL: Validate coordinates - NEVER post with null, '0', or invalid coordinates + if (coords == null || + coords.$1.isEmpty || + coords.$2.isEmpty || + coords.$1 == '0' || + coords.$2 == '0') { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})', + ); + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)', + ); + return; // Skip this post - don't send invalid coordinates + } + + // Validate coordinate ranges + final latDouble = double.tryParse(coords.$1) ?? 0.0; + final lngDouble = double.tryParse(coords.$2) ?? 0.0; + if (latDouble == 0 || + lngDouble == 0 || + latDouble.abs() > 90 || + lngDouble.abs() > 180) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)', + ); + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)', + ); + return; // Skip this post - don't send invalid coordinates + } + + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ✅ Valid coordinates: lat=${coords.$1}, lng=${coords.$2}', + ); + + // Cumulative KM is tracked exclusively by LiveTrackingService (high-frequency, every 3s). + // Do not accumulate here to avoid race conditions with concurrent writers. + + final now = DateTime.now(); + final logdate = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + + // CRITICAL: Ensure starttime is always included in payload + final starttimeValue = basePayload['starttime']?.toString() ?? ''; + if (starttimeValue.isEmpty) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⚠️ WARNING: starttime is empty in basePayload, using fallback', + ); + } + + // CRITICAL: Use validated coordinates - guaranteed to be non-null and valid at this point + final payload = { + ...basePayload, + 'logdate': logdate, + 'latitude': coords.$1, // Guaranteed non-null and valid + 'longitude': coords.$2, // Guaranteed non-null and valid + 'starttime': starttimeValue.isNotEmpty + ? starttimeValue + : '', // CRITICAL: Always include starttime + }; + + // Validate payload has all required fields + final requiredFields = [ + 'tenantid', + 'partnerid', + 'locationid', + 'orderheaderid', + 'deliveryid', + 'userid', + 'orderid', + 'orderstatus', + 'starttime', + ]; + final missingFields = requiredFields + .where((field) => payload[field] == null || payload[field] == '') + .toList(); + if (missingFields.isNotEmpty) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields', + ); + } + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + debugPrint('[DELIVERIES][DELIVERYLOG][POST] 📤 Sending to API: $url'); + debugPrint('[DELIVERIES][DELIVERYLOG][POST] 📦 Payload: $payload'); + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ✅ starttime in payload: "${payload['starttime']}"', + ); + + await _deliveryLogProvider + .createDeliveryLog(url, payload) + .timeout( + const Duration(seconds: 8), + onTimeout: () { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ⚠️ API timeout for orderId: $orderId', + ); + throw TimeoutException('API timeout', const Duration(seconds: 8)); + }, + ); + + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}', + ); + } catch (e, stackTrace) { + debugPrint( + '[DELIVERIES][DELIVERYLOG][POST] ❌ ERROR for orderId: $orderId - $e', + ); + debugPrint('[DELIVERIES][DELIVERYLOG][POST] Stack trace: $stackTrace'); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + + if (_picked.isNotEmpty) { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Pending deliveries'), + content: const Text( + 'Are you sure you want to close? There are deliveries pending.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('No'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Yes'), + ), + ], + ), + ); + if (confirm == true && context.mounted) { + Navigator.pop(context); + } + } else { + Navigator.pop(context); + } + }, + child: SafeArea( + child: Scaffold( + backgroundColor: Colors.grey.shade200, + appBar: AppBar( + backgroundColor: Colors.grey.shade200, + elevation: 0, + centerTitle: false, + toolbarHeight: 70, // Same as your summary page + titleSpacing: 0, + title: Padding( + padding: const EdgeInsets.only(left: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Transform.translate( + offset: Offset(0, 6), + child: Text( + "DELIVERIES", + style: TextStyle( + fontSize: FontConstants.xxxLarge(context).sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + ), + + /// Map view row on right side + Transform.translate( + offset: Offset(-20, 2), + child: MapViewRow( + deliveries: _picked, + preservedStepNumbers: _preservedStepNumbers, + ), + ), + ], + ), + ), + + /// Divider under AppBar (same as summary page) + bottom: PreferredSize( + preferredSize: Size.fromHeight(1), + child: Divider(color: Colors.grey, height: 1), + ), + ), + body: Stack( + children: [ + _picked.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 80), + Transform.translate( + offset: Offset(0, -1.5), + child: Image.asset( + "assets/images/Nearle Bike.png", + errorBuilder: (c, e, s) => const Icon( + Icons.delivery_dining, + size: 120, + color: Colors.grey, + ), + ), + ), + const SizedBox(height: 16), + Transform.translate( + offset: Offset(0, -1.5), + child: Text( + "No Deliveries at the moment", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: FontConstants.xxxLarge(context), + fontFamily: FontConstants.fontFamily, + color: Colors.grey.shade500, + ), + ), + ), + ], + ), + ) + : RefreshIndicator( + onRefresh: _fetchPicked, + child: ListView.builder( + padding: EdgeInsets.only( + left: 2, + right: 2, + top: 8, + bottom: _activeDeliveries.isNotEmpty + ? 80 + : 8, // Add bottom padding for banner + ), + itemCount: _picked.length, + itemBuilder: (context, index) { + final item = _picked[index]; + final int displayStep = _getDisplayStepNumber( + _picked, + index, + ); + final String distanceStr = _distanceKmDisplay(item); + final String orderId = (item['orderid'] ?? '') + .toString(); + final String status = + (item['orderstatus']?.toString().toLowerCase() ?? + '') + .trim(); + + // Logic for enabling cards: + // 1. Skipped orders are ALWAYS enabled (so they can be un-skipped or completed) + // 2. The first non-skipped, non-cancelled, non-delivered order is enabled + // 3. All other orders are disabled + + final bool isSkipped = status == 'skipped'; + final bool isCancelledOrDelivered = + status == 'cancelled' || status == 'delivered'; + final bool hasActive = _hasActiveDelivery(); + + // Don't show cancelled or delivered orders + if (isCancelledOrDelivered) { + return const SizedBox.shrink(); + } + + // Skipped orders: only enable when there is NO other active delivery + if (isSkipped && !isCancelledOrDelivered) { + return DeliveryCard( + key: ValueKey('delivery_$orderId'), + item: item, + displayStep: displayStep, + distanceStr: distanceStr, + enabled: !hasActive, + isSkipped: true, + ); + } + + // Find the first non-skipped order to enable + int firstEnabledIndex = -1; + for (int i = 0; i < _picked.length; i++) { + final orderStatus = + (_picked[i]['orderstatus'] + ?.toString() + .toLowerCase() ?? + '') + .trim(); + if (orderStatus != 'skipped' && + orderStatus != 'cancelled' && + orderStatus != 'delivered') { + firstEnabledIndex = i; + break; + } + } + + // Enable if this is the first available non-skipped order + // AND there is no active delivery running. + final bool enabled = + !hasActive && + firstEnabledIndex >= 0 && + index == firstEnabledIndex; + + return DeliveryCard( + key: ValueKey('delivery_$orderId'), + item: item, + displayStep: displayStep, + distanceStr: distanceStr, + enabled: enabled, + isSkipped: false, + ); + }, + ), + ), + // Bottom banner for active deliveries (Swiggy/Zomato style) + if (_activeDeliveries.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _ActiveDeliveryBanner( + activeDeliveries: _activeDeliveries, + onTap: (delivery) async { + await startDelivery(delivery); + // Refresh after returning from map screen + if (mounted) { + await Future.delayed(const Duration(milliseconds: 500)); + _fetchPicked(); + } + }, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ------------------------------------------------------------------------- +// ACTIVE DELIVERY BANNER (Swiggy/Zomato style) +// ------------------------------------------------------------------------- +class _ActiveDeliveryBanner extends StatelessWidget { + final List> activeDeliveries; + final Function(Map) onTap; + + const _ActiveDeliveryBanner({ + required this.activeDeliveries, + required this.onTap, + }); + + String _getDeliveryAddress(Map delivery) { + final address = + delivery['deliveryaddress'] ?? + delivery['DeliveryAddress'] ?? + delivery['address'] ?? + ''; + if (address.toString().length > 40) { + return '${address.toString().substring(0, 40)}...'; + } + return address.toString(); + } + + @override + Widget build(BuildContext context) { + if (activeDeliveries.isEmpty) { + return const SizedBox.shrink(); + } + + // Show first active delivery (or show count if multiple) + final delivery = activeDeliveries.first; + final count = activeDeliveries.length; + + return Container( + margin: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onTap(delivery), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + // Active indicator icon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.two_wheeler, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + // Delivery info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + count > 1 + ? '$count Active Deliveries' + : 'Active Delivery', + style: TextStyle( + color: Colors.white, + fontSize: 19, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + if (count > 1) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$count', + style: TextStyle( + color: Colors.white, + fontSize: FontConstants.small(context).sp, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + _getDeliveryAddress(delivery), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 17, + fontFamily: FontConstants.fontFamily, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Arrow icon + const Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: 20, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/deliveries/done.dart b/lib/views/Dashboard/deliveries/done.dart new file mode 100644 index 0000000..a9f4584 --- /dev/null +++ b/lib/views/Dashboard/deliveries/done.dart @@ -0,0 +1,278 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// DELIVERIES DONE SCREEN +// ------------------------------------------------------------------------- +class DeliveriesDone extends StatefulWidget { + final bool isCancelled; + final int bonusPoints; + + const DeliveriesDone({ + super.key, + this.isCancelled = false, + this.bonusPoints = 0, + }); + + @override + State createState() => _DeliveriesDoneState(); +} + +class _DeliveriesDoneState extends State { + late ConfettiController _confettiController; + final GlobalKey _scratcherKey = GlobalKey(); + double _opacity = 0.0; + bool _isScratched = false; // Track scratch state + + @override + void initState() { + super.initState(); + _confettiController = ConfettiController( + duration: const Duration(seconds: 3), + ); + } + + @override + void dispose() { + _confettiController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Check if we should show the scratch card + final bool showScratchCard = !widget.isCancelled && widget.bonusPoints > 0; + + return Scaffold( + backgroundColor: Colors.white, + body: Stack( + children: [ + // Main Content + SafeArea( + child: showScratchCard + ? _buildScratchCardContent() + : _buildStandardContent(), + ), + + // Confetti Layer (on top) + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + shouldLoop: false, + colors: const [ + Colors.green, + Colors.blue, + Colors.pink, + Colors.orange, + Colors.purple, + ], + createParticlePath: drawStar, + ), + ), + ], + ), + ); + } + + Widget _buildStandardContent() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + Lottie.asset( + widget.isCancelled + ? 'assets/lotties/Error Occurred!.json' + : 'assets/lotties/result page succes.json', + height: 220, + repeat: false, + errorBuilder: + (c, e, s) => Icon( + widget.isCancelled ? Icons.cancel : Icons.check_circle, + size: 120, + color: widget.isCancelled ? Colors.red : Colors.green, + ), + ), + const SizedBox(height: 20), + Text( + widget.isCancelled ? 'Delivery Cancelled' : 'Delivery Completed!', + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + Text( + widget.isCancelled + ? 'This order was cancelled.' + : 'Great job! Your delivery was successful.', + style: const TextStyle(fontSize: 16, color: Colors.grey), + textAlign: TextAlign.center, + ), + const Spacer(), + _buildDoneButton(isEnabled: true), + ], + ); + } + + Widget _buildScratchCardContent() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + Text( + 'You won a Scratch Card!', + style: TextStyle( + fontSize: 28, // Increased + fontWeight: FontWeight.bold, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 12), + Text( + 'Scratch to reveal your bonus points', + style: TextStyle( + fontSize: 18, // Increased + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 30), + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Scratcher( + key: _scratcherKey, + brushSize: 50, + threshold: 50, + color: ColorConstants.primaryColor, + onChange: (value) { + // Optional: haptic feedback or sound while scratching + }, + onThreshold: () { + _confettiController.play(); + setState(() { + _opacity = 1.0; + _isScratched = true; // Enable button + }); + }, + // Custom cover content instead of just solid color + image: Image.asset( + 'assets/images/nearlelauncher.png', + fit: BoxFit.scaleDown, + width: 100, // Constrain width so it fits nicely + height: 100, + ), + child: Container( + width: 250, + height: 250, + color: Colors.white, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.monetization_on, + size: 80, + color: Colors.amber, + ), + const SizedBox(height: 16), + Text( + '${widget.bonusPoints}', + style: TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + Text( + 'Points', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + ), + ), + const Spacer(), + _buildDoneButton(isEnabled: _isScratched), + ], + ); + } + + Widget _buildDoneButton({required bool isEnabled}) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: isEnabled ? ColorConstants.primaryColor : Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: isEnabled ? () { + Get.offAll(() => const BottomPage(initialIndex: 1)); + } : null, + child: const Text( + 'Done', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } + + Path drawStar(Size size) { + // Method to draw star shape for confetti + double degToRad(double deg) => deg * (math.pi / 180.0); + + const numberOfPoints = 5; + final halfWidth = size.width / 2; + final externalRadius = halfWidth; + final internalRadius = halfWidth / 2.5; + final degreesPerStep = degToRad(360 / numberOfPoints); + final halfDegreesPerStep = degreesPerStep / 2; + final path = Path(); + final fullAngle = degToRad(360); + path.moveTo(size.width, halfWidth); + + for (double step = 0; step < fullAngle; step += degreesPerStep) { + path.lineTo( + halfWidth + externalRadius * math.cos(step), + halfWidth + externalRadius * math.sin(step), + ); + path.lineTo( + halfWidth + internalRadius * math.cos(step + halfDegreesPerStep), + halfWidth + internalRadius * math.sin(step + halfDegreesPerStep), + ); + } + path.close(); + return path; + } +} diff --git a/lib/views/Dashboard/deliveries/map.dart b/lib/views/Dashboard/deliveries/map.dart new file mode 100644 index 0000000..9672097 --- /dev/null +++ b/lib/views/Dashboard/deliveries/map.dart @@ -0,0 +1,831 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// SCREEN 1: DELIVERY MAP PREVIEW (Shows route, has "Start" button) +// ------------------------------------------------------------------------- +class _DeliveryMapScreen extends StatefulWidget { + final Map delivery; + final _MyDeliveriesState? parentState; + const _DeliveryMapScreen({ + required this.delivery, + this.parentState, + }); + + @override + State<_DeliveryMapScreen> createState() => _DeliveryMapScreenState(); +} + +class _DeliveryMapScreenState extends State<_DeliveryMapScreen> { + GoogleMapController? _mapController; + late final LatLng _pickupLocation; + late final LatLng _dropLocation; + final Set _markers = {}; + final Set _polylines = {}; + late final PolylinePoints _polylinePoints; + bool _isLoadingRoute = true; + bool _isNavigating = false; // Prevent multiple clicks + + static const String _googleApiKey = 'AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q'; + + @override + void initState() { + super.initState(); + _polylinePoints = PolylinePoints(apiKey: _googleApiKey); + _resolveLocationsFromDelivery(); + _setMarkers(); + _isLoadingRoute = false; + _createRealRoute(); + } + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + void _resolveLocationsFromDelivery() { + final d = widget.delivery; + final double pickLat = _parseD(d['pickuplat'] ?? d['PickupLat']); + final double pickLon = _parseD(d['pickuplon'] ?? d['PickupLon']); + final double dropLat = _parseD( + d['droplat'] ?? d['DropLat'] ?? d['deliverylat'], + ); + final double dropLon = _parseD( + d['droplon'] ?? d['DropLon'] ?? d['deliverylong'], + ); + final double riderLat = _parseD(d['riderslat']); + final double riderLon = _parseD(d['riderslon']); + + final bool hasPickup = pickLat != 0 && pickLon != 0; + final bool hasDrop = dropLat != 0 && dropLon != 0; + + final LatLng pickup = hasPickup + ? LatLng(pickLat, pickLon) + : (riderLat != 0 && riderLon != 0 + ? LatLng(riderLat, riderLon) + : const LatLng(10.998356, 76.977596)); + + final LatLng drop = hasDrop + ? LatLng(dropLat, dropLon) + : const LatLng(11.004556, 76.967696); + + _pickupLocation = pickup; + _dropLocation = drop; + } + + void _setMarkers() { + _markers.addAll([ + Marker( + markerId: const MarkerId('pickup'), + position: _pickupLocation, + infoWindow: const InfoWindow(title: 'Pickup Location'), + icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed), + ), + Marker( + markerId: const MarkerId('drop'), + position: _dropLocation, + infoWindow: const InfoWindow(title: 'Drop Location'), + icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), + ), + ]); + } + + Future _createRealRoute() async { + try { + final request = PolylineRequest( + origin: PointLatLng( + _pickupLocation.latitude, + _pickupLocation.longitude, + ), + destination: PointLatLng( + _dropLocation.latitude, + _dropLocation.longitude, + ), + mode: TravelMode.driving, + ); + + final result = await _polylinePoints.getRouteBetweenCoordinates( + request: request, + ); + + if (result.points.isNotEmpty) { + final routePoints = result.points + .map((e) => LatLng(e.latitude, e.longitude)) + .toList(); + + if (mounted) { + setState(() { + _polylines.add( + Polyline( + polylineId: const PolylineId('real_route'), + color: ColorConstants.primaryColor, + width: 6, + points: routePoints, + ), + ); + _isLoadingRoute = false; + }); + + _fitMapToRoute(); + } + } else { + debugPrint('[MAP_PREVIEW] No route found'); + if (mounted) { + setState(() => _isLoadingRoute = false); + } + } + } catch (e) { + debugPrint('[MAP_PREVIEW] Error creating route: $e'); + if (mounted) { + setState(() => _isLoadingRoute = false); + } + } + } +Future _fitMapToRoute() async { + if (!mounted) return; + if (_mapController == null) return; + + // Check if controller is still alive (important!) + try { + await _mapController!.getVisibleRegion(); + } catch (e) { + debugPrint("❌ Map controller is dead. Skip animateCamera."); + return; + } + + final bounds = LatLngBounds( + southwest: LatLng( + math.min(_pickupLocation.latitude, _dropLocation.latitude), + math.min(_pickupLocation.longitude, _dropLocation.longitude), + ), + northeast: LatLng( + math.max(_pickupLocation.latitude, _dropLocation.latitude), + math.max(_pickupLocation.longitude, _dropLocation.longitude), + ), + ); + + // Try animate safely + for (int i = 0; i < 10; i++) { + if (!mounted) return; + + try { + await _mapController!.animateCamera( + CameraUpdate.newLatLngBounds(bounds, 80), + ); + return; + } catch (e) { + await Future.delayed(const Duration(milliseconds: 150)); + } + } + + debugPrint("❌ animateCamera failed after retries (map probably disposed)"); +} + + + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + SizedBox.expand( + child: GoogleMap( + initialCameraPosition: CameraPosition( + target: _pickupLocation, + zoom: 14.5, + ), + onMapCreated: (controller) { + _mapController = controller; + + Future.delayed(const Duration(milliseconds: 500), () { + if (!_isLoadingRoute && _polylines.isNotEmpty) { + _fitMapToRoute(); + } + }); + }, + markers: _markers, + polylines: _polylines, + zoomControlsEnabled: false, + myLocationButtonEnabled: false, + ), +), + Positioned( + top: 50, + left: 16, + child: CircleAvatar( + backgroundColor: Colors.white, + child: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => Navigator.pop(context), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 8, + offset: Offset(0, -2), + ), + ], + ), + child: SafeArea( + top: false, + child: LayoutBuilder( + builder: (ctx, constraints) { + final double maxSheetHeight = + MediaQuery.of(ctx).size.height * 0.35; + // ignore: unused_local_variable + final double allowedHeight = math.min( + constraints.maxHeight, + maxSheetHeight, + ); + return ListView( + padding: EdgeInsets.zero, + shrinkWrap: true, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Customer Details', + style: TextStyle( + fontSize: FontConstants.xxLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + InkWell( + onTap: () async { + // Just launch dialer; PiP is handled only from navigation screen + final phone = + (widget.delivery['deliverycontactno'] ?? '') + .toString(); + final bool success = await launchPhoneDialer( + phone.isNotEmpty ? phone : '9876543210', + ); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not launch dialer'), + ), + ); + } + }, + child: Image.asset( + 'assets/images/phone-call .png', + height: 27, + width: 27, + errorBuilder: (c, e, s) => const Icon( + Icons.phone, + size: 27, + color: Colors.green, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + const Divider(thickness: 1), + const SizedBox(height: 8), + _buildCustomerInfo(), + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: _isNavigating ? null : () async { + // Prevent multiple clicks + if (_isNavigating || !mounted || !context.mounted) return; + + setState(() { + _isNavigating = true; + }); + + try { + // Capture screen size before navigation + final screenSize = MediaQuery.of(context).size * + MediaQuery.of(context).devicePixelRatio; + + final dc = Get.put( + DeliveriesController(), + permanent: true, + ); + final d = widget.delivery; + final int deliveryId = + int.tryParse( + '${d['deliveryid'] ?? d['DeliveryId'] ?? 0}', + ) ?? + 0; + final String orderId = (d['orderid'] ?? + d['OrderId'] ?? + '') + .toString(); + final int orderHeaderId = + int.tryParse( + '${d['orderheaderid'] ?? d['OrderHeaderId'] ?? 0}', + ) ?? + 0; + // Save ridertime start at the moment navigation is started + try { + if (deliveryId > 0) { + final prefs = + await SharedPreferences.getInstance(); + // 1) Save rider time start (existing behaviour) + await prefs.setString( + 'ridertime_start_$deliveryId', + DateTime.now().toIso8601String(), + ); + // 2) Save ETA end time for this order so PiP timer can resume correctly + final rawEta = d['eta']; + int etaMinutes = 0; + if (rawEta != null) { + etaMinutes = + int.tryParse(rawEta.toString()) ?? 0; + } + if (etaMinutes > 0) { + final now = DateTime.now(); + final endTime = now + .add(Duration(minutes: etaMinutes)) + .millisecondsSinceEpoch ~/ + 1000; // store seconds + await prefs.setInt( + 'eta_endtime_$orderId', + endTime, + ); + debugPrint( + '[ACTIVE][ETA] Saved eta_endtime_$orderId -> $endTime (eta=$etaMinutes min)', + ); + } + debugPrint( + '[ACTIVE] Saved ridertime_start for deliveryId=$deliveryId', + ); + } + } catch (e) { + debugPrint( + '[ACTIVE] Error saving ridertime_start: $e', + ); + } + + final parentState = + widget.parentState ?? + context + .findAncestorStateOfType< + _MyDeliveriesState + >(); + + // ✅ CRITICAL: Check active delivery BEFORE navigation + if (parentState != null) { + final currentOrderId = (d['orderid'] ?? + d['OrderId'] ?? + '') + .toString(); + final activeOrderIds = parentState + ._activeDeliveries + .map( + (del) => (del['orderid'] ?? + del['OrderId'] ?? + '') + .toString(), + ) + .where((id) => id.isNotEmpty) + .toSet(); + + // Block if there's a different active delivery + if (parentState._activeDeliveries.isNotEmpty && + !activeOrderIds.contains(currentOrderId)) { + if (mounted) { + setState(() { + _isNavigating = false; + }); + showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext dialogContext) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.orange.shade400, + Colors.red.shade500, + ], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Icon + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.warning_rounded, + color: Colors.white, + size: 48, + ), + ), + const SizedBox(height: 20), + // Title + Text( + 'Active Delivery in Progress', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: FontConstants.xxLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 12), + // Message + Text( + 'Please complete your active delivery first before starting another delivery.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withOpacity(0.95), + fontSize: FontConstants.medium(context), + height: 1.4, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 24), + // Action Button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + // Navigate to active delivery + if (parentState._activeDeliveries.isNotEmpty) { + parentState.startDelivery(parentState._activeDeliveries.first); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.red.shade600, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.two_wheeler, + size: 22, + ), + const SizedBox(width: 8), + Text( + 'View Active Delivery', + style: TextStyle( + fontSize: FontConstants.regular(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + // Close Button + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + child: Text( + 'Close', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 15, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + return; // Return early to prevent starting another delivery + } + } + + if (deliveryId > 0 && orderId.isNotEmpty) { + String riderLatStr = '0'; + String riderLngStr = '0'; + try { + final position = + await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.medium, + timeLimit: Duration(seconds: 3), + ), + ).timeout(const Duration(seconds: 3)); + riderLatStr = position.latitude + .toStringAsFixed(6); + riderLngStr = position.longitude + .toStringAsFixed(6); + } catch (e) { + debugPrint( + '[ACTIVE] Error getting rider location: $e', + ); + try { + final lastPos = + await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + riderLatStr = lastPos.latitude + .toStringAsFixed(6); + riderLngStr = lastPos.longitude + .toStringAsFixed(6); + } + } catch (_) {} + } + + // ✅ CRITICAL: Navigate FIRST, then handle status updates + if (!mounted || !context.mounted) { + setState(() { + _isNavigating = false; + }); + return; + } + + // Navigate immediately - this must happen + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => _RiderNavigationScreen( + delivery: widget.delivery, + parentState: widget.parentState, + ), + ), + ); + + // Reset navigation state after navigation completes + setState(() { + _isNavigating = false; + }); + + // Continue with status updates and PiP in background + debugPrint( + '[ACTIVE] Updating status for deliveryId=$deliveryId orderId=$orderId lat=$riderLatStr lng=$riderLngStr', + ); + + final ok = await dc.updateActiveStatus( + deliveryId: deliveryId, + orderHeaderId: orderHeaderId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + orderId: orderId, + ); + + debugPrint( + '[ACTIVE] Status update result: $ok', + ); + + // ✅ CRITICAL: ENFORCE PiP when delivery becomes active (compulsory) + if (ok && !dc.isPipEnabled.value) { + try { + debugPrint('[ACTIVE] Delivery is now active - Enforcing PiP mode'); + final floating = Floating(); + const rational = Rational.landscape(); + + final height = (screenSize.height * 0.5).toInt(); + final width = (screenSize.width * 0.9).toInt(); + + final arguments = ImmediatePiP( + aspectRatio: rational, + sourceRectHint: math.Rectangle( + ((screenSize.width - width) ~/ 2).toInt(), + ((screenSize.height - height) ~/ 2).toInt(), + width, + height, + ), + ); + + await floating.enable(arguments); + dc.isPipEnabled.value = true; + debugPrint('[ACTIVE] PiP enabled successfully'); + + // Also try method channel as backup + try { + const channel = MethodChannel('nearle/pip'); + await channel.invokeMethod('enterPip'); + } catch (_) {} + } catch (e) { + debugPrint('[ACTIVE] Error enabling PiP: $e'); + } + } + + if (parentState != null) { + final previousActive = + parentState._activeDeliveryOrderId; + if (previousActive != null && + previousActive != orderId) { + parentState._stopDeliveryPosting( + previousActive, + ); + } + + parentState._activeDeliveryOrderId = orderId; + d['orderstatus'] = 'active'; + + final orderKey = parentState._getOrderKey(d); + if (parentState._skippedOrdersCache + .containsKey(orderKey)) { + parentState._skippedOrdersCache.remove( + orderKey, + ); + parentState._skippedOrderTimestamps.remove( + orderKey, + ); + await parentState._saveSkippedOrdersCache(); + debugPrint( + '[ACTIVE] Removed from skipped cache (resumed): $orderKey', + ); + } + + await parentState._startDeliveryPosting(d); + + try { + final prefs = + await SharedPreferences.getInstance(); + await prefs.setString( + 'active_delivery_order_id', + orderId, + ); + } catch (e) { + debugPrint( + '[ACTIVE] Error saving active delivery ID: $e', + ); + } + + // ignore: invalid_use_of_protected_member + parentState.setState(() {}); + } + } else { + debugPrint( + '[ACTIVE] Invalid deliveryId: $deliveryId or orderId: $orderId', + ); + setState(() { + _isNavigating = false; + }); + } + } catch (e) { + debugPrint( + '[ACTIVE] Error in navigation flow: $e', + ); + if (mounted) { + setState(() { + _isNavigating = false; + }); + } + } + }, + child: _isNavigating + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + const SizedBox(width: 12), + Text( + 'Starting...', + style: TextStyle( + fontSize: FontConstants.xLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.white, + ), + ), + ], + ) + : Text( + 'Start Navigation', + style: TextStyle( + fontSize: FontConstants.xLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.white, + ), + ), + ), + ], + ); + }, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCustomerInfo() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + 'Name:', + (widget.delivery['deliverycustomer'] ?? 'Customer').toString(), + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Address:', + (widget.delivery['deliveryaddress'] ?? 'Address not available') + .toString(), + isExpanded: true, + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Order ID:', + '#${(widget.delivery['orderid'] ?? '').toString()}', + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Distance:', + '${(widget.delivery['kms'] ?? '0').toString()} km', + valueColor: Colors.red, + ), + ], + ); + } + + Widget _buildInfoRow( + String label, + String value, { + bool isExpanded = false, + Color? valueColor, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: FontConstants.xLarge(context), + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(width: 15), + isExpanded + ? Expanded( + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: FontConstants.large(context), + fontFamily: FontConstants.fontFamily, + color: valueColor ?? Colors.black87, + ), + ), + ) + : Text( + value, + style: TextStyle( + fontSize: FontConstants.large(context), + fontFamily: FontConstants.fontFamily, + color: valueColor ?? Colors.black87, + ), + ), + ], + ); + } +} diff --git a/lib/views/Dashboard/deliveries/map_btn.dart b/lib/views/Dashboard/deliveries/map_btn.dart new file mode 100644 index 0000000..99a26a5 --- /dev/null +++ b/lib/views/Dashboard/deliveries/map_btn.dart @@ -0,0 +1,41 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// MAP VIEW BUTTON (Customer locations) +// ------------------------------------------------------------------------- +class MapViewRow extends StatelessWidget { + final List> deliveries; + final Map? preservedStepNumbers; + + const MapViewRow({ + super.key, + required this.deliveries, + this.preservedStepNumbers, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MultiCustomerMapScreen( + deliveries: deliveries, + preservedStepNumbers: preservedStepNumbers ?? {}, + ), + ), + ); + }, + child: Image.asset( + 'assets/images/customermap.png', + color: ColorConstants.primaryColor, + height: 32, + width: 32, + errorBuilder: (c, e, s) => + const Icon(Icons.map, size: 32, color: Colors.blue), + ), + ); + } +} + diff --git a/lib/views/Dashboard/deliveries/multi_map.dart b/lib/views/Dashboard/deliveries/multi_map.dart new file mode 100644 index 0000000..456471d --- /dev/null +++ b/lib/views/Dashboard/deliveries/multi_map.dart @@ -0,0 +1,894 @@ +part of 'deliveries.dart'; + +class _StepPoint { + final int step; + final LatLng position; + _StepPoint(this.step, this.position); +} + +// ------------------------------------------------------------------------- +// MULTI CUSTOMER MAP (Shows all deliveries with real step numbers) +// ------------------------------------------------------------------------- +class MultiCustomerMapScreen extends StatefulWidget { + final List> deliveries; + final Map preservedStepNumbers; + + const MultiCustomerMapScreen({ + super.key, + required this.deliveries, + this.preservedStepNumbers = const {}, + }); + + @override + State createState() => _MultiCustomerMapScreenState(); +} + +class _MultiCustomerMapScreenState extends State { + GoogleMapController? mapController; + Set markers = {}; + Set polylines = {}; + LatLng? currentLocation; + bool _isLoading = true; + bool _mapReady = false; + final PolylinePoints _polylinePoints = PolylinePoints( + apiKey: 'AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q', + ); + final Map> _deliveryMap = {}; + + @override + void initState() { + super.initState(); + if (widget.deliveries.isNotEmpty) { + final firstDelivery = widget.deliveries.first; + final dropLat = _parseD( + firstDelivery['droplat'] ?? firstDelivery['deliverylat'] ?? 0, + ); + final dropLon = _parseD( + firstDelivery['droplon'] ?? firstDelivery['deliverylong'] ?? 0, + ); + if (dropLat != 0 && dropLon != 0) { + currentLocation = LatLng(dropLat, dropLon); + } else { + currentLocation = const LatLng(11.018356, 77.012596); + } + } else { + currentLocation = const LatLng(11.018356, 77.012596); + } + _loadMapData(); + } + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + int _getStepNumber(Map order) { + final dynamic raw = order['step'] ?? order['Step']; + final int step = raw == null + ? 0 + : (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0); + return step; + } + + String _getOrderKey(Map order) { + final deliveryId = (order['deliveryid'] ?? '').toString(); + final orderId = (order['orderid'] ?? '').toString(); + return deliveryId.isNotEmpty ? 'delivery_$deliveryId' : 'order_$orderId'; + } + + int _getPreservedOrCurrentStep(Map order) { + final orderKey = _getOrderKey(order); + if (widget.preservedStepNumbers.containsKey(orderKey)) { + return widget.preservedStepNumbers[orderKey]!; + } + final currentStep = _getStepNumber(order); + return currentStep; + } + + Future _loadMapData() async { + try { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + + _getCurrentLocation().then((_) { + if (mounted) { + _createDeliveryMarkers(); + } + }); + } catch (e) { + debugPrint('[CUSTOMER_MAP] Error loading map data: $e'); + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _getCurrentLocation() async { + try { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + currentLocation = LatLng(lastPos.latitude, lastPos.longitude); + }); + } + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + currentLocation = LatLng(lastPos.latitude, lastPos.longitude); + }); + } + return; + } + } + + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.medium, + distanceFilter: 0, + timeLimit: Duration(seconds: 5), + ), + ); + } catch (e) { + debugPrint( + '[CUSTOMER_MAP] Timeout getting location, using last known: $e', + ); + position = await Geolocator.getLastKnownPosition(); + } + + final safePosition = position; + if (safePosition != null && mounted) { + setState(() { + currentLocation = LatLng( + safePosition.latitude, + safePosition.longitude, + ); + }); + } + } catch (e) { + debugPrint('[CUSTOMER_MAP] Error getting location: $e'); + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + currentLocation = LatLng(lastPos.latitude, lastPos.longitude); + }); + } + } catch (_) {} + } + } + + int? _getNextDeliveryStep() { + // Find the first non-skipped delivery (next delivery from user location) + for (int i = 0; i < widget.deliveries.length; i++) { + final delivery = widget.deliveries[i]; + final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + if (status != 'skipped') { + final stepNumber = _getPreservedOrCurrentStep(delivery); + if (stepNumber > 0) { + return stepNumber; + } else { + // Calculate display step for orders without step + final ordersWithStepBefore = widget.deliveries + .sublist(0, i) + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + final totalOrdersWithStep = widget.deliveries + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + return totalOrdersWithStep + (i - ordersWithStepBefore) + 1; + } + } + } + return null; + } + + Future _createDeliveryMarkers() async { + Set tempMarkers = {}; + List<_StepPoint> stepPoints = []; + _deliveryMap.clear(); + + final nextStep = _getNextDeliveryStep(); + + for (int i = 0; i < widget.deliveries.length; i++) { + final delivery = widget.deliveries[i]; + final stepNumber = _getPreservedOrCurrentStep(delivery); + + int displayStep; + if (stepNumber > 0) { + displayStep = stepNumber; + } else { + final ordersWithStepBefore = widget.deliveries + .sublist(0, i) + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + final totalOrdersWithStep = widget.deliveries + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + displayStep = totalOrdersWithStep + (i - ordersWithStepBefore) + 1; + } + + final double lat = _parseD( + delivery['droplat'] ?? delivery['deliverylat'], + ); + final double lon = _parseD( + delivery['droplon'] ?? delivery['deliverylong'], + ); + if (lat == 0 || lon == 0) continue; + + final customerName = (delivery['deliverycustomer'] ?? 'Customer ${i + 1}') + .toString(); + final orderId = (delivery['orderid'] ?? '').toString(); + final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isSkipped = status == 'skipped'; + final isNext = displayStep == nextStep; + + // Store delivery data for dialog + _deliveryMap['delivery_$orderId'] = delivery; + + final icon = await _createCircularMarkerBitmap( + displayStep, + isSkipped: isSkipped, + isNext: isNext, + ); + + tempMarkers.add( + Marker( + markerId: MarkerId('delivery_$orderId'), + position: LatLng(lat, lon), + icon: icon, + infoWindow: InfoWindow( + title: isSkipped + ? 'Step $displayStep: $customerName (SKIPPED)' + : 'Step $displayStep: $customerName', + snippet: 'Order #$orderId', + ), + onTap: () { + _showCustomerDetailsSheet(delivery, displayStep); + }, + ), + ); + + stepPoints.add(_StepPoint(displayStep, LatLng(lat, lon))); + } + + if (currentLocation != null) { + tempMarkers.add( + Marker( + markerId: const MarkerId('current_location'), + position: currentLocation!, + icon: BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ), + infoWindow: const InfoWindow(title: 'You are here'), + ), + ); + stepPoints.insert(0, _StepPoint(0, currentLocation!)); + } + + stepPoints.sort((a, b) => a.step.compareTo(b.step)); + + Set newPolylines = {}; + int segmentIndex = 0; + + for (int i = 0; i < stepPoints.length - 1; i++) { + final start = stepPoints[i].position; + final end = stepPoints[i + 1].position; + + try { + // ignore: deprecated_member_use + final request = PolylineRequest( + origin: PointLatLng(start.latitude, start.longitude), + destination: PointLatLng(end.latitude, end.longitude), + mode: TravelMode.driving, + ); + + final result = await _polylinePoints.getRouteBetweenCoordinates( + request: request, + ); + + if (result.points.isNotEmpty) { + final routePoints = result.points + .map((p) => LatLng(p.latitude, p.longitude)) + .toList(); + + newPolylines.add( + Polyline( + polylineId: PolylineId('segment_$segmentIndex'), + points: routePoints, + width: 6, + color: Colors.blue, + startCap: Cap.roundCap, + endCap: Cap.roundCap, + jointType: JointType.round, + geodesic: true, + ), + ); + segmentIndex++; + } else { + newPolylines.add( + Polyline( + polylineId: PolylineId('segment_fallback_$segmentIndex'), + points: [start, end], + width: 4, + color: Colors.blue.shade200, + ), + ); + segmentIndex++; + } + } catch (e) { + debugPrint('[CUSTOMER_MAP] Directions error for segment $i: $e'); + newPolylines.add( + Polyline( + polylineId: PolylineId('segment_error_$segmentIndex'), + points: [start, end], + width: 4, + color: Colors.blue.shade200, + ), + ); + segmentIndex++; + } + } + + if (mounted) { + setState(() { + markers = tempMarkers; + polylines = newPolylines; + }); + } + + await Future.delayed(const Duration(milliseconds: 200)); + _fitBoundsToMarkersAndPolylines(); + } + + Future _fitBoundsToMarkersAndPolylines() async { + if (mapController == null) return; + + double minLat = double.infinity; + double maxLat = -double.infinity; + double minLng = double.infinity; + double maxLng = -double.infinity; + + bool hasPoint = false; + + for (final m in markers) { + final pos = m.position; + minLat = math.min(minLat, pos.latitude); + maxLat = math.max(maxLat, pos.latitude); + minLng = math.min(minLng, pos.longitude); + maxLng = math.max(maxLng, pos.longitude); + hasPoint = true; + } + + for (final poly in polylines) { + for (final pos in poly.points) { + minLat = math.min(minLat, pos.latitude); + maxLat = math.max(maxLat, pos.latitude); + minLng = math.min(minLng, pos.longitude); + maxLng = math.max(maxLng, pos.longitude); + hasPoint = true; + } + } + + if (!hasPoint) return; + + final bounds = LatLngBounds( + southwest: LatLng(minLat, minLng), + northeast: LatLng(maxLat, maxLng), + ); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + await mapController!.animateCamera( + CameraUpdate.newLatLngBounds(bounds, 80), + ); + } catch (e) { + Future.delayed(const Duration(milliseconds: 300), () async { + try { + await mapController!.animateCamera( + CameraUpdate.newLatLngBounds(bounds, 80), + ); + } catch (e) { + debugPrint('[CUSTOMER_MAP] Retry failed: $e'); + } + }); + } + }); + } + + Future _createCircularMarkerBitmap( + int number, { + bool isSkipped = false, + bool isNext = false, + }) async { + const double size = 70; + final pictureRecorder = ui.PictureRecorder(); + final canvas = Canvas(pictureRecorder); + final center = Offset(size / 2, size / 2); + + final paint = Paint() + ..color = isSkipped ? Colors.orange : ColorConstants.primaryColor; + canvas.drawCircle(center, 15, paint); + + final border = Paint() + ..color = isSkipped ? Colors.orange.shade900 : Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = isSkipped ? 4 : 3; + canvas.drawCircle(center, 15, border); + + final textPainter = TextPainter( + text: TextSpan( + text: number.toString(), + style: const TextStyle( + fontSize: 19, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset( + center.dx - textPainter.width / 2, + center.dy - textPainter.height / 2, + ), + ); + + // Draw "NEXT" indicator (road sign with down arrow) on top + if (isNext) { + // Draw road sign background (rectangle) + final signPaint = Paint() + ..color = Colors.green + ..style = PaintingStyle.fill; + final signRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(size / 2, 8), width: 45, height: 30), + const Radius.circular(4), + ); + canvas.drawRRect(signRect, signPaint); + + // Draw border + final signBorder = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawRRect(signRect, signBorder); + + // Draw "NEXT" text + final nextTextPainter = TextPainter( + text: const TextSpan( + text: 'NEXT', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + ); + nextTextPainter.layout(); + nextTextPainter.paint( + canvas, + Offset(size / 2 - nextTextPainter.width / 2, 4), + ); + + // Draw down arrow + final arrowPath = Path(); + arrowPath.moveTo(size / 2, 18); + arrowPath.lineTo(size / 2 - 4, 24); + arrowPath.lineTo(size / 2 + 4, 24); + arrowPath.close(); + final arrowPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + canvas.drawPath(arrowPath, arrowPaint); + } + + final img = await pictureRecorder.endRecording().toImage( + size.toInt(), + size.toInt(), + ); + final data = await img.toByteData(format: ui.ImageByteFormat.png); + return BitmapDescriptor.bytes(data!.buffer.asUint8List()); + } + + void _showCustomerDetailsSheet( + Map delivery, + int stepNumber, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + enableDrag: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext context) { + final customerName = (delivery['deliverycustomer'] ?? 'Customer') + .toString(); + final address = (delivery['deliveryaddress'] ?? 'Address not available') + .toString(); + final phone = (delivery['deliverycontactno'] ?? '').toString(); + final orderId = (delivery['orderid'] ?? '').toString(); + final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isSkipped = status == 'skipped'; + + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with title and close icon + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + 'Customer Details', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primaryColor, + ), + ), + ), + InkWell( + onTap: () => Navigator.of(context).pop(), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.grey.shade200, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.cancel, + size: 34, + color: Colors.red, + ), + ), + ), + ], + ), + + const Divider(thickness: 1.5), + + // Step number and status + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: isSkipped + ? Colors.orange + : ColorConstants.primaryColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Step $stepNumber', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + if (isSkipped) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'SKIPPED', + style: TextStyle( + color: Colors.orange, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 16), + + // Customer name + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.person, size: 20, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Customer Name', + style: TextStyle( + fontSize: 16, + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + customerName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Delivery address + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.location_on, + size: 20, + color: Colors.grey, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Delivery Address', + style: TextStyle( + fontSize: 16, + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + address, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Phone number + if (phone.isNotEmpty) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.phone, size: 20, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Contact Number', + style: TextStyle( + fontSize: 16, + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + phone, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Order ID + Text( + 'Order ID: $orderId', + style: const TextStyle( + fontSize: 16, + color: Colors.black, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 20), + + // Call button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () async { + // Close bottom sheet first + Navigator.of(context).pop(); + + // Small delay to ensure bottom sheet is closed + await Future.delayed( + const Duration(milliseconds: 300), + ); + + // Then make the call + final bool success = await launchPhoneDialer( + phone.isNotEmpty ? phone : '9876543210', + ); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not launch dialer'), + ), + ); + } + }, + icon: const Icon( + Icons.phone, + color: Colors.white, + size: 20, + ), + label: const Text( + 'Call', + style: TextStyle( + color: Colors.white, + fontSize: 21, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + LatLng initialTarget; + double initialZoom = 13; + + if (currentLocation != null) { + initialTarget = currentLocation!; + } else if (widget.deliveries.isNotEmpty) { + final firstDelivery = widget.deliveries.first; + final lat = _parseD( + firstDelivery['droplat'] ?? firstDelivery['deliverylat'], + ); + final lon = _parseD( + firstDelivery['droplon'] ?? firstDelivery['deliverylong'], + ); + initialTarget = (lat != 0 && lon != 0) + ? LatLng(lat, lon) + : const LatLng(11.0168, 76.9558); + } else { + initialTarget = const LatLng(11.0168, 76.9558); + } + + final initialCameraPosition = CameraPosition( + target: initialTarget, + zoom: initialZoom, + ); + + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(70), + child: SafeArea( + bottom: false, + child: AppBar( + automaticallyImplyLeading: false, + backgroundColor: ColorConstants.primaryColor, + elevation: 0, + toolbarHeight: 80, + leadingWidth: double.infinity, + leading: Row( + children: [ + IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + size: 26, + ), + onPressed: () => Navigator.pop(context), + ), + const Text( + 'Delivery Route', + style: TextStyle( + fontSize: 26, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + ], + ), + centerTitle: false, + ), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : GoogleMap( + initialCameraPosition: initialCameraPosition, + markers: markers, + polylines: polylines, + myLocationEnabled: true, + myLocationButtonEnabled: true, + onMapCreated: (controller) { + mapController = controller; + _mapReady = true; + + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 150), () { + if (_mapReady) _fitBoundsToMarkersAndPolylines(); + }); + }); + }, + ), + ); + } +} diff --git a/lib/views/Dashboard/deliveries/nav.dart b/lib/views/Dashboard/deliveries/nav.dart new file mode 100644 index 0000000..f998837 --- /dev/null +++ b/lib/views/Dashboard/deliveries/nav.dart @@ -0,0 +1,1004 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// SCREEN 2: RIDER NAVIGATION (Live tracking + auto-opens Google Maps once) +// ------------------------------------------------------------------------- +class _RiderNavigationScreen extends StatefulWidget { + final Map delivery; + final _MyDeliveriesState? parentState; + const _RiderNavigationScreen({ + // ignore: unused_element_parameter + super.key, + required this.delivery, + this.parentState, + }); + + @override + State<_RiderNavigationScreen> createState() => _RiderNavigationScreenState(); +} + +class _RiderNavigationScreenState extends State<_RiderNavigationScreen> + with WidgetsBindingObserver { + GoogleMapController? mapController; + bool _mapReady = false; + bool _isDisposed = false; + Position? _currentPosition; + StreamSubscription? _positionStream; + final Set _markers = {}; + late final LatLng dropLocation; + bool _isInitializing = true; + bool _hasOpenedGoogleMaps = false; + Timer? _autoOpenTimer; + Timer? _pipLoaderTimer; + bool _showPipExpandLoader = false; + bool _wasInPipBeforePause = false; + // ETA timer for full navigation screen (top-right corner) + final CountDownController _navEtaController = CountDownController(); + int _navEtaRemainingSeconds = 0; + final DeliveriesController deliveriesController = Get.put( + DeliveriesController(), + permanent: true, + ); + final Floating floating = Floating(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _resolveDropFromDelivery(); + _initializeMap(); + _initEtaForNav(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _attemptAutoOpenNavigation(); + }); + _autoOpenTimer = Timer( + const Duration(seconds: 3), + () => _attemptAutoOpenNavigation(fromTimer: true), + ); + } + + // Initialize remaining ETA for navigation screen using same persisted end time + Future _initEtaForNav() async { + try { + final prefs = await SharedPreferences.getInstance(); + final orderId = widget.delivery['orderid']?.toString() ?? + widget.delivery['OrderId']?.toString() ?? + ''; + if (orderId.isEmpty) return; + + final endKey = 'eta_endtime_$orderId'; + final endSeconds = prefs.getInt(endKey); + final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + int remaining = 0; + + // ✅ FIX: Checking (endSeconds > nowSeconds) caused the timer to RESET if user was late. + // We must trust existing endSeconds even if it's in the past. + if (endSeconds != null && endSeconds > 0) { + remaining = endSeconds - nowSeconds; + debugPrint('[NAV] Loaded saved ETA end time: $endSeconds (Remaining: $remaining)'); + } else { + // Only start a NEW timer if one doesn't exist + final rawEta = widget.delivery['eta']; + int etaMinutes = 0; + if (rawEta != null) { + etaMinutes = int.tryParse(rawEta.toString()) ?? 0; + } + if (etaMinutes > 0) { + remaining = etaMinutes * 60; + final newEndSeconds = nowSeconds + remaining; + await prefs.setInt(endKey, newEndSeconds); + debugPrint('[NAV] Saved NEW ETA end time: $newEndSeconds (Remaining: $remaining)'); + } + } + + if (!mounted) return; + setState(() { + _navEtaRemainingSeconds = remaining.clamp(0, 24 * 60 * 60); + }); + } catch (_) { + // Ignore errors – timer simply won't show + } + } + + @override + void dispose() { + _isDisposed = true; + WidgetsBinding.instance.removeObserver(this); + _autoOpenTimer?.cancel(); + _pipLoaderTimer?.cancel(); + _positionStream?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState lifecycleState) { + if (!mounted) return; + + if (lifecycleState == AppLifecycleState.paused || + lifecycleState == AppLifecycleState.inactive) { + final wasInPip = deliveriesController.isPipEnabled.value; + _wasInPipBeforePause = wasInPip; + if (wasInPip) { + _pipLoaderTimer?.cancel(); + if (mounted) { + setState(() { + _showPipExpandLoader = true; + }); + } + } + } else if (lifecycleState == AppLifecycleState.resumed) { + // When app comes back to foreground, consider PiP session ended. + deliveriesController.isPipEnabled.value = false; + + // ✅ FIX: Refresh the timer when coming back from PiP/Background + _initEtaForNav(); + + if (_wasInPipBeforePause) { + _wasInPipBeforePause = false; + _pipLoaderTimer?.cancel(); + _pipLoaderTimer = Timer(const Duration(milliseconds: 900), () { + if (!mounted) return; + setState(() { + _showPipExpandLoader = false; + }); + }); + } + } + } + + Future enablePip( + BuildContext context, { + bool autoEnable = false, + }) async { + if (!mounted) return; + + debugPrint('[NAV] Enabling PiP for navigation screen'); + + const rational = Rational.landscape(); + final screenSize = + MediaQuery.of(context).size * MediaQuery.of(context).devicePixelRatio; + final height = (screenSize.height * 0.5).toInt(); + final width = (screenSize.width * 0.9).toInt(); + + final arguments = autoEnable + ? OnLeavePiP( + aspectRatio: rational, + sourceRectHint: math.Rectangle( + 0, + (screenSize.height ~/ 1) - (height ~/ 1), + screenSize.width.toInt(), + height, + ), + ) + : ImmediatePiP( + aspectRatio: rational, + sourceRectHint: math.Rectangle( + ((screenSize.width - width) ~/ 2).toInt(), + ((screenSize.height - height) ~/ 2).toInt(), + width, + height, + ), + ); + + final status = await floating.enable(arguments); + deliveriesController.isPipEnabled.value = true; + debugPrint('PiP enabled? $status'); + } + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + void _resolveDropFromDelivery() { + final d = widget.delivery; + final double dropLat = _parseD( + d['droplat'] ?? d['DropLat'] ?? d['deliverylat'], + ); + final double dropLon = _parseD( + d['droplon'] ?? d['DropLon'] ?? d['deliverylong'], + ); + + dropLocation = (dropLat != 0 && dropLon != 0) + ? LatLng(dropLat, dropLon) + : const LatLng(11.018356, 77.012596); + } + + Future _initializeMap() async { + try { + if (mounted) { + setState(() { + Geolocator.getLastKnownPosition().then((lastPos) { + if (lastPos != null && mounted) { + setState(() { + _currentPosition = lastPos; + }); + _addMarkers(LatLng(lastPos.latitude, lastPos.longitude)); + _startLiveTracking(); + } + }); + + if (_currentPosition == null) { + _currentPosition = Position( + latitude: dropLocation.latitude, + longitude: dropLocation.longitude, + timestamp: DateTime.now(), + accuracy: 0, + altitude: 0, + heading: 0, + speed: 0, + speedAccuracy: 0, + altitudeAccuracy: 0, + headingAccuracy: 0, + ); + + _addMarkers(dropLocation); + } + + _isInitializing = false; + }); + } + + _getCurrentLocation().then((_) { + _startLiveTracking(); + }); + } catch (e) { + debugPrint('[NAVIGATION] Error initializing map: $e'); + if (mounted) { + setState(() { + if (_currentPosition == null) { + _currentPosition = Position( + latitude: dropLocation.latitude, + longitude: dropLocation.longitude, + timestamp: DateTime.now(), + accuracy: 0, + altitude: 0, + heading: 0, + speed: 0, + speedAccuracy: 0, + altitudeAccuracy: 0, + headingAccuracy: 0, + ); + + _addMarkers(dropLocation); + } + + _isInitializing = false; + }); + } + } + } + + Future _getCurrentLocation() async { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + _currentPosition = lastPos; + _isInitializing = false; + }); + } + Geolocator.openLocationSettings(); + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.deniedForever || + permission == LocationPermission.denied) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + _currentPosition = lastPos; + _isInitializing = false; + }); + _addMarkers(LatLng(lastPos.latitude, lastPos.longitude)); + } else if (mounted) { + setState(() => _isInitializing = false); + } + return; + } + + Position? pos; + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.medium, + timeLimit: const Duration(seconds: 5), + ).timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('[NAVIGATION] Timeout getting position, using last known: $e'); + pos = await Geolocator.getLastKnownPosition(); + } + + if (pos != null && mounted) { + setState(() { + _currentPosition = pos; + _isInitializing = false; + }); + _addMarkers(LatLng(pos.latitude, pos.longitude)); + } else if (mounted) { + setState(() => _isInitializing = false); + } + } + + void _startLiveTracking() { + _positionStream = + Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.bestForNavigation, + distanceFilter: 5, + ), + ).listen((Position position) { + if (mounted) { + setState(() { + _currentPosition = position; + }); + _addMarkers(LatLng(position.latitude, position.longitude)); + + final distance = Geolocator.distanceBetween( + position.latitude, + position.longitude, + dropLocation.latitude, + dropLocation.longitude, + ); + deliveriesController.deliverableDistance.value = distance; + + _safeAnimateCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: LatLng(position.latitude, position.longitude), + zoom: 16.5, + tilt: 45, + ), + ), + ); + } + }); + } + + void _addMarkers(LatLng riderPos) { + _markers + ..clear() + ..add( + Marker( + markerId: const MarkerId('rider'), + position: riderPos, + icon: BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ), + infoWindow: const InfoWindow(title: 'You (Rider)'), + ), + ) + ..add( + Marker( + markerId: const MarkerId('drop'), + position: dropLocation, + icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed), + infoWindow: const InfoWindow(title: 'Drop Location'), + ), + ); + } + + Future _attemptAutoOpenNavigation({bool fromTimer = false}) async { + if (!mounted || _hasOpenedGoogleMaps) return; + final bool opened = await _openGoogleMapsNavigation(); + if (!opened && fromTimer) { + debugPrint('[NAVIGATION] Auto open failed, waiting for manual trigger'); + } + } + + Future _openGoogleMapsNavigation() async { + if (!mounted) return false; + if (_hasOpenedGoogleMaps) return true; + try { + if (Platform.isAndroid) { + try { + // ✅ CRITICAL: ENFORCE PiP when starting navigation (compulsory for active deliveries) + final dc = Get.put(DeliveriesController(), permanent: true); + + // Enable PiP using floating package (primary) + if (!dc.isPipEnabled.value) { + await enablePip(context); + // Wait a moment for PiP to activate + await Future.delayed(const Duration(milliseconds: 300)); + + // Also tell native side (if available) – best-effort + const channel = MethodChannel('nearle/pip'); + try { + await channel.invokeMethod('enterPip'); + } catch (_) {} + + dc.isPipEnabled.value = true; + } + } on PlatformException catch (e) { + debugPrint('[NAV] Method channel PiP failed: ${e.message}'); + } catch (e) { + debugPrint('[NAV] Error enabling PiP: $e'); + } + } + Position? currentPos; + try { + currentPos = await Geolocator.getLastKnownPosition(); + } catch (_) {} + + if (currentPos == null) { + try { + currentPos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.medium, + timeLimit: const Duration(seconds: 2), + ); + } catch (_) {} + } + + final originLat = currentPos?.latitude ?? dropLocation.latitude; + final originLng = currentPos?.longitude ?? dropLocation.longitude; + final destLat = dropLocation.latitude; + final destLng = dropLocation.longitude; + + final Uri nativeUri = Uri.parse( + 'google.navigation:q=$destLat,$destLng&mode=d', + ); + final Uri webUri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1' + '&origin=$originLat,$originLng' + '&destination=$destLat,$destLng' + '&travelmode=driving' + '&dir_action=navigate', + ); + + // Try native intent first without relying on canLaunch (can be flaky) + bool launched = false; + + try { + launched = await launchUrl( + nativeUri, + mode: LaunchMode.externalApplication, + ); + } catch (e) { + debugPrint('[NAVIGATION] Native intent failed: $e'); + } + + if (!launched) { + try { + launched = await launchUrl( + webUri, + mode: LaunchMode.externalApplication, + ); + } catch (e) { + debugPrint('[NAVIGATION] Web intent failed: $e'); + } + } + + if (launched && mounted) { + setState(() { + _hasOpenedGoogleMaps = true; + }); + debugPrint('[NAVIGATION] Opened Google Maps'); + return true; + } + + debugPrint('[NAVIGATION] Could not open Google Maps'); + } catch (e) { + debugPrint('[NAVIGATION] Error opening Google Maps: $e'); + } + return false; + } + + @override + Widget build(BuildContext context) { + return Obx(() { + if (deliveriesController.isPipEnabled.value) { + final rawEta = widget.delivery['eta']; + int etaMinutes = 0; + if (rawEta != null) { + etaMinutes = int.tryParse(rawEta.toString()) ?? 0; + } + + return PipInfoCard( + orderId: widget.delivery['orderid']?.toString() ?? + widget.delivery['OrderId']?.toString() ?? + 'N/A', + etaMinutes: etaMinutes, + ); + } + + return Scaffold( + body: Stack( + children: [ + GoogleMap( + mapType: MapType.normal, + onMapCreated: (controller) { + mapController = controller; + _mapReady = true; + }, + initialCameraPosition: CameraPosition( + target: LatLng( + _currentPosition?.latitude ?? dropLocation.latitude, + _currentPosition?.longitude ?? dropLocation.longitude, + ), + zoom: 15, + tilt: 45, + ), + markers: _markers, + myLocationEnabled: true, + myLocationButtonEnabled: false, + trafficEnabled: true, + compassEnabled: true, + buildingsEnabled: true, + tiltGesturesEnabled: true, + ), + if (!_hasOpenedGoogleMaps) + Positioned( + top: 50, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + decoration: BoxDecoration( + color: ColorConstants.primaryColor, + borderRadius: BorderRadius.circular(25), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + const SizedBox(width: 12), + Text( + 'Opening Google Maps...', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + Positioned( + top: _hasOpenedGoogleMaps ? 50 : 120, + left: 16, + child: CircleAvatar( + backgroundColor: Colors.white, + child: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () { + _autoOpenTimer?.cancel(); + // Navigate back to BottomPage on Deliveries tab + Get.offAll(() => const BottomPage(initialIndex: 1)); + }, + ), + ), + ), + if (_hasOpenedGoogleMaps) + Positioned( + top: 50, + right: 16, + child: CircleAvatar( + backgroundColor: Colors.white, + child: IconButton( + icon: const Icon(Icons.navigation, color: Colors.blue), + onPressed: () async { + await _openGoogleMapsNavigation(); + }, + tooltip: 'Open Google Maps', + ), + ), + ), + // Small real-time ETA timer on top-right in full navigation (not in PiP) + if (!deliveriesController.isPipEnabled.value && + _navEtaRemainingSeconds > 0) + Positioned( + top: 110, + right: 16, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 32, + height: 32, + child: CircularCountDownTimer( + key: ValueKey('nav_timer_$_navEtaRemainingSeconds'), // Force rebuild if duration changes significantly + duration: _navEtaRemainingSeconds, + initialDuration: 0, + controller: _navEtaController, + width: 32, + height: 32, + ringColor: ColorConstants.primaryColor + .withValues(alpha: 0.15), + fillColor: ColorConstants.primaryColor, + backgroundColor: Colors.white, + strokeWidth: 3, + strokeCap: StrokeCap.round, + textStyle: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: ColorConstants.primaryColor, + fontFamily: FontConstants.fontFamily, + ), + isReverse: true, + isReverseAnimation: true, + isTimerTextShown: true, + autoStart: true, + timeFormatterFunction: + (defaultFormatter, duration) { + return duration.inSeconds <= 0 + ? 'Out' + : defaultFormatter(duration); + }, + ), + ), + const SizedBox(width: 6), + Text( + 'ETA', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + ], + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 8, + offset: Offset(0, -2), + ), + ], + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Customer Details', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + InkWell( + onTap: () async { + // Enable PiP mode first before launching dialer + if (!deliveriesController.isPipEnabled.value) { + await enablePip(context); + // Wait a moment for PiP to activate + await Future.delayed( + const Duration(milliseconds: 300), + ); + } + + final phone = + (widget.delivery['deliverycontactno'] ?? '') + .toString(); + final bool success = await launchPhoneDialer( + phone.isNotEmpty ? phone : '9876543210', + ); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not launch dialer'), + ), + ); + } + }, + child: Image.asset( + 'assets/images/phone-call .png', + height: 27, + width: 27, + errorBuilder: (c, e, s) => const Icon( + Icons.phone, + size: 27, + color: Colors.green, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + const Divider(thickness: 1.5), + const SizedBox(height: 10), + _buildCustomerInfo(), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () async { + // Optimize: Run PiP enable and Prefs saving in parallel + final d = widget.delivery; + final double? lat = double.tryParse("${d['droplat']}"); + final double? lng = double.tryParse("${d['droplon']}"); + + if (lat == null || lng == null) { + debugPrint("[RIDE] Invalid drop location"); + return; + } + + final prefsFuture = SharedPreferences.getInstance().then((prefs) async { + await prefs.setString("drop_lat", lat.toString()); + await prefs.setString("drop_lng", lng.toString()); + }); + + // PiP Logic + if (!deliveriesController.isPipEnabled.value) { + try { + debugPrint('[NAV] Enforcing PiP before Ride button'); + // Don't await extremely long, just enough to start transition + await enablePip(context); + await Future.delayed(const Duration(milliseconds: 150)); + } catch (e) { + debugPrint('[NAV] Error enabling PiP: $e'); + } + } + + // Construct URL (Map uses current location by default if origin not specified) + final String url = "https://www.google.com/maps/dir/?api=1&destination=$lat,$lng&travelmode=driving"; + final Uri uri = Uri.parse(url); + + // Fire and forget prefs + prefsFuture.ignore(); + + try { + // Direct launch is faster and avoids package visibility queries + await launchUrl(uri, mode: LaunchMode.externalApplication); + debugPrint("[RIDE] Opening Google Maps..."); + } catch (e) { + debugPrint("[RIDE] Could not launch Maps: $e"); + } + }, + child: Text( + 'Ride', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + + SizedBox(width: 12), // space between buttons + + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + minimumSize: const Size( + 0, + 48, + ), // FIX: no infinity width + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + builder: (context) => _DeliveryBottomSheet( + delivery: widget.delivery, + parentState: widget.parentState, + ), + ); + + if (result == true) { + // ✅ If delivery updated/skipped, close navigation screen immediately + if (mounted && Navigator.canPop(context)) { + Navigator.pop(context); + } + } + }, + child: Text( + 'Update', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + if (_showPipExpandLoader) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.45), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 56, + height: 56, + child: CircularProgressIndicator( + strokeWidth: 4, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Restoring full view...', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ], + ), + ); + }); + } + + Widget _buildCustomerInfo() { + final rawCollection = widget.delivery['collectionamt']; + double collectionAmt = 0.0; + if (rawCollection != null) { + if (rawCollection is num) { + collectionAmt = rawCollection.toDouble(); + } else { + collectionAmt = double.tryParse(rawCollection.toString()) ?? 0.0; + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + 'Name:', + (widget.delivery['deliverycustomer'] ?? 'Customer').toString(), + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Address:', + (widget.delivery['deliveryaddress'] ?? 'Address not available') + .toString(), + isExpanded: true, + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Order ID:', + '#${(widget.delivery['orderid'] ?? '').toString()}', + ), + if (collectionAmt > 0) ...[ + const SizedBox(height: 10), + _buildInfoRow( + 'To Collect:', + '₹${collectionAmt.toStringAsFixed(0)}', + ), + ], + ], + ); + } + + Widget _buildInfoRow(String label, String value, {bool isExpanded = false}) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(width: 15), + isExpanded + ? Expanded( + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + ) + : Text( + value, + style: TextStyle( + fontSize: 18, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + ], + ); + } + + void _safeAnimateCamera(CameraUpdate update) { + if (!_mapReady || _isDisposed || mapController == null) return; + try { + mapController?.animateCamera(update); + } catch (e) { + debugPrint('[NAVIGATION] animateCamera skipped: $e'); + } + } +} diff --git a/lib/views/Dashboard/deliveries/pip.dart b/lib/views/Dashboard/deliveries/pip.dart new file mode 100644 index 0000000..a9022f9 --- /dev/null +++ b/lib/views/Dashboard/deliveries/pip.dart @@ -0,0 +1,172 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// PIP INFO CARD (Shown when PiP mode is enabled) +// ------------------------------------------------------------------------- +class PipInfoCard extends StatefulWidget { + final String orderId; + final int etaMinutes; // ETA in minutes from API (always treat as minutes) + + const PipInfoCard({ + super.key, + required this.orderId, + required this.etaMinutes, + }); + + @override + State createState() => _PipInfoCardState(); +} + +class _PipInfoCardState extends State { + late final CountDownController _controller; + int _durationSeconds = 0; + + @override + void initState() { + super.initState(); + _controller = CountDownController(); + // Load remaining ETA from SharedPreferences so timer doesn't reset + _initDuration(); + } + + Future _initDuration() async { + try { + final prefs = await SharedPreferences.getInstance(); + final endKey = 'eta_endtime_${widget.orderId}'; + final endSeconds = prefs.getInt(endKey); + final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + int remaining = 0; + if (endSeconds != null && endSeconds > nowSeconds) { + remaining = endSeconds - nowSeconds; + } else { + // Fallback: use full ETA from API + final int safeEtaMinutes = + widget.etaMinutes < 0 ? 0 : widget.etaMinutes; + remaining = safeEtaMinutes * 60; + } + + if (!mounted) return; + setState(() { + _durationSeconds = remaining.clamp(0, 24 * 60 * 60); + }); + } catch (_) { + // In case of error, just fall back to raw ETA minutes + final int safeEtaMinutes = widget.etaMinutes < 0 ? 0 : widget.etaMinutes; + if (!mounted) return; + setState(() { + _durationSeconds = safeEtaMinutes * 60; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + // Expanded PiP: timer + key delivery details, but still relatively compact + body: Center( + child: widget.orderId.isEmpty || widget.orderId == 'N/A' + ? Text( + 'No Active Order', + style: TextStyle( + fontSize: 12, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primaryColor, + ), + ) + : Card( + color: Colors.white, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + width: 220, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Timer + SizedBox( + width: 72, + height: 72, + child: _durationSeconds <= 0 + ? Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorConstants.primaryColor, + width: 3, + ), + color: Colors.white, + ), + alignment: Alignment.center, + child: Text( + 'Out', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: ColorConstants.primaryColor, + fontFamily: FontConstants.fontFamily, + ), + ), + ) + : CircularCountDownTimer( + duration: _durationSeconds, + initialDuration: 0, + controller: _controller, + width: 72, + height: 72, + ringColor: ColorConstants.primaryColor + .withValues(alpha: 0.15), + fillColor: ColorConstants.primaryColor, + backgroundColor: Colors.white, + strokeWidth: 5, + strokeCap: StrokeCap.round, + textStyle: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: ColorConstants.primaryColor, + fontFamily: FontConstants.fontFamily, + ), + isReverse: true, + isReverseAnimation: true, + isTimerTextShown: true, + autoStart: true, + timeFormatterFunction: + (defaultFormatter, duration) { + return duration.inSeconds <= 0 + ? 'Out' + : defaultFormatter(duration); + }, + ), + ), + const SizedBox(height: 4), + // Active order id + Text( + 'Active Order ID: ${widget.orderId}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black87, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + diff --git a/lib/views/Dashboard/deliveries/sheet.dart b/lib/views/Dashboard/deliveries/sheet.dart new file mode 100644 index 0000000..0193e6d --- /dev/null +++ b/lib/views/Dashboard/deliveries/sheet.dart @@ -0,0 +1,1031 @@ +// ignore_for_file: invalid_return_type_for_catch_error + +part of 'deliveries.dart'; + +// ignore_for_file: unused_element_parameter +// Imports should be in deliveries.dart +// import 'dart:io'; +// import 'package:image_picker/image_picker.dart'; +// import 'package:shared_preferences/shared_preferences.dart'; + +// ------------------------------------------------------------------------- +// DELIVERY BOTTOM SHEET (Delivered/Cancel with payment options) +// ------------------------------------------------------------------------- +class _DeliveryBottomSheet extends StatefulWidget { + final Map delivery; + final _MyDeliveriesState? parentState; + + const _DeliveryBottomSheet({ + super.key, + required this.delivery, + this.parentState, + }); + + @override + State<_DeliveryBottomSheet> createState() => _DeliveryBottomSheetState(); +} + +class _DeliveryBottomSheetState extends State<_DeliveryBottomSheet> { + String selectedOption = ''; + bool isDeliveredSelected = true; + bool showOptions = false; + bool showSlider = true; + bool isLoading = false; + bool isProcessing = false; // Prevents any action until alert is dismissed + DateTime? lastFailureTime; // Track last failure to prevent duplicate alerts + + final List cancelOptions = [ + 'Customer refused', + 'Not Reachable', + 'Switched Off', + 'Incorrect Location', + ]; + + final List deliveredOptions = [ + 'Pay Later', + 'Cash Payment', + 'QR Payment', + ]; + + @override + void initState() { + super.initState(); + _resetSheetState(); + } + + void _resetSheetState() { + selectedOption = ''; + showOptions = false; + showSlider = true; + isDeliveredSelected = true; + isProcessing = false; + lastFailureTime = null; + } + + bool _hasCollection() { + final raw = widget.delivery['collectionamt']; + if (raw == null) return false; + if (raw is num) return raw > 0; + final parsed = double.tryParse(raw.toString()) ?? 0.0; + return parsed > 0; + } + + double _getCollectionAmount() { + final raw = widget.delivery['collectionamt']; + if (raw == null) return 0.0; + if (raw is num) return raw.toDouble(); + return double.tryParse(raw.toString()) ?? 0.0; + } + + double _getDeliveryAmount() { + final raw = widget.delivery['deliveryamt']; + if (raw == null) return 0.0; + if (raw is num) return raw.toDouble(); + return double.tryParse(raw.toString()) ?? 0.0; + } + + @override + Widget build(BuildContext context) { + // For delivered orders with collection amount, show custom payment choices + final options = isDeliveredSelected + ? (_hasCollection() + ? [ + 'Cash (₹${_getCollectionAmount().toStringAsFixed(0)})', + 'UPI (₹${_getCollectionAmount().toStringAsFixed(0)})', + 'Not Collected', + ] + : deliveredOptions) + : cancelOptions; + final Color confirmColor = isDeliveredSelected + ? ColorConstants.primaryColor + : Colors.red; + + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + top: false, + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Update Delivery Status', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + InkWell( + onTap: () { + if (!showSlider && showOptions) { + setState(() => _resetSheetState()); + } else { + Navigator.pop(context); + } + }, + child: const Icon( + Icons.cancel, + color: Colors.red, + size: 30, + ), + ), + ], + ), + const SizedBox(height: 10), + const Divider(), + if (!showOptions && showSlider) ...[ + _buildCustomerInfo(), + const SizedBox(height: 10), + ], + if (showSlider) + Slidable( + key: ValueKey(widget.delivery['deliveryid']), + startActionPane: ActionPane( + motion: const ScrollMotion(), + extentRatio: 0.9, // Allocate 90% of width for the 3 actions to prevent truncation + children: [ + SlidableAction( + borderRadius: BorderRadius.circular(6), + onPressed: (_) { + setState(() { + isDeliveredSelected = true; + selectedOption = ''; + showOptions = true; + showSlider = false; + isProcessing = false; // Reset when switching + lastFailureTime = null; // Clear failure cache + }); + }, + backgroundColor: Colors.green, + foregroundColor: Colors.white, + icon: Icons.check_circle, + label: 'Delivered', + ), + SlidableAction( + borderRadius: BorderRadius.circular(6), + onPressed: (_) { + setState(() { + isDeliveredSelected = false; + selectedOption = ''; + showOptions = true; + showSlider = false; + isProcessing = false; // Reset when switching + lastFailureTime = null; // Clear failure cache + }); + }, + backgroundColor: Colors.red, + foregroundColor: Colors.white, + icon: Icons.cancel, + label: 'Cancelled', + ), + SlidableAction( + borderRadius: BorderRadius.circular(6), + onPressed: (c) async { + // Resolve parent state reliably + final resolvedParentState = widget.parentState ?? + context + .findAncestorStateOfType<_MyDeliveriesState>(); + + // Open skip sheet on TOP of this sheet (safer context usage) + // We await it so we know when it closes + await _showMyOptionsSheet( + context, + widget.delivery, + resolvedParentState, + ); + + // Close this update sheet after skip sheet is done + // Pass 'true' to indicate that an action *might* have been taken + // We could potentially return result from _showMyOptionsSheet but currently it returns void + // The skip sheet handles the logic, so we can assume if we return here we are done. + // Actually, we should only return true if the skip sheet *actually* skipped. + // Since _showMyOptionsSheet returns Future, we can't know for sure. + // BUT, since the user interacted with the skip sheet and we are closing this sheet, + // passing 'true' is safer to trigger a refresh check upstream. + if (mounted) { + Navigator.pop(context, true); + } + }, + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + icon: Icons.skip_next, + label: 'Skip', + ), + ], + ), + child: Container( + height: 65, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.swipe, color: Colors.black), + SizedBox(width: 10), + Text( + 'Slide to Update Delivery', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 5), + if (showOptions) ...[ + ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + ...options.map((option) { + final bool isSelected = option == selectedOption; + return GestureDetector( + onTap: () { + if (isLoading || isProcessing) return; + setState(() { + selectedOption = option; + lastFailureTime = null; // Clear on new selection + }); + }, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + border: Border.all( + color: isSelected + ? confirmColor + : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + option, + style: TextStyle( + fontSize: 18, + color: isSelected + ? confirmColor + : Colors.black, + fontFamily: FontConstants.fontFamily, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + Icon( + isSelected + ? Icons.check_circle + : Icons.radio_button_unchecked, + color: isSelected ? confirmColor : Colors.grey, + ), + ], + ), + ), + ); + }), + ], + ), + ], + const SizedBox(height: 15), + // Slider enabled when a menu option is chosen. + if (selectedOption.isNotEmpty) + SliderButton( + properties: SliderButtonProperties( + height: 55, + buttonSize: 50, + width: MediaQuery.of(context).size.width - 40, + backgroundColor: confirmColor, + dismissThresholds: 0.90, + + /// ACTION + action: () async { + // Prevent action if already processing + if (isLoading || isProcessing) { + debugPrint( + '[SLIDER] ⚠️ Already processing, ignoring slide action', + ); + return false; + } + // _handleConfirm manages isLoading and isProcessing internally + await _handleConfirm(); + return false; // required by slider_button_lite + }, + + /// LABEL + label: Text( + isDeliveredSelected + ? "Slide to Confirm Delivery" + : "Slide to Confirm Cancellation", + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + + /// SLIDER BUTTON (Icon or Loader) + icon: ClipOval( + child: Material( + color: Colors.white, + child: SizedBox( + width: 50, + height: 50, + child: Center( + child: isLoading + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + confirmColor, + ), + ), + ) + : Icon( + isDeliveredSelected + ? Icons.check + : Icons.close, + size: 28, + color: confirmColor, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildCustomerInfo() { + final pickupCustomer = (widget.delivery['pickupcustomer'] ?? '') + .toString() + .trim(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + 'Name:', + widget.delivery['deliverycustomer']?.toString() ?? 'Customer', + ), + const SizedBox(height: 10), + if (pickupCustomer.isNotEmpty) ...[ + _buildInfoRow('Kitchen:', pickupCustomer), + const SizedBox(height: 10), + ], + _buildInfoRow( + 'Address:', + widget.delivery['deliveryaddress']?.toString() ?? + 'Address not available', + isExpanded: true, + ), + const SizedBox(height: 10), + _buildInfoRow( + 'Order ID:', + '#${widget.delivery['orderid']?.toString() ?? ''}', + ), + ], + ); + } + + Widget _buildInfoRow(String title, String value, {bool isExpanded = false}) { + final textWidget = Text( + value, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + fontFamily: FontConstants.fontFamily, + ), + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$title ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(width: 10), + isExpanded ? Expanded(child: textWidget) : Flexible(child: textWidget), + ], + ); + } + + bool _wasSkippedDelivery( + Map delivery, + _MyDeliveriesState? parentState, + ) { + final status = + (delivery['orderstatus'] ?? + delivery['status'] ?? + delivery['orderStatus'] ?? + '') + .toString() + .toLowerCase(); + if (status == 'skipped') return true; + if (parentState == null) return false; + final key = parentState._getOrderKey(delivery); + return parentState._skippedOrdersCache.containsKey(key); + } + + Future _handleConfirm() async { + // CRITICAL: Prevent any re-entry during processing or alert display + if (isLoading || isProcessing) { + debugPrint( + '[CONFIRM] ⚠️ Already processing or alert showing, ignoring tap', + ); + return; + } + + // Check if we recently had a failure (within last 4 seconds) + // This prevents cached/duplicate alerts from showing + if (lastFailureTime != null) { + final timeSinceLastFailure = DateTime.now().difference(lastFailureTime!); + if (timeSinceLastFailure.inSeconds < 4) { + debugPrint( + '[CONFIRM] ⚠️ Too soon after last failure (${timeSinceLastFailure.inSeconds}s), ignoring tap', + ); + return; + } + } + + final d = widget.delivery; + debugPrint('[CONFIRM] ===== DELIVERY INFO ====='); + debugPrint('[CONFIRM] deliveryid: ${d['deliveryid']}'); + debugPrint('[CONFIRM] orderid: ${d['orderid']}'); + debugPrint('[CONFIRM] orderstatus: ${d['orderstatus']}'); + + final parentState = + widget.parentState ?? + context.findAncestorStateOfType<_MyDeliveriesState>(); + final orderId = (d['orderid'] ?? '').toString(); + final deliveryId = (d['deliveryid'] ?? '').toString(); + + final bool wasSkipped = _wasSkippedDelivery(d, parentState); + + if (parentState != null) { + final key = parentState._getOrderKey(d); + debugPrint('[CONFIRM] Generated key: $key'); + debugPrint( + '[CONFIRM] Is in cache: ${parentState._skippedOrdersCache.containsKey(key)}', + ); + debugPrint( + '[CONFIRM] All cache keys: ${parentState._skippedOrdersCache.keys.toList()}', + ); + } + debugPrint('[CONFIRM] wasSkipped=$wasSkipped'); + debugPrint('[CONFIRM] =========================='); + + // Set BOTH flags to block all interactions + if (mounted) { + setState(() { + isLoading = true; + isProcessing = true; + }); + } + + try { + final dc = Get.put(DeliveriesController(), permanent: true); + final int deliveryIdInt = int.tryParse('${d['deliveryid'] ?? 0}') ?? 0; + final int orderHeaderId = int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0; + final int deliveryLocationId = + int.tryParse('${d['deliverylocationid'] ?? 0}') ?? 0; + + bool apiSuccess = false; + + if (isDeliveredSelected) { + // Delivery amount should come from API (deliveryamt), not from the + // selected payment option text (Cash/UPI), to avoid conflicts with + // collection amounts. + double deliveryAmount = _getDeliveryAmount(); + + // Fallback: if API did not send deliveryamt, keep old behavior of + // parsing a numeric value from the selected option (if any). + if (deliveryAmount == 0.0 && selectedOption.isNotEmpty) { + final m = RegExp(r'[0-9]+(?:\.[0-9]+)?').firstMatch(selectedOption); + if (m != null) { + deliveryAmount = double.tryParse(m.group(0)!) ?? 0.0; + } + } + + // Collection amounts from API (if any) + final double apiCollectionAmt = _getCollectionAmount(); + double collectionAmtToSend = 0.0; + double collectedAmtToSend = 0.0; + int collectionStatusToSend = 0; // 1 = cash, 2 = UPI, 3 = not collected + + if (apiCollectionAmt > 0) { + collectionAmtToSend = apiCollectionAmt; + final lower = selectedOption.toLowerCase(); + if (lower.startsWith('cash')) { + collectedAmtToSend = apiCollectionAmt; + collectionStatusToSend = 1; // Cash + } else if (lower.startsWith('upi')) { + collectedAmtToSend = apiCollectionAmt; + collectionStatusToSend = 2; // UPI + } else { + collectedAmtToSend = 0.0; + collectionStatusToSend = 3; // Not Collected + } + } + + double parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + final pickupLat = parseD(d['pickuplat'] ?? d['PickupLat'] ?? 0); + final pickupLng = parseD(d['pickuplon'] ?? d['PickupLon'] ?? 0); + final deliveryLat = parseD( + d['droplat'] ?? d['DropLat'] ?? d['deliverylat'] ?? 0, + ); + final deliveryLng = parseD( + d['droplon'] ?? d['DropLon'] ?? d['deliverylong'] ?? 0, + ); + + // Start fetching location in background while user takes photo + final locationFuture = Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 5), + ), + ).then((p) => p).catchError((e) { + debugPrint('[DELIVER] Error getting rider location: $e'); + return null; + }); + + debugPrint( + '[DELIVER][CONFIRM] deliveryId=$deliveryIdInt orderHeaderId=$orderHeaderId deliveryLocationId=$deliveryLocationId deliveryAmount=$deliveryAmount notes="$selectedOption"', + ); + + // TRIGGER CAMERA - Proof of Delivery + // We do this concurrently with location fetching to save time + String? proofImageUrl; + + try { + final ImagePicker picker = ImagePicker(); + // Optimize image: Resize to max 1024x1024 and 50% quality for faster upload + final XFile? photo = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 50, + maxWidth: 1024, + maxHeight: 1024, + ); + + if (photo == null) { + // User cancelled camera, abort delivery confirmation + if (mounted) { + setState(() { + isLoading = false; + isProcessing = false; + }); + } + debugPrint('[CONFIRM] Camera cancelled by user'); + return; + } + + // Upload Proof + // By now, location fetching should be nearly done + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? 0; + if (userId > 0) { + proofImageUrl = await dc.uploadProofImage( + File(photo.path), + 'delivered', + userId, + deliveryIdInt, + ); + } + } catch (e) { + debugPrint('[CONFIRM] Camera/Upload error: $e'); + } + + // Now wait for location if it hasn't finished yet + String riderLatStr = '0'; + String riderLngStr = '0'; + final position = await locationFuture; + riderLatStr = position.latitude.toStringAsFixed(6); + riderLngStr = position.longitude.toStringAsFixed(6); + + // Validate coordinates before API call + final double riderLat = double.tryParse(riderLatStr) ?? 0.0; + final double riderLng = double.tryParse(riderLngStr) ?? 0.0; + final bool hasValidRiderLocation = + riderLat != 0 && + riderLng != 0 && + riderLat.abs() <= 90 && + riderLng.abs() <= 180; + final bool hasValidDeliveryLocation = + deliveryLat != 0 && + deliveryLng != 0 && + deliveryLat.abs() <= 90 && + deliveryLng.abs() <= 180; + + if (!hasValidRiderLocation || !hasValidDeliveryLocation) { + debugPrint( + '[DELIVER][CONFIRM] ⚠️ Invalid coordinates - Rider: ($riderLat, $riderLng), Delivery: ($deliveryLat, $deliveryLng)', + ); + if (mounted) { + setState(() { + isLoading = false; + }); + } + // Show error alert + Get.snackbar( + 'Location Error', + 'Unable to get your location. Please ensure GPS is enabled and try again.', + backgroundColor: Colors.red, + colorText: Colors.white, + duration: const Duration(seconds: 4), + snackPosition: SnackPosition.TOP, + isDismissible: true, + ); + lastFailureTime = DateTime.now(); + await Future.delayed(const Duration(seconds: 4)); + if (mounted) { + setState(() { + isProcessing = false; + }); + } + return; + } + + + + + if (proofImageUrl == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to upload proof image. Please try again.')), + ); + setState(() { + isLoading = false; + isProcessing = false; + }); + } + return; + } + + + apiSuccess = await dc.updateDeliveredStatus( + deliveryId: deliveryIdInt, + orderHeaderId: orderHeaderId, + deliveryLocationId: deliveryLocationId, + smsDelivery: 0, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + pickupLat: pickupLat.toStringAsFixed(6), + pickupLng: pickupLng.toStringAsFixed(6), + deliveryLat: deliveryLat.toStringAsFixed(6), + deliveryLng: deliveryLng.toStringAsFixed(6), + notes: selectedOption, + deliveryAmount: deliveryAmount, + collectionAmount: collectionAmtToSend, + collectedAmount: collectedAmtToSend, + collectionStatus: collectionStatusToSend, + wasSkipped: wasSkipped, + orderId: orderId, // ✅ Pass orderId for bonus points logic + proofImage: proofImageUrl, + ); + debugPrint('[DELIVER][CONFIRM] result=$apiSuccess'); + + if (apiSuccess) { + // On success, cache and clean up before navigating to done screen + await _cacheDeliveredCancelled( + deliveryId, + orderId, + isDelivered: true, + ); + await _cleanupAfterCompletion( + orderId, + deliveryId, + parentState, + isCancelled: false, + ); + if (!mounted) return; + + if (!mounted) return; + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => DeliveriesDone( + bonusPoints: dc.lastBonusPoints.value, + ), + ), + ); + return; + } + } else { + double parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + final pickupLat = parseD(d['pickuplat'] ?? d['PickupLat'] ?? 0); + final pickupLng = parseD(d['pickuplon'] ?? d['PickupLon'] ?? 0); + + String riderLatStr = '0'; + String riderLngStr = '0'; + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 5), + ), + ); + riderLatStr = position.latitude.toStringAsFixed(6); + riderLngStr = position.longitude.toStringAsFixed(6); + } catch (e) { + debugPrint('[CANCEL] Error getting rider location: $e'); + } + + // Validate coordinates before API call + final double riderLat = double.tryParse(riderLatStr) ?? 0.0; + final double riderLng = double.tryParse(riderLngStr) ?? 0.0; + final bool hasValidRiderLocation = + riderLat != 0 && + riderLng != 0 && + riderLat.abs() <= 90 && + riderLng.abs() <= 180; + + // For cancellation, we need delivery location from the delivery data + final double cancelDeliveryLat = parseD( + d['droplat'] ?? d['DropLat'] ?? d['deliverylat'] ?? 0, + ); + final double cancelDeliveryLng = parseD( + d['droplon'] ?? d['DropLon'] ?? d['deliverylong'] ?? 0, + ); + final bool hasValidDeliveryLocation = + cancelDeliveryLat != 0 && + cancelDeliveryLng != 0 && + cancelDeliveryLat.abs() <= 90 && + cancelDeliveryLng.abs() <= 180; + + if (!hasValidRiderLocation || !hasValidDeliveryLocation) { + debugPrint( + '[CANCEL][CONFIRM] ⚠️ Invalid coordinates - Rider: ($riderLat, $riderLng), Delivery: ($cancelDeliveryLat, $cancelDeliveryLng)', + ); + if (mounted) { + setState(() { + isLoading = false; + }); + } + // Show error alert + Get.snackbar( + 'Location Error', + 'Unable to get your location. Please ensure GPS is enabled and try again.', + backgroundColor: Colors.red, + colorText: Colors.white, + duration: const Duration(seconds: 4), + snackPosition: SnackPosition.TOP, + isDismissible: true, + ); + lastFailureTime = DateTime.now(); + await Future.delayed(const Duration(seconds: 4)); + if (mounted) { + setState(() { + isProcessing = false; + }); + } + return; + } + + apiSuccess = await dc.updateCancelledStatus( + deliveryId: deliveryIdInt, + orderHeaderId: orderHeaderId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + pickupLat: pickupLat.toStringAsFixed(6), + pickupLng: pickupLng.toStringAsFixed(6), + deliveryLat: cancelDeliveryLat.toStringAsFixed(6), + deliveryLng: cancelDeliveryLng.toStringAsFixed(6), + notes: selectedOption, + wasSkipped: wasSkipped, + ); + debugPrint('[CANCEL][CONFIRM] result=$apiSuccess'); + + if (apiSuccess) { + // On success, cache and clean up before navigating to done screen + await _cacheDeliveredCancelled( + deliveryId, + orderId, + isDelivered: false, + ); + await _cleanupAfterCompletion( + orderId, + deliveryId, + parentState, + isCancelled: true, + ); + if (!mounted) return; + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => const DeliveriesDone(isCancelled: true), + ), + ); + return; + } + } + + // If API failed, record the failure time to prevent duplicate alerts + lastFailureTime = DateTime.now(); + debugPrint( + '[CONFIRM] ⚠️ API failed at $lastFailureTime, waiting for alert to dismiss...', + ); + + // Stop the loading spinner immediately + if (mounted) { + setState(() { + isLoading = false; + }); + } + + // Wait for geofence/error snackbar to show and be dismissed + // The controller's _checkGeofence already shows the snackbar, so we just wait + // Don't show duplicate fallback error - controller already shows error message + await Future.delayed(const Duration(seconds: 3)); + + // Now re-enable the button + if (mounted) { + setState(() { + isProcessing = false; + }); + debugPrint('[CONFIRM] ✅ Button re-enabled after alert dismissal'); + } + } catch (e) { + debugPrint('[CONFIRM] ❌ Exception during confirm: $e'); + + // Record failure time + lastFailureTime = DateTime.now(); + + // Stop loading spinner + if (mounted) { + setState(() { + isLoading = false; + }); + } + + // Wait for potential error alert to dismiss (4 seconds) + await Future.delayed(const Duration(seconds: 4)); + + // Re-enable button + if (mounted) { + setState(() { + isProcessing = false; + }); + debugPrint('[CONFIRM] ✅ Button re-enabled after exception handling'); + } + } + } + + Future _cacheDeliveredCancelled( + String deliveryId, + String orderId, { + required bool isDelivered, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + if (userId > 0) { + final key = 'recently_delivered_cancelled_$userId'; + final deliveredCancelledJson = prefs.getString(key); + List deliveredCancelledList = []; + if (deliveredCancelledJson != null && + deliveredCancelledJson.isNotEmpty) { + deliveredCancelledList = jsonDecode(deliveredCancelledJson); + } + + deliveredCancelledList.add({ + 'deliveryId': deliveryId, + 'orderId': orderId, + 'timestamp': DateTime.now().toIso8601String(), + 'type': isDelivered ? 'delivered' : 'cancelled', + }); + + await prefs.setString(key, jsonEncode(deliveredCancelledList)); + debugPrint( + '[${isDelivered ? 'DELIVER' : 'CANCEL'}] ✅ Stored order in SharedPreferences: deliveryId=$deliveryId, orderId=$orderId', + ); + } + } catch (e) { + debugPrint( + '[${isDelivered ? 'DELIVER' : 'CANCEL'}] Error storing order in SharedPreferences: $e', + ); + } + } + + Future _cleanupAfterCompletion( + String orderId, + String deliveryId, + _MyDeliveriesState? parentState, { + required bool isCancelled, + }) async { + final label = isCancelled ? 'CANCEL' : 'DELIVER'; + if (orderId.isEmpty) { + debugPrint('[$label] ⚠️ Empty orderId, skipping cleanup'); + return; + } + + if (parentState == null) { + debugPrint( + '[$label] ⚠️ parentState null; relying on cache cleanup via SharedPreferences', + ); + // Try to find and stop timer from cart page if it exists + // This is a workaround - ideally we'd pass a callback + try { + // The cart page will handle timer cleanup on next refresh + debugPrint('[$label] Cart page will cleanup timer on next refresh'); + } catch (_) {} + return; + } + + parentState._stopDeliveryPosting(orderId); + debugPrint('[$label] Stopped timer for completed delivery: $orderId'); + + final deliveryKey = 'delivery_$deliveryId'; + final orderKey = 'order_$orderId'; + final actualKey = parentState._getOrderKey(widget.delivery); + + bool cacheUpdated = false; + + void removeKey(String key, String reason) { + if (parentState._skippedOrdersCache.remove(key) != null) { + parentState._skippedOrderTimestamps.remove(key); + cacheUpdated = true; + debugPrint('[$label] ✅ Removed from cache ($reason): $key'); + } + } + + removeKey(deliveryKey, 'delivery key'); + removeKey(orderKey, 'order key'); + removeKey(actualKey, 'actual key'); + + if (!cacheUpdated) { + final keysToRemove = []; + // NOTE: We rely on the explicitly constructed keys above to remove entries. + // Iterating via entry values isn't supported with Map cache. + // If items were not found by keys above, they remain until valid keys are used. + + for (final key in keysToRemove) { + parentState._skippedOrdersCache.remove(key); + parentState._skippedOrderTimestamps.remove(key); + cacheUpdated = true; + debugPrint('[$label] ✅ Removed from cache (matched by ID): $key'); + } + } + + if (cacheUpdated) { + await parentState._saveSkippedOrdersCache(); + debugPrint( + '[$label] ✅ Cache saved. Remaining: ${parentState._skippedOrdersCache.length}', + ); + } else { + debugPrint( + '[$label] ⚠️ Order not found in skipped cache (cleanup will run on next fetch)', + ); + } + } +} diff --git a/lib/views/Dashboard/deliveries/skip_sheet.dart b/lib/views/Dashboard/deliveries/skip_sheet.dart new file mode 100644 index 0000000..b36ed7d --- /dev/null +++ b/lib/views/Dashboard/deliveries/skip_sheet.dart @@ -0,0 +1,411 @@ +part of 'deliveries.dart'; + +// ------------------------------------------------------------------------- +// SKIP REASONS SHEET +// ------------------------------------------------------------------------- + Future _showMyOptionsSheet( + BuildContext context, + Map delivery, + _MyDeliveriesState? parentState, +) async { + int selected = -1; + bool isLoading = false; + + final List skipReasons = [ + 'Customer unreachable', + 'Customer not at the location', + ]; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: StatefulBuilder( + builder: (context, setState) { + Widget optionBox(String title, int index) { + final bool isSelected = selected == index; + + return GestureDetector( + onTap: () { + if (!isLoading) { + setState(() => selected = index); + } + }, + child: Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: isSelected + ? ColorConstants.primaryColor.withOpacity(0.1) + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? ColorConstants.primaryColor + : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Icon( + isSelected ? Icons.check_circle : Icons.circle_outlined, + color: isSelected + ? ColorConstants.primaryColor + : Colors.grey, + size: 28, + ), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } + + Future handleConfirm() async { + if (selected == -1 || isLoading) return; + + // Check skip limits before proceeding + final dc = Get.put(DeliveriesController(), permanent: true); + final skipStatus = await dc.checkSkipStatus(); + final int skipCount = skipStatus['count'] ?? 0; + bool applyPenalty = false; + + if (skipCount >= 2) { + // Show Styled Alert Dialog + final bool? confirm = await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + elevation: 5, + backgroundColor: Colors.white, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.warning_amber_rounded, + color: Colors.red.shade600, + size: 40, + ), + ), + const SizedBox(height: 20), + Text( + 'Skip Limit Reached', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + Text( + 'You have exceeded the limit of 2 skips within 3 hours.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontFamily: FontConstants.fontFamily, + color: Colors.black54, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.orange.shade200, + ), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.orange.shade800, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + "Proceeding will forfeit your bonus points for this session.", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.orange.shade900, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context, false), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: BorderSide(color: Colors.grey.shade300), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + "Cancel", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + "Confirm Skip", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + + if (confirm != true) return; // User cancelled or dismissed + applyPenalty = true; + } + + setState(() => isLoading = true); + + try { + final dc = Get.put(DeliveriesController(), permanent: true); + final d = delivery; + final int deliveryId = + int.tryParse('${d['deliveryid'] ?? 0}') ?? 0; + final int orderHeaderId = + int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0; + final String reason = selected >= 0 && selected < skipReasons.length + ? skipReasons[selected] + : ''; + + if (deliveryId > 0 && orderHeaderId > 0) { + String riderLatStr = '0'; + String riderLngStr = '0'; + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: const Duration(seconds: 5), + ); + riderLatStr = position.latitude.toStringAsFixed(6); + riderLngStr = position.longitude.toStringAsFixed(6); + } catch (e) { + debugPrint('[SKIP] Error getting rider location: $e'); + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + riderLatStr = lastPos.latitude.toStringAsFixed(6); + riderLngStr = lastPos.longitude.toStringAsFixed(6); + } + } catch (_) {} + } + + debugPrint( + '[SKIP] Updating status for deliveryId=$deliveryId orderHeaderId=$orderHeaderId reason="$reason"', + ); + + final ok = await dc.updateSkippedStatus( + deliveryId: deliveryId, + orderHeaderId: orderHeaderId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + notes: reason, + ); + + debugPrint('[SKIP] Status update result: $ok'); + + if (ok && context.mounted) { + // Register skip locally only on success + await dc.registerSkip(applyPenalty: applyPenalty); + + if (context.mounted) { + Navigator.pop(context); // Close the sheet + + if (parentState != null) { + // Only update list if called from the list view + parentState.markOrderAsSkipped(d, reason); + } else { + // If called from Navigation/Map (where parentState is null), + // we just close the sheet and let the caller handle the UI update + // typically by popping the route or showing a snackbar. + // We DO NOT force navigation to "MyDeliveries". + debugPrint( + '[SKIP] Skipped from Nav/Map screen, sheet closed.', + ); + } + } + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Failed to skip delivery. Please try again.', + ), + backgroundColor: Colors.red, + ), + ); + } + } else { + debugPrint( + '[SKIP] Invalid deliveryId: $deliveryId or orderHeaderId: $orderHeaderId', + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid delivery information.'), + backgroundColor: Colors.red, + ), + ); + } + } + } catch (e) { + debugPrint('[SKIP] Error updating skip status: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('An error occurred. Please try again.'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (context.mounted) { + setState(() => isLoading = false); + } + } + } + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 30), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Select Reason', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + IconButton( + icon: const Icon(Icons.cancel, + color: Colors.red, size: 32), + onPressed: isLoading ? null : () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 20), + optionBox('Customer unreachable', 0), + optionBox('Customer not at the location', 1), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: (selected == -1 || isLoading) + ? Colors.grey.shade300 + : ColorConstants.primaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: + (selected == -1 || isLoading) ? null : handleConfirm, + child: isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: + AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + 'Confirm', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: (selected == -1 || isLoading) + ? Colors.black45 + : Colors.white, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); +} + diff --git a/lib/views/Dashboard/home/homepage.dart b/lib/views/Dashboard/home/homepage.dart new file mode 100644 index 0000000..9cac2d8 --- /dev/null +++ b/lib/views/Dashboard/home/homepage.dart @@ -0,0 +1,3085 @@ +// ignore_for_file: unused_element, unused_import + +import 'package:flutter/material.dart'; +import 'dart:convert'; +import 'package:flutter_polyline_points/flutter_polyline_points.dart'; +import 'dart:async'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:nearle/providers/delivery/delivery_provider.dart'; +import 'package:nearle/providers/deliverylog/deliverylog_provider.dart'; +import 'package:nearle/views/Dashboard/orders/orderstaus_button.dart'; +import 'package:nearle/views/helpers/constants/apiconstants.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:nearle/controllers/profile_controller.dart'; +import 'package:nearle/controllers/riderlog.dart'; +import 'package:nearle/controllers/logcontroller.dart'; +import 'package:nearle/controllers/delivery.dart'; +import 'package:nearle/controllers/deliveries_controller.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:slide_to_submit_button/slide_to_submit_button.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:floating/floating.dart'; +import 'dart:math' as math; +import 'package:http/http.dart' as http; +import 'package:nearle/views/Dashboard/home/homepage_banner.dart'; +import 'package:nearle/views/Dashboard/deliveries/deliveries.dart' + as deliveries; +import 'package:nearle/background/live_tracking_service.dart'; + +/// Helper function to launch phone dialer - works in both debug and release builds +/// In release builds, canLaunchUrl may fail due to R8/ProGuard, so we always try to launch +Future launchPhoneDialer(String phoneNumber) async { + try { + // Sanitize phone number: keep only digits and '+' + final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), ''); + + if (phone.isEmpty) { + debugPrint( + '[PHONE] Empty phone number after sanitization, skipping dial', + ); + return false; + } + + final Uri uri = Uri(scheme: 'tel', path: phone); + + // Try canLaunchUrl first (works in debug, may fail in release) + bool canLaunch = false; + try { + canLaunch = await canLaunchUrl(uri); + debugPrint('[PHONE] canLaunchUrl result: $canLaunch'); + } catch (e) { + debugPrint('[PHONE] canLaunchUrl check failed (common in release): $e'); + // Continue anyway - launch might still work + } + + // Always attempt to launch, even if canLaunchUrl returned false + // Using LaunchMode.platformDefault is often safer for system intents like dialing + try { + final launched = await launchUrl(uri, mode: LaunchMode.platformDefault); + if (launched) { + debugPrint('[PHONE] Successfully launched dialer for: $phone'); + return true; + } else { + debugPrint('[PHONE] launchUrl returned false for: $phone'); + } + } catch (e) { + debugPrint('[PHONE] Failed to launch dialer: $e'); + } + return false; + } catch (e) { + debugPrint('[PHONE] Error in launchPhoneDialer: $e'); + return false; + } +} + +class Homepage extends StatefulWidget { + const Homepage({super.key}); + @override + State createState() => _HomepageState(); +} + +class _HomepageState extends State + with AutomaticKeepAliveClientMixin { + bool isOnline = true; + String _userName = ''; + String _shiftStart = ''; + String _shiftEnd = ''; + final DeliveryProvider _delivery = DeliveryProvider(); + final DeliveryController _deliveryController = Get.find(); + + // Core data - using Map with orderId as key for stable tracking + Map> _ordersMap = {}; + List _orderIds = []; // Maintain order + + StreamSubscription? _pollerSubscription; + Duration _currentPollInterval = const Duration(seconds: 0); + bool _isToggling = false; + + // Selection tracking + Map _selectedOrders = {}; + bool isAllSelected = false; + + String _lastQueuesJson = ''; + bool _loadedOnlineFromPrefs = false; + bool _isFetchingQueues = false; + + final Map _deliveryRunning = {}; + final Map _deliveryTimers = {}; + final Map> _deliveryBasePayload = + >{}; + final CreateDeliveryLogProvider _deliveryLogProvider = + CreateDeliveryLogProvider(); + // Prevent optimistic status flips; disable row while updating + final Map _statusBusy = {}; + + Timer? _locationTimer; + final Set _hiddenOrderIds = {}; + + // Active delivery tracking + List> _activeDeliveries = >[]; + + @override + void dispose() { + // Stop any active delivery posting timers + final keys = List.from(_deliveryTimers.keys); + for (final k in keys) { + _stopDeliveryPosting(k); + } + _pollerSubscription?.cancel(); + _locationTimer?.cancel(); + super.dispose(); + } + + void _stopDeliveryPosting(String orderId) { + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers.remove(orderId); + } + + // Helper to get order status from API data + String _getStatusFromOrder(Map order) { + final apiStatus = (order['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + if (apiStatus == 'accepted') return 'ACCEPTED'; + if (apiStatus == 'arrived') return 'ARRIVED'; + if (apiStatus == 'picked') return 'PICKED'; + return 'ACCEPT'; // Default for pending/new orders + } + + // Parse double safely + double _parseDouble(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + // Temporarily disabled: always allow (prox check will be re-enabled later) + Future _isNearPickupLocation(Map order) async { + try { + final pickupLat = _parseDouble(order['pickuplat'] ?? order['PickupLat']); + final pickupLng = _parseDouble(order['pickuplon'] ?? order['PickupLon']); + + if (pickupLat == 0 || pickupLng == 0) { + debugPrint('[PROXIMITY] Invalid pickup coordinates, skipping check.'); + return true; // Assume near if coordinates are missing + } + + final riderLoc = await _getValidCoordinates(); + if (riderLoc == null) { + debugPrint('[PROXIMITY] Could not get rider location, skipping check.'); + return true; // Assume near if location fails (don't block) + } + + final double riderLat = double.tryParse(riderLoc.$1) ?? 0; + final double riderLng = double.tryParse(riderLoc.$2) ?? 0; + + if (riderLat == 0 || riderLng == 0) { + return true; + } + + final double distanceInMeters = Geolocator.distanceBetween( + riderLat, + riderLng, + pickupLat, + pickupLng, + ); + + debugPrint( + '[PROXIMITY] Distance: ${distanceInMeters.toStringAsFixed(2)}m (Threshold: 500m)', + ); + + if (distanceInMeters > 500) { + return false; + } + + return true; + } catch (e) { + debugPrint('[PROXIMITY] Error checking proximity: $e'); + return true; // Fail safe + } + } + + // Check proximity for multiple orders + Future> _checkProximityForOrders( + List orderIds, + ) async { + final Map results = {}; + + for (final orderId in orderIds) { + final order = _ordersMap[orderId]; + if (order == null) { + results[orderId] = false; + continue; + } + + results[orderId] = await _isNearPickupLocation(order); + } + + return results; + } + + // Show proximity warning dialog + Future _showProximityWarning( + BuildContext context, { + String? specificMessage, + }) async { + return showDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + const Icon(Icons.location_off, color: Colors.red, size: 28), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Not at Pickup Location', + style: TextStyle( + fontSize: FontConstants.xLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + content: Text( + specificMessage ?? + 'You must be within 500 meters of the pickup location to mark this order as arrived. Please move closer and try again.', + style: TextStyle( + fontSize: FontConstants.regular(context), + fontFamily: FontConstants.fontFamily, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + style: TextButton.styleFrom( + foregroundColor: ColorConstants.primaryColor, + ), + child: Text( + 'OK', + style: TextStyle( + fontSize: FontConstants.regular(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + Future<(String lat, String lng)?> _getValidCoordinates() async { + try { + await _ensureLocationPermission(); + Position? pos; + try { + // Use lower accuracy with a shorter timeout for faster loading + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.low, + timeLimit: const Duration(seconds: 3), + ).timeout(const Duration(seconds: 3)); + } catch (_) { + pos = await Geolocator.getLastKnownPosition(); + } + if (pos != null) { + final lat = pos.latitude.toStringAsFixed(6); + final lng = pos.longitude.toStringAsFixed(6); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lat); + await prefs.setString('last_lng', lng); + } catch (_) {} + return (lat, lng); + } + try { + final prefs = await SharedPreferences.getInstance(); + final lat = (prefs.getString('last_lat') ?? '').trim(); + final lng = (prefs.getString('last_lng') ?? '').trim(); + if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') { + return (lat, lng); + } + } catch (_) {} + return null; + } catch (_) { + return null; + } + } + + Future _getLogInterval() async { + try { + final prefs = await SharedPreferences.getInstance(); + final secs = prefs.getInt('logseconds'); + final int interval = (secs != null && secs > 0) ? secs : 30; + return Duration(seconds: interval); + } catch (_) { + return const Duration(seconds: 30); + } + } + + @override + void initState() { + super.initState(); + // Run these in parallel for faster loading + _loadName(); + _ensureLocationPermission(); // Don't block on this + _ensureCameraPermission(); // Request camera permission at startup + _startPolling(); + + // Fetch queues immediately, don't wait for location + WidgetsBinding.instance.addPostFrameCallback((_) { + _fetchQueues(); + }); + + try { + if (Get.isRegistered()) { + final pc = Get.find(); + ever(pc.userName, (_) { + _loadName(); + }); + } + } catch (_) {} + } + + void _showLocationBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const Icon(Icons.location_off, size: 80, color: Colors.red), + const SizedBox(height: 16), + Text( + "Location is turned off", + style: TextStyle( + fontSize: FontConstants.xxLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 12), + Text( + "Please turn on location to receive orders and track delivery.", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: FontConstants.regular(context), + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () async { + Navigator.of(ctx).pop(); + await Geolocator.openLocationSettings(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: Text( + "Turn On Location", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Future _getAndUpdateCurrentLocation() async { + bool serviceEnabled; + LocationPermission permission; + + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + _showLocationBottomSheet(); + return; + } + + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + debugPrint("Location permissions are denied."); + return; + } + } + + if (permission == LocationPermission.deniedForever) { + debugPrint("Location permissions are permanently denied."); + return; + } + + Position position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + // ✅ OPTIMIZATION: Only geocode if moved significantly (> 100m) + // Reverse geocoding is expensive and slow + if (_lastGeocodedPosition != null) { + final dist = Geolocator.distanceBetween( + _lastGeocodedPosition!.latitude, + _lastGeocodedPosition!.longitude, + position.latitude, + position.longitude, + ); + if (dist < 100) { + // Hasn't moved enough to change address, skip + return; + } + } + + try { + List placemarks = await placemarkFromCoordinates( + position.latitude, + position.longitude, + ); + + if (placemarks.isNotEmpty) { + _lastGeocodedPosition = position; // Save for next check + Placemark place = placemarks.first; + + String fullAddress = + "${place.name}, ${place.subLocality}, ${place.locality}, ${place.administrativeArea}, ${place.country}, ${place.postalCode}"; + String city = place.locality ?? ''; + String state = place.administrativeArea ?? ''; + String suburb = place.subLocality ?? ''; + + await _updateProfile( + address: fullAddress, + latitude: position.latitude, + longitude: position.longitude, + city: city, + state: state, + suburb: suburb, + ); + } + } catch (e) { + debugPrint("Error geocoding location: $e"); + } + } + + Future _updateProfile({ + required String address, + required double latitude, + required double longitude, + required String city, + required String state, + required String suburb, + }) async { + debugPrint( + 'Profile update → $address | $latitude,$longitude | $city,$state,$suburb', + ); + } + + void _startLocationUpdates() { + _locationTimer?.cancel(); + _locationTimer = Timer.periodic(const Duration(seconds: 30), (_) { + _getAndUpdateCurrentLocation(); + }); + } + + Future _ensureLocationPermission() async { + try { + final prefs = await SharedPreferences.getInstance(); + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + // Try last known position first + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + await prefs.setString('last_lat', lastPos.latitude.toString()); + await prefs.setString('last_lng', lastPos.longitude.toString()); + } + // Open settings in background, don't wait + Geolocator.openLocationSettings(); + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.deniedForever || + permission == LocationPermission.denied) { + // Use last known position if available + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + await prefs.setString('last_lat', lastPos.latitude.toString()); + await prefs.setString('last_lng', lastPos.longitude.toString()); + } + return; + } + + // Use medium accuracy with timeout for faster initialization + Position? pos; + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.medium, // Changed from high + timeLimit: const Duration(seconds: 5), // Add timeout + ).timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('[HOMEPAGE] Timeout getting location, using last known: $e'); + pos = await Geolocator.getLastKnownPosition(); + } + + if (pos != null) { + await prefs.setString('last_lat', pos.latitude.toString()); + await prefs.setString('last_lng', pos.longitude.toString()); + } + } catch (e) { + debugPrint('[HOMEPAGE] Error ensuring location permission: $e'); + // Try to save last known position as fallback + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lastPos.latitude.toString()); + await prefs.setString('last_lng', lastPos.longitude.toString()); + } + } catch (_) {} + } + } + + Future _ensureCameraPermission() async { + try { + final status = await Permission.camera.status; + if (status.isDenied) { + // We haven't asked yet or user denied previously but not forever + await Permission.camera.request(); + } else if (status.isPermanentlyDenied) { + // User denied forever, we might want to show a dialog or snackbar + // asking them to go to settings, but for startup let's just leave it + // so we don't annoy them every time if they really don't want to. + debugPrint('[HOMEPAGE] Camera permission permanently denied'); + } + } catch (e) { + debugPrint('[HOMEPAGE] Error ensuring camera permission: $e'); + } + } + + void _confirmOnlineOffline() { + if (_isToggling) return; + final bool newStatus = !isOnline; + + if (!mounted) return; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext ctx) { + return StatefulBuilder( + builder: (ctx2, setSheetState) { + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx2).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Spacer(), + IconButton( + icon: const Icon( + Icons.cancel, + color: Colors.red, + size: 32, + ), + onPressed: () { + if (Navigator.of(ctx2).canPop()) { + Navigator.of(ctx2).pop(); + } + }, + ), + ], + ), + Image.asset( + 'assets/images/Nearle Bike.png', + height: 140, + errorBuilder: (c, e, s) => const SizedBox.shrink(), + ), + const SizedBox(height: 16), + Text( + newStatus ? "Go Online?" : "Go Offline?", + style: TextStyle( + fontSize: FontConstants.xxxLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 16), + Text( + newStatus + ? "You will start receiving orders." + : "You will stop receiving orders.", + style: TextStyle( + fontSize: FontConstants.large(context), + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 24), + SizedBox( + width: MediaQuery.of(context).size.width - 40, + child: SlideToSubmit.custom( + height: 55, + sliderWidth: 40, + padding: const EdgeInsets.all(8), + backgroundDecoration: BoxDecoration( + color: newStatus + ? ColorConstants.primaryColor.withOpacity(0.50) + : Colors.red.withOpacity(0.50), + borderRadius: BorderRadius.circular(40), + ), + foregroundDecoration: BoxDecoration( + color: newStatus + ? ColorConstants.primaryColor + : Colors.red, + borderRadius: BorderRadius.circular(999), + ), + slider: Center( + child: ClipOval( + child: Container( + height: 40, + width: 40, + color: Colors.white, // or any background color + padding: EdgeInsets.all(8), + child: const Icon( + Icons.arrow_forward_ios, + size: 28, + color: Colors.black, + ), + ), + ), + ), + hint: const Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: AnimatedSlideArrow( + arrowImage: AssetImage( + 'assets/arrow_right.png', + package: 'slide_to_submit_button', + ), + ), + ), + ), + onSubmit: (controller) async { + if (!mounted) return; + if (_isToggling) return; + + if (newStatus == false) { + // Check for any pending queues or live (picked) deliveries + bool hasPending = + _ordersMap.isNotEmpty || + _deliveryRunning.values.any((v) => v); + try { + final prefs = + await SharedPreferences.getInstance(); + final bool hasLive = + prefs.getBool('has_live_deliveries') ?? + false; + hasPending = hasPending || hasLive; + } catch (_) {} + if (hasPending) { + try { + await showDialog( + context: context, + builder: (dCtx) => AlertDialog( + title: const Text('Pending orders'), + content: const Text( + 'Please complete all orders before going offline.', + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of(dCtx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } catch (_) {} + try { + controller.reset(); + } catch (_) {} + if (Navigator.of(ctx2).canPop()) { + Navigator.of(ctx2).pop(); + } + return; + } + } + + _isToggling = true; + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogCtx) { + return const Center( + child: CircularProgressIndicator(), + ); + }, + ); + + bool ok = false; + try { + ok = await _handleOnlineToggle( + newStatus, + ).timeout(const Duration(seconds: 30)); + } catch (_) { + ok = false; + } finally { + if (mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + _isToggling = false; + } + + if (!mounted) return; + setState(() { + isOnline = ok ? newStatus : isOnline; + }); + + try { + final prefs = + await SharedPreferences.getInstance(); + await prefs.setBool('online', isOnline); + } catch (_) {} + + if (mounted) { + Get.snackbar( + ok + ? (newStatus ? 'Online' : 'Offline') + : 'Error', + ok + ? (newStatus + ? 'You are now online.' + : 'You are now offline.') + : 'Something went wrong. Please try again.', + backgroundColor: Colors.black.withOpacity(0.6), + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(12), + borderRadius: 12, + duration: const Duration(seconds: 2), + ); + } + + if (ok && newStatus) { + _startLocationUpdates(); + LiveTrackingService().startTracking(); + _fetchQueues(); + _startPolling(); + } else if (!newStatus) { + _locationTimer?.cancel(); + LiveTrackingService().stopTracking(); + _fetchQueues(); + _startPolling(); + } + + try { + controller.reset(); + } catch (_) {} + if (Navigator.of(ctx2).canPop()) { + Navigator.of(ctx2).pop(); + } + }, + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } + + Future _loadName() async { + String name = ''; + try { + if (Get.isRegistered()) { + final pc = Get.find(); + final val = pc.userName.value; + if (val.toString().trim().isNotEmpty) { + name = val.toString().trim(); + } + } + } catch (_) {} + + try { + final prefs = await SharedPreferences.getInstance(); + if (name.isEmpty) { + name = (prefs.getString('user_name') ?? '').trim(); + } + _shiftStart = (prefs.getString('starttime') ?? '').toString(); + _shiftEnd = (prefs.getString('endtime') ?? '').toString(); + final savedOnline = prefs.getBool('online'); + final int onduty = prefs.getInt('onduty') ?? -1; + + if (!mounted) return; + setState(() { + _userName = name; + if (!_loadedOnlineFromPrefs) { + if (onduty == 1) { + isOnline = true; + prefs.setBool('online', true); + // Ensure foreground logging service is running when already on duty + try { + if (Get.isRegistered()) { + Get.find().startLogging(); + } + } catch (_) {} + } else if (onduty == 0) { + isOnline = false; + prefs.setBool('online', false); + } else { + isOnline = savedOnline ?? true; + } + _loadedOnlineFromPrefs = true; + } + }); + } catch (_) { + if (!mounted) return; + setState(() { + _userName = name; + isOnline = true; + }); + } + } + + Future _handleOnlineToggle(bool online) async { + try { + if (!Get.isRegistered()) return false; + final ctl = Get.find(); + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userId') ?? prefs.getInt('userid'); + if (userId == null) return false; + + const String lat = '0'; + const String lng = '0'; + + if (online == true) { + debugPrint('[BREAK] Ending break... lat=$lat lng=$lng'); + final ok = await ctl + .endBreakAuto(latitude: lat, longitude: lng) + .timeout(const Duration(seconds: 12), onTimeout: () => false); + debugPrint('[BREAK] endBreakAuto -> $ok'); + final dutyOk = await ctl.setOnDuty(true); + debugPrint('[ONDUTY] setOnDuty(true) -> $dutyOk'); + await prefs.setBool('online', dutyOk); + } else { + debugPrint('[BREAK] Starting break... lat=$lat lng=$lng'); + final ok = await ctl + .startBreakAuto(latitude: lat, longitude: lng) + .timeout(const Duration(seconds: 12), onTimeout: () => false); + debugPrint('[BREAK] startBreakAuto -> $ok'); + final dutyOk = await ctl.setOnDuty(false); + debugPrint('[ONDUTY] setOnDuty(false) -> $dutyOk'); + await prefs.setBool('online', dutyOk ? false : true); + } + return true; + } catch (_) { + return false; + } + } + + void _startPolling() { + final interval = isOnline + ? const Duration(seconds: 5) + : const Duration(seconds: 20); + + if (_pollerSubscription != null && _currentPollInterval == interval) { + return; + } + + _pollerSubscription?.cancel(); + _currentPollInterval = interval; + + // Use Stream.periodic instead of Timer.periodic for better resource management + _pollerSubscription = Stream.periodic(interval, (_) {}) + .asyncMap((_) async { + if (mounted && !_isFetchingQueues) { + await _fetchQueues(); + } + }) + .listen( + (_) {}, // Success handler + onError: (error) { + // Handle errors gracefully without crashing + debugPrint('[HOMEPAGE][STREAM ERROR] $error'); + }, + cancelOnError: false, // Continue even on errors + ); + } + + Future _fetchQueues() async { + if (_isFetchingQueues) return; + + try { + _isFetchingQueues = true; + + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid'); + + if (userId == null) { + if (!mounted) return; + setState(() { + _ordersMap.clear(); + _orderIds.clear(); + _selectedOrders.clear(); + isAllSelected = false; + }); + return; + } + + final items = await _delivery.getDeliveryQueues( + live: true, + userid: userId, + ); + + if (!mounted) return; + + // ✅ Also fetch active deliveries from v2 API (same as deliveries page) + await _fetchActiveDeliveries(userId); + + final validOrders = items.whereType>().where((m) { + final status = (m['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final oid = (m['orderid'] ?? '').toString(); + + if (oid.isNotEmpty && _hiddenOrderIds.contains(oid)) return false; + // Exclude active orders from main list (they show in banner) + if (status == 'active') return false; + return status != 'picked'; + }).toList(); + + if (validOrders.isNotEmpty) { + final delivery = validOrders.first; + await _deliveryController.saveFromQueueItem(delivery); + } + + final newJson = jsonEncode(validOrders); + if (newJson == _lastQueuesJson) { + return; + } + _lastQueuesJson = newJson; + + final Map> newOrdersMap = {}; + final List newOrderIds = []; + + for (final order in validOrders) { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) continue; + + newOrdersMap[orderId] = order; + newOrderIds.add(orderId); + } + + final Map newSelection = {}; + for (final orderId in newOrderIds) { + newSelection[orderId] = _selectedOrders[orderId] ?? false; + } + + setState(() { + _ordersMap = newOrdersMap; + _orderIds = newOrderIds; + _selectedOrders = newSelection; + + if (_selectedOrders.isEmpty) { + isAllSelected = false; + } else { + isAllSelected = _selectedOrders.values.every((selected) => selected); + } + + for (final orderId in _orderIds) { + if (!_deliveryRunning.containsKey(orderId)) { + _deliveryRunning[orderId] = false; + } + } + }); + } catch (e) { + debugPrint('[FETCH_QUEUES] Error: $e'); + } finally { + _isFetchingQueues = false; + } + } + + // Persistent client to avoid creating new connections constantly + static final http.Client _httpClient = http.Client(); + Position? _lastGeocodedPosition; + + // Fetch active deliveries from v2 API (same as deliveries page) + Future _fetchActiveDeliveries(int userId) async { + try { + // Get current date in YYYY-MM-DD format + final now = DateTime.now(); + final today = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + + // Hardcoded API endpoint for active deliveries: v2/deliveries/getdeliveries + final bool isLive = ApiConstants.mainRoute == 'live'; + final baseUrl = isLive + ? 'https://jupiter.nearle.app/live/api/v2/deliveries/getdeliveries' + : 'https://jupiter.nearle.app/dev/api/v2/deliveries/getdeliveries'; + + final uri = Uri.parse(baseUrl).replace( + queryParameters: { + 'userid': userId.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + + // Fetch deliveries from v2 API directly using REUSED http client + List itemsV2 = []; + try { + final response = await _httpClient.get(uri); // Use persistent client + + if (response.statusCode >= 200 && response.statusCode < 300) { + final decoded = json.decode(response.body); + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + itemsV2 = data is List + ? data + : (data is Map && data['items'] is List + ? data['items'] as List + : []); + } else { + debugPrint('[HOMEPAGE] v2 API error: ${response.statusCode}'); + } + } catch (e) { + debugPrint('[HOMEPAGE] Error fetching from v2 API: $e'); + } + // Do NOT close client here + + // Filter for ACTIVE status from v2 API + final activeOrders = itemsV2.whereType>().where(( + order, + ) { + final status = (order['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isActive = status == 'active'; + if (isActive) { + debugPrint( + '[HOMEPAGE] ✅ Found active order from v2: ${order['orderid']}', + ); + } + return isActive; + }).toList(); + + debugPrint( + '[HOMEPAGE] Found ${activeOrders.length} active deliveries from v2 API', + ); + + if (mounted) { + setState(() { + _activeDeliveries = activeOrders; + }); + } + } catch (e) { + debugPrint('[HOMEPAGE] Error fetching active deliveries: $e'); + } + } + + PreferredSizeWidget _buildHeader(double width) { + final height = MediaQuery.of(context).size.height; + final w = width; + + // Responsive multipliers copied from lib/homepage.dart + final titleSize = w * 0.06; // Hi, Rider + final shiftSize = w * 0.04; // Shift : 10 AM to 6 PM + final switchWidth = w * 0.32; // Status button width + final switchHeight = height * 0.045; + + return PreferredSize( + preferredSize: Size.fromHeight(height * 0.115), + child: SafeArea( + top: true, + bottom: false, + child: AppBar( + automaticallyImplyLeading: false, + backgroundColor: ColorConstants.primaryColor, + elevation: 0, + toolbarHeight: height * 0.115, // responsive toolbar height + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Hi, ${_userName.isNotEmpty ? _userName.split(' ').first : "Rider"}!", + style: TextStyle( + fontSize: titleSize, // responsive + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: height * 0.012), + Text( + (() { + final s = _shiftStart.trim(); + final e = _shiftEnd.trim(); + if (s.isEmpty || e.isEmpty) return "Shift : -"; + return "Shift : $s to $e"; + })(), + style: TextStyle( + color: Colors.white, + fontSize: shiftSize, // responsive + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + actions: [ + Padding( + padding: EdgeInsets.only(right: w * 0.03), + child: GestureDetector( + onTap: _hasActiveDelivery() ? null : _confirmOnlineOffline, + child: Container( + width: switchWidth, // responsive + height: switchHeight, // responsive + decoration: BoxDecoration( + color: isOnline ? Colors.green : Colors.red, + borderRadius: BorderRadius.circular(switchHeight * 0.9), + ), + child: Stack( + children: [ + Align( + alignment: isOnline + ? Alignment.centerLeft + : Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: w * 0.04), + child: Text( + isOnline ? "ONLINE" : "OFFLINE", + style: TextStyle( + color: Colors.white, + fontSize: w * 0.038, // responsive + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + Align( + alignment: isOnline + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.all(2), + width: switchHeight * 0.78, + height: switchHeight * 0.78, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: Center( + child: Image.asset( + 'assets/images/onlineoffline.png', + width: switchHeight * 0.45, + height: switchHeight * 0.45, + errorBuilder: (c, e, s) => + const SizedBox.shrink(), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + // ✅ Check if there's an active delivery (blocks all actions) + bool _hasActiveDelivery() { + return _activeDeliveries.isNotEmpty; + } + + // Previously showed a blocking dialog; now we just disable conflicting buttons in the UI. + void _showActiveDeliveryBlockMessage() {} + + // Navigate to active delivery map screen directly (same as deliveries page) + Future _navigateToActiveDelivery(Map delivery) async { + if (!mounted) return; + debugPrint( + '[HOMEPAGE] Navigate to active delivery map: ${delivery['orderid']}', + ); + + // Navigate directly to delivery map screen (same as deliveries page) + await deliveries.MyDeliveries.navigateToDeliveryMap(context, delivery); + + // Refresh after returning from map screen + if (mounted) { + await Future.delayed(const Duration(milliseconds: 500)); + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid'); + if (userId != null) { + await _fetchQueues(); + } + } + } + + String _getBulkButtonLabel() { + final selectedOrderIds = _selectedOrders.entries + .where((e) => e.value) + .map((e) => e.key) + .toList(); + + if (selectedOrderIds.isEmpty) return "Accept Orders"; + + final statuses = selectedOrderIds + .map((id) => _getStatusFromOrder(_ordersMap[id] ?? {})) + .toSet(); + + if (statuses.length == 1 && statuses.first == 'ACCEPT') { + return "Accept Orders"; + } + + if (statuses.length == 1 && statuses.first == 'ACCEPTED') { + return "Move to Arrived"; + } + + if (statuses.length == 1 && statuses.first == 'ARRIVED') { + return "Move to Picked"; + } + + if (statuses.contains('ACCEPT')) { + return "Accept Orders"; + } else if (statuses.contains('ACCEPTED')) { + return "Move to Arrived"; + } else { + return "Move to Picked"; + } + } + + Future _handleBulkAction() async { + final dc = Get.put(DeliveriesController(), permanent: true); + + final selectedOrderIds = _selectedOrders.entries + .where((e) => e.value) + .map((e) => e.key) + .toList(); + + if (selectedOrderIds.isEmpty) return; + + final statuses = selectedOrderIds + .map((id) => _getStatusFromOrder(_ordersMap[id] ?? {})) + .toSet(); + + String targetStatus; + if (statuses.length == 1 && statuses.first == 'ACCEPT') { + targetStatus = 'ACCEPTED'; + } else if (statuses.length == 1 && statuses.first == 'ACCEPTED') { + targetStatus = 'ARRIVED'; + } else if (statuses.length == 1 && statuses.first == 'ARRIVED') { + targetStatus = 'PICKED'; + } else { + targetStatus = 'ACCEPTED'; + } + + // ✅ PROXIMITY CHECK FOR ARRIVED STATUS + if (targetStatus == 'ARRIVED') { + debugPrint( + '[BULK] Checking proximity for ${selectedOrderIds.length} orders...', + ); + + // Quick proximity check (no loading dialog since it's fast) + final proximityResults = await _checkProximityForOrders(selectedOrderIds) + .timeout( + const Duration(seconds: 3), + onTimeout: () { + debugPrint('[BULK] Proximity check timeout, allowing all'); + // Return all true on timeout + return Map.fromEntries( + selectedOrderIds.map((id) => MapEntry(id, true)), + ); + }, + ); + + // Find orders that are NOT near pickup + final farOrders = proximityResults.entries + .where((e) => !e.value) + .map((e) => e.key) + .toList(); + + if (farOrders.isNotEmpty) { + // Show error for all orders that are too far + if (mounted) { + final message = farOrders.length == selectedOrderIds.length + ? 'You must be within 500 meters of the pickup location(s) to mark orders as arrived. Please move closer and try again.' + : 'Some selected orders are too far from their pickup locations. Please move closer or deselect those orders.'; + + await _showProximityWarning(context, specificMessage: message); + } + return; // Don't proceed with the update + } + + debugPrint('[BULK] All orders within 500m of pickup. Proceeding...'); + } + + debugPrint( + '[BULK] Moving ${selectedOrderIds.length} orders to $targetStatus', + ); + + // TRIGGER CAMERA FOR BULK PICKED + XFile? bulkPhoto; + if (targetStatus == 'PICKED') { + try { + final ImagePicker picker = ImagePicker(); + bulkPhoto = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 50, + ); + if (bulkPhoto == null) return; // Abort if cancelled + } catch (e) { + debugPrint('[BULK] Camera error: $e'); + return; + } + } + + // Show minimal loading indicator (only for bulk operations) + showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.black.withOpacity(0.3), + builder: (ctx) => const Center(child: CircularProgressIndicator()), + ); + + try { + // Upload Bulk Proof if available + String? bulkProofImageUrl; + if (bulkPhoto != null) { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = int.tryParse(prefs.getString('userid') ?? '0') ?? 0; + if (userId > 0) { + bulkProofImageUrl = await dc.uploadProofImage( + File(bulkPhoto.path), + 'picked', + userId, + 0, // 0 for bulk upload + ); + } + } catch (e) { + debugPrint('[BULK] Upload error: $e'); + } + } + + for (final orderId in selectedOrderIds) { + final order = _ordersMap[orderId]; + if (order == null) continue; + + final deliveryId = int.tryParse('${order['deliveryid'] ?? 0}') ?? 0; + final orderHeaderId = + int.tryParse('${order['orderheaderid'] ?? 0}') ?? 0; + final pickupLocationId = + int.tryParse('${order['pickuplocationid'] ?? 0}') ?? 0; + + bool success = false; + + if (targetStatus == 'ACCEPTED') { + debugPrint('[BULK] Accepting order $orderId'); + success = await dc + .updateAcceptedStatus( + deliveryId: deliveryId, + orderHeaderId: orderHeaderId, + ) + .timeout(const Duration(seconds: 20), onTimeout: () => false); + } else if (targetStatus == 'ARRIVED') { + debugPrint('[BULK] Arriving order $orderId'); + + final pickupLat = _parseDouble( + order['pickuplat'] ?? order['PickupLat'] ?? 0, + ); + final pickupLng = _parseDouble( + order['pickuplon'] ?? order['PickupLon'] ?? 0, + ); + final riderLoc = await _getValidCoordinates(); + + success = await dc + .updateArrivedStatus( + deliveryId: deliveryId, + orderHeaderId: orderHeaderId, + pickupLat: pickupLat.toString(), + pickupLng: pickupLng.toString(), + ridersLat: riderLoc?.$1 ?? '0', + ridersLng: riderLoc?.$2 ?? '0', + ) + .timeout(const Duration(seconds: 20), onTimeout: () => false); + } else if (targetStatus == 'PICKED') { + debugPrint('[BULK] Picking order $orderId'); + + // Get pickup coordinates and rider location for distance calculation + final pickupLat = _parseDouble( + order['pickuplat'] ?? order['PickupLat'] ?? 0, + ); + final pickupLng = _parseDouble( + order['pickuplon'] ?? order['PickupLon'] ?? 0, + ); + + final riderLoc = await _getValidCoordinates(); + final riderLatStr = riderLoc?.$1 ?? '0'; + final riderLngStr = riderLoc?.$2 ?? '0'; + + success = await dc + .updatePickedStatus( + deliveryId: deliveryId, + orderHeaderId: orderHeaderId, + pickupLocationId: pickupLocationId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + pickupLat: pickupLat.toStringAsFixed(6), + pickupLng: pickupLng.toStringAsFixed(6), + proofImage: bulkProofImageUrl, + ) + .timeout(const Duration(seconds: 20), onTimeout: () => false); + + if (success) { + _hiddenOrderIds.add(orderId); + } + } + + if (!success) { + debugPrint('[BULK] Failed to update order $orderId'); + } + + // Reduced delay between requests + await Future.delayed(const Duration(milliseconds: 50)); + } + + setState(() { + _selectedOrders.clear(); + isAllSelected = false; + }); + + await _fetchQueues(); + } catch (e) { + debugPrint('[BULK] Error: $e'); + } finally { + if (mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + + final selectedCount = _selectedOrders.values.where((s) => s).length; + + return WillPopScope( + onWillPop: () async { + try { + final prefs = await SharedPreferences.getInstance(); + final hasLive = prefs.getBool('has_live_deliveries') ?? false; + if (hasLive) { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Pending deliveries'), + content: const Text( + 'Are you sure you want to close? There are deliveries pending.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('No'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Yes'), + ), + ], + ), + ); + return confirm == true; + } + } catch (_) {} + return true; + }, + child: SafeArea( + top: true, + bottom: true, + left: true, + right: true, + child: Scaffold( + backgroundColor: Colors.grey.shade200, + appBar: _buildHeader(MediaQuery.of(context).size.width), + body: Stack( + children: [ + Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(14.0), + child: Text( + "Orders Pending", + style: TextStyle( + fontSize: 21, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + Transform.translate( + offset: Offset(-10, 1), + child: Text( + "Select all", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Transform.translate( + offset: Offset(-10, 0), + child: Transform.scale( + scale: 1.3, + child: Checkbox( + value: isAllSelected, + activeColor: ColorConstants.primaryColor, + onChanged: + _orderIds.isEmpty || _hasActiveDelivery() + ? null + : (value) { + setState(() { + isAllSelected = value ?? false; + for (final orderId in _orderIds) { + _selectedOrders[orderId] = + isAllSelected; + } + }); + }, + ), + ), + ), + ], + ), + ), + ], + ), + Divider(color: Colors.grey.shade400, thickness: 1, height: 1), + + Expanded( + child: _orderIds.isEmpty + ? Transform.translate( + offset: const Offset(0, -4), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/images/Nearle Bike.png"), + const SizedBox(height: 16), + Text( + "No Orders at the moment", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: FontConstants.xxxLarge(context), + fontFamily: FontConstants.fontFamily, + color: Colors.grey.shade500, + ), + ), + ], + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + itemCount: _orderIds.length, + itemBuilder: (context, index) { + final orderId = _orderIds[index]; + final item = _ordersMap[orderId] ?? {}; + + final title = + (item['deliverycustomer'] ?? + 'Senthil Stores, RS Puram') + .toString(); + final address = + (item['deliveryaddress'] ?? + '23, 2nd street, Race course') + .toString(); + final tenant = (item['tenantname'] ?? '') + .toString(); + final pickupCustomer = + (item['pickupcustomer'] ?? '') + .toString() + .trim(); + final bool hasPickupCustomer = + pickupCustomer.isNotEmpty; + final String primaryStoreName = hasPickupCustomer + ? pickupCustomer + : tenant; + final String secondaryStoreName = + hasPickupCustomer ? tenant : ''; + final int quantity = + int.tryParse( + '${item['Quantity'] ?? item['quantity'] ?? 0}', + ) ?? + 0; + final isSelected = + _selectedOrders[orderId] ?? false; + + final currentStatus = _getStatusFromOrder(item); + + return Container( + key: ValueKey('order_$orderId'), + margin: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 12, + height: 12, + decoration: + const BoxDecoration( + color: Colors.orange, + shape: + BoxShape.circle, + ), + ), + Container( + width: 2, + height: 30, + color: Colors.grey.shade300, + ), + Container( + width: 12, + height: 12, + decoration: + const BoxDecoration( + color: Colors.green, + shape: + BoxShape.circle, + ), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Transform.translate( + offset: const Offset( + 0, + -5, + ), + child: Text( + title, + style: TextStyle( + fontWeight: + FontWeight.w600, + fontSize: 18, + color: Colors.black, + fontFamily: + FontConstants + .fontFamily, + ), + ), + ), + const SizedBox(height: 18), + Transform.translate( + offset: const Offset( + 0, + -1, + ), + child: Text( + address, + style: TextStyle( + fontSize: 18, + color: Colors.black87, + fontFamily: + FontConstants + .fontFamily, + ), + ), + ), + const SizedBox(height: 6), + if (quantity > 0) + Text( + 'Quantity: $quantity', + style: TextStyle( + fontSize: 19, + color: Colors + .blueGrey + .shade900, + fontFamily: + FontConstants + .fontFamily, + fontWeight: + FontWeight.bold, + ), + ), + ], + ), + ), + GestureDetector( + onTap: _hasActiveDelivery() + ? null + : () { + setState(() { + _selectedOrders[orderId] = + !isSelected; + isAllSelected = + _selectedOrders + .values + .every( + (s) => s, + ); + }); + }, + child: Container( + padding: const EdgeInsets.all( + 4, + ), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorConstants + .primaryColor, + width: 2, + ), + ), + child: AnimatedContainer( + duration: const Duration( + milliseconds: 200, + ), + width: 20, + height: 20, + decoration: BoxDecoration( + color: isSelected + ? ColorConstants + .primaryColor + : Colors.white, + shape: BoxShape.circle, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + LayoutBuilder( + builder: (context, constraints) => + Row( + children: List.generate( + (constraints.maxWidth / 6) + .floor(), + (index) => const Expanded( + child: Padding( + padding: + EdgeInsets.symmetric( + horizontal: 1.5, + ), + child: Divider( + color: Colors.grey, + thickness: 1, + height: 1, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 10), + SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Image.asset( + "assets/images/shoppingbag.png", + height: 32, + width: 32, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + primaryStoreName, + style: TextStyle( + fontWeight: + FontWeight + .w600, + fontSize: 16, + color: + Colors.black, + fontFamily: + FontConstants + .fontFamily, + ), + ), + if (secondaryStoreName + .isNotEmpty) ...[ + const SizedBox( + height: 2, + ), + Text( + secondaryStoreName, + style: TextStyle( + fontSize: 16, + color: Colors + .blueGrey + .shade700, + fontFamily: + FontConstants + .fontFamily, + fontWeight: + FontWeight + .w500, + ), + ), + ], + ], + ), + Text( + "Order ID: #$orderId", + style: TextStyle( + fontSize: 18, + color: Colors.black54, + fontFamily: + FontConstants + .fontFamily, + ), + ), + ], + ), + ), + InkWell( + onTap: () async { + // Just launch dialer, no PiP + final phone = + (item['pickupcontactno'] ?? + '') + .toString(); + final bool success = + await launchPhoneDialer( + phone.isNotEmpty + ? phone + : '9876543210', + ); + if (!success && + context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + 'Could not launch dialer', + ), + ), + ); + } + }, + child: Image.asset( + "assets/images/phone-call .png", + height: 27, + width: 27, + errorBuilder: (c, e, s) => + const Icon( + Icons.phone, + size: 27, + color: Colors.green, + ), + ), + ), + SizedBox(width: 13), + InkWell( + onTap: () { + _showmapDetailsSheet( + context, + item, + ); + }, + child: Row( + children: [ + Image.asset( + "assets/images/map.png", + height: 26, + width: 26, + ), + const SizedBox(width: 15), + InkWell( + onTap: () { + _showProductDetailsSheet( + context, + ); + }, + child: Image.asset( + "assets/images/information-point.png", + height: 27, + width: 27, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Status buttons row + Row( + children: [ + Expanded( + child: OrderStatusRow( + key: ValueKey( + 'status_${orderId}_$currentStatus', + ), + currentStatus: currentStatus, + enabled: + selectedCount == 1 && + isSelected && + !(_statusBusy[orderId] ?? + false), + onStatusChange: + ( + newStatus, { + String? notes, + String? proofImagePath, + }) async { + debugPrint( + '[SINGLE] Order $orderId: $currentStatus -> $newStatus', + ); + + // ✅ PROXIMITY CHECK FOR ARRIVED STATUS (SINGLE ORDER) + // ✅ PROXIMITY CHECK DELEGATED TO CONTROLLER + // We removed the local check here because the controller + // already performs a robust geofence check with better error handling. + + if (_statusBusy[orderId] == + true) { + return false; + } + + if (mounted) { + setState(() { + _statusBusy[orderId] = + true; + }); + } + + final dc = Get.put( + DeliveriesController(), + permanent: true, + ); + final deliveryId = + int.tryParse( + '${item['deliveryid'] ?? 0}', + ) ?? + 0; + final orderHeaderId = + int.tryParse( + '${item['orderheaderid'] ?? 0}', + ) ?? + 0; + final pickupLocationId = + int.tryParse( + '${item['pickuplocationid'] ?? 0}', + ) ?? + 0; + + bool success = false; + + try { + if (newStatus == + 'ACCEPTED') { + debugPrint( + '[SINGLE] updateAcceptedStatus deliveryId=$deliveryId', + ); + success = await dc + .updateAcceptedStatus( + deliveryId: + deliveryId, + orderHeaderId: + orderHeaderId, + ) + .timeout( + const Duration( + seconds: 30, + ), + onTimeout: () => + false, + ); + } else if (newStatus == + 'ARRIVED') { + debugPrint( + '[SINGLE] updateArrivedStatus deliveryId=$deliveryId', + ); + + final pickupLat = + _parseDouble( + item['pickuplat'] ?? + item['PickupLat'] ?? + 0, + ); + final pickupLng = + _parseDouble( + item['pickuplon'] ?? + item['PickupLon'] ?? + 0, + ); + final riderLoc = + await _getValidCoordinates(); + + success = await dc + .updateArrivedStatus( + deliveryId: + deliveryId, + orderHeaderId: + orderHeaderId, + pickupLat: pickupLat + .toString(), + pickupLng: pickupLng + .toString(), + ridersLat: + riderLoc?.$1 ?? + '0', + ridersLng: + riderLoc?.$2 ?? + '0', + ) + .timeout( + const Duration( + seconds: 30, + ), + onTimeout: () => + false, + ); + } else if (newStatus == + 'PICKED') { + debugPrint( + '[SINGLE] updatePickedStatus deliveryId=$deliveryId', + ); + + final pickupLat = + _parseDouble( + item['pickuplat'] ?? + item['PickupLat'] ?? + 0, + ); + // PROOF OF DELIVERY UPLOAD + String? proofImageUrl; + if (proofImagePath != + null) { + try { + final prefs = await SharedPreferences.getInstance(); + // Safely retrieve 'userid' which might be stored as int or String + final rawUserId = prefs.get('userid'); + final userId = + int.tryParse( + '${rawUserId ?? 0}', + ) ?? + 0; + + if (userId > 0) { + proofImageUrl = await dc + .uploadProofImage( + File( + proofImagePath, + ), + 'picked', + userId, + deliveryId, + ); + debugPrint( + '[SINGLE] Picked Proof URL: $proofImageUrl'); + } + } catch (e) { + debugPrint( + 'Error uploading proof image: $e', + ); + } + } + + final pickupLng = + _parseDouble( + item['pickuplon'] ?? + item['PickupLon'] ?? + 0, + ); + + final riderLoc = + await _getValidCoordinates(); + final riderLatStr = + riderLoc?.$1 ?? '0'; + final riderLngStr = + riderLoc?.$2 ?? '0'; + + success = await dc + .updatePickedStatus( + deliveryId: + deliveryId, + orderHeaderId: + orderHeaderId, + pickupLocationId: + pickupLocationId, + ridersLat: + riderLatStr, + ridersLng: + riderLngStr, + pickupLat: pickupLat + .toStringAsFixed( + 6, + ), + pickupLng: pickupLng + .toStringAsFixed( + 6, + ), + proofImage: + proofImageUrl, + ) + .timeout( + const Duration( + seconds: 30, + ), + onTimeout: () => + false, + ); + + if (success) { + // Signal Deliveries page to refresh immediately + dc.triggerRefresh(); + + _startDeliveryPosting( + item, + ); + _hiddenOrderIds.add( + orderId, + ); + } + } else if (newStatus == + 'REJECTED') { + debugPrint( + '[SINGLE] updateRejectedStatus deliveryId=$deliveryId notes=${notes ?? ''}', + ); + success = await dc + .updateRejectedStatus( + deliveryId: + deliveryId, + orderHeaderId: + orderHeaderId, + notes: notes ?? '', + ) + .timeout( + const Duration( + seconds: 30, + ), + onTimeout: () => + false, + ); + } + + if (success) { + if (newStatus == 'REJECTED' || newStatus == 'PICKED') { + if (mounted) { + setState(() { + _hiddenOrderIds.add(orderId); + _ordersMap.remove(orderId); + _orderIds.remove(orderId); + _selectedOrders.remove(orderId); + _statusBusy.remove(orderId); + }); + } + await _fetchQueues(); + if (mounted) { + if (newStatus == 'REJECTED') { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Order rejected.')), + ); + } else if (newStatus == 'PICKED') { + // Optional: Confirm move to next tab + // ScaffoldMessenger.of(context).showSnackBar( + // const SnackBar( + // content: Text('Order moved to Deliveries.'), + // duration: Duration(seconds: 1), + // ), + // ); + } + } + } else { + if (mounted) { + setState(() { + final order = _ordersMap[orderId]; + if (order != null) { + order['orderstatus'] = newStatus.toLowerCase(); + } + _statusBusy[orderId] = false; + }); + } + await _fetchQueues(); + if (mounted && newStatus == 'ARRIVED') { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Arrived status updated.')), + ); + } + } + } else { + debugPrint( + '[SINGLE] API call failed for order $orderId', + ); + if (mounted) { + setState(() { + _statusBusy[orderId] = + false; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + 'Failed to update status. Please try again.', + ), + ), + ); + } + } + } catch (e) { + debugPrint( + '[SINGLE] Error updating order $orderId: $e', + ); + if (mounted) { + setState(() { + _statusBusy[orderId] = + false; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar( + const SnackBar( + content: Text( + 'Error updating status.', + ), + ), + ); + } + } + + return success; + }, + ), + ), + ], + ), + ], + ), + ); + }, + ), + ), + + // Bulk action button + if (selectedCount > 1) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + child: ElevatedButton( + onPressed: _hasActiveDelivery() + ? null + : _handleBulkAction, + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text( + _getBulkButtonLabel(), + style: TextStyle( + fontSize: FontConstants.xLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + // Bottom banner for active deliveries (Swiggy/Zomato style) + _activeDeliveries.isNotEmpty + ? Positioned( + left: 0, + right: 0, + bottom: 0, + child: ActiveDeliveryBanner( + activeDeliveries: _activeDeliveries, + onTap: (delivery) async { + await _navigateToActiveDelivery(delivery); + // Refresh after returning from map screen + if (mounted) { + await Future.delayed( + const Duration(milliseconds: 500), + ); + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid'); + if (userId != null) { + await _fetchQueues(); + } + } + }, + ), + ) + : const SizedBox.shrink(), + ], + ), + ), + ), + ); + } + + void _startDeliveryPosting(Map it) async { + final orderId = (it['orderid'] ?? '').toString(); + if (orderId.isEmpty) return; + + final base = { + 'logid': 0, + 'tenantid': it['tenantid'] ?? 0, + 'partnerid': it['partnerid'] ?? 0, + 'locationid': it['locationid'] ?? 0, + 'orderheaderid': it['orderheaderid'] ?? 0, + 'deliveryid': it['deliveryid'] ?? 0, + 'userid': it['userid'] ?? 0, + 'orderid': orderId, + 'orderstatus': 'picked', + }; + _deliveryBasePayload[orderId] = base; + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'deliverylog_${orderId}_tenantid', + (base['tenantid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_partnerid', + (base['partnerid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_locationid', + (base['locationid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_orderheaderid', + (base['orderheaderid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_deliveryid', + (base['deliveryid'] ?? 0).toString(), + ); + await prefs.setString( + 'deliverylog_${orderId}_userid', + (base['userid'] ?? 0).toString(), + ); + await prefs.setString('deliverylog_${orderId}_orderid', orderId); + await prefs.setString( + 'deliverylog_${orderId}_orderstatus', + (base['orderstatus'] ?? 'picked').toString(), + ); + } catch (_) {} + + await _postDeliveryLog(orderId); + final interval = await _getLogInterval(); + _deliveryTimers[orderId]?.cancel(); + _deliveryTimers[orderId] = Timer.periodic(interval, (_) { + _postDeliveryLog(orderId); + }); + } + + Future _postDeliveryLog(String orderId) async { + try { + Map? base = _deliveryBasePayload[orderId]; + if (base == null) { + try { + final prefs = await SharedPreferences.getInstance(); + base = { + 'logid': 0, + 'tenantid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_tenantid') ?? '0', + ) ?? + 0, + 'partnerid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_partnerid') ?? '0', + ) ?? + 0, + 'locationid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_locationid') ?? '0', + ) ?? + 0, + 'orderheaderid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_orderheaderid') ?? + '0', + ) ?? + 0, + 'deliveryid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_deliveryid') ?? '0', + ) ?? + 0, + 'userid': + int.tryParse( + prefs.getString('deliverylog_${orderId}_userid') ?? '0', + ) ?? + 0, + 'orderid': + prefs.getString('deliverylog_${orderId}_orderid') ?? orderId, + 'orderstatus': + prefs.getString('deliverylog_${orderId}_orderstatus') ?? + 'picked', + }; + } catch (_) {} + } + if (base == null) return; + + final coords = await _getValidCoordinates(); + final now = DateTime.now(); + final logdate = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + + final payload = { + ...base, + 'logdate': logdate, + 'latitude': (coords?.$1 ?? '0'), + 'longitude': (coords?.$2 ?? '0'), + }; + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + debugPrint('[DELIVERYLOG][POST] URL: $url'); + debugPrint('[DELIVERYLOG][POST] Body: $payload'); + + await _deliveryLogProvider.createDeliveryLog(url, payload); + } catch (e) { + debugPrint('[DELIVERYLOG][POST] error: $e'); + } + } + + @override + bool get wantKeepAlive => true; +} + +// Map details bottom sheet +void _showmapDetailsSheet( + BuildContext context, + Map? item, +) async { + // ⭐ STEP 1 — Show instant loader bottom sheet + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) { + return SizedBox( + height: 180, + child: Center(child: CircularProgressIndicator()), + ); + }, + ); + + // Give time to open loader smoothly + await Future.delayed(const Duration(milliseconds: 10)); + + // ⭐ STEP 2 — Now load heavy work (your full code) + double parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + final String pickupLatStr = (item?['pickuplat'] ?? item?['PickupLat'] ?? '') + .toString(); + final String pickupLngStr = (item?['pickuplon'] ?? item?['PickupLon'] ?? '') + .toString(); + // ignore: unused_local_variable + final String dropLatStr = + (item?['droplat'] ?? item?['DropLat'] ?? item?['deliverylat'] ?? '') + .toString(); + // ignore: unused_local_variable + final String dropLngStr = + (item?['droplon'] ?? item?['DropLon'] ?? item?['deliverylong'] ?? '') + .toString(); + final String orderId = (item?['orderid'] ?? '').toString(); + final String statusStr = (item?['orderstatus'] ?? '').toString(); + final String pickupAddress = + (item?['Pickupaddress'] ?? item?['pickuplocation'] ?? '').toString(); + // ignore: unused_local_variable + final String dropAddress = + (item?['deliveryaddress'] ?? item?['deliverylocation'] ?? '').toString(); + + double pickupLat = parseD(pickupLatStr); + double pickupLng = parseD(pickupLngStr); + bool hasPickup = pickupLat != 0 && pickupLng != 0; + final Completer mapController = Completer(); + + double riderLat = 0.0; + double riderLng = 0.0; + bool hasRiderLocation = false; + + try { + Position? pos; + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: const Duration(seconds: 8), + ); + } catch (e) { + pos = await Geolocator.getLastKnownPosition(); + } + if (pos != null) { + riderLat = pos.latitude; + riderLng = pos.longitude; + hasRiderLocation = true; + } + } catch (e) {} + + double distanceMeters = 0.0; + String distanceKm = '0.00'; + if (hasRiderLocation && hasPickup) { + distanceMeters = Geolocator.distanceBetween( + riderLat, + riderLng, + pickupLat, + pickupLng, + ); + distanceKm = (distanceMeters / 1000).toStringAsFixed(2); + } + + final bool hasAny = hasRiderLocation && hasPickup; + + final List polylineCoords = []; + final Set polylines = {}; + + const String googleAPIKey = "AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q"; + + if (hasRiderLocation && hasPickup) { + final PolylinePoints polylinePoints = PolylinePoints(apiKey: googleAPIKey); + final PolylineResult result = await polylinePoints + .getRouteBetweenCoordinates( + request: PolylineRequest( + origin: PointLatLng(riderLat, riderLng), + destination: PointLatLng(pickupLat, pickupLng), + mode: TravelMode.driving, + ), + ); + if (result.points.isNotEmpty) { + for (var point in result.points) { + polylineCoords.add(LatLng(point.latitude, point.longitude)); + } + polylines.add( + Polyline( + polylineId: const PolylineId("route"), + color: Colors.blueAccent, + width: 5, + points: polylineCoords, + ), + ); + } + } + + // ⭐ STEP 3 — Close loader + Navigator.pop(context); + + // ⭐ STEP 4 — Open your FULL ORIGINAL bottom sheet (no UI changed) + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return Container( + margin: const EdgeInsets.only(top: 50), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(25)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 15, + offset: const Offset(0, -5), + ), + ], + ), + child: Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 10, + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 50, + height: 5, + margin: const EdgeInsets.only(bottom: 15), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(20), + ), + ), + + // ⭐ ALL YOUR ORIGINAL UI BELOW (unchanged) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Trip Map", + style: TextStyle( + fontSize: FontConstants.xxLarge(context), + fontWeight: FontWeight.bold, + color: Colors.grey.shade900, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 4), + Text( + orderId.isNotEmpty ? 'Order: #$orderId' : 'Order: -', + style: TextStyle(color: Colors.grey.shade700), + ), + ], + ), + ), + InkWell( + onTap: () => Navigator.of(context).pop(), + child: const Icon( + Icons.cancel_rounded, + size: 36, + color: Colors.red, + ), + ), + ], + ), + const SizedBox(height: 5), + + if (hasAny) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + children: [ + Transform.translate( + offset: Offset(0, -18), + child: const Icon( + Icons.location_on, + color: Colors.red, + size: 22, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + hasPickup + ? "Pickup: ${pickupAddress.isNotEmpty ? pickupAddress : 'Your current location'}" + : 'Pickup: Not available', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 5), + + if (distanceKm != '0.00') + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade200), + ), + child: Row( + children: [ + const Icon( + Icons.navigation, + color: Colors.blue, + size: 22, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Distance to Pickup: $distanceKm km", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + color: Colors.blue.shade700, + ), + ), + ), + ], + ), + ), + if (distanceKm != '0.00') const SizedBox(height: 10), + + ClipRRect( + borderRadius: BorderRadius.circular(15), + child: Container( + height: 280, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade200), + ), + child: GoogleMap( + initialCameraPosition: CameraPosition( + target: hasRiderLocation && hasPickup + ? LatLng( + (riderLat + pickupLat) / 2, + (riderLng + pickupLng) / 2, + ) + : hasPickup + ? LatLng(pickupLat, pickupLng) + : hasRiderLocation + ? LatLng(riderLat, riderLng) + : const LatLng(0, 0), + zoom: hasRiderLocation && hasPickup ? 13 : 15, + ), + markers: { + if (hasRiderLocation) + Marker( + markerId: const MarkerId("rider"), + position: LatLng(riderLat, riderLng), + infoWindow: const InfoWindow( + title: "Your Location", + ), + icon: BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueAzure, + ), + ), + if (hasPickup) + Marker( + markerId: const MarkerId("pickup"), + position: LatLng(pickupLat, pickupLng), + infoWindow: const InfoWindow( + title: "Pickup Location", + ), + icon: BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueRed, + ), + ), + }, + polylines: polylines, + zoomGesturesEnabled: true, + scrollGesturesEnabled: true, + tiltGesturesEnabled: true, + rotateGesturesEnabled: true, + zoomControlsEnabled: false, + myLocationButtonEnabled: false, + onMapCreated: (GoogleMapController controller) async { + mapController.complete(controller); + if (hasRiderLocation && hasPickup) { + final bounds = LatLngBounds( + southwest: LatLng( + riderLat < pickupLat ? riderLat : pickupLat, + riderLng < pickupLng ? riderLng : pickupLng, + ), + northeast: LatLng( + riderLat > pickupLat ? riderLat : pickupLat, + riderLng > pickupLng ? riderLng : pickupLng, + ), + ); + await controller.animateCamera( + CameraUpdate.newLatLngBounds(bounds, 80), + ); + } + }, + ), + ), + ), + const SizedBox(height: 8), + + Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(15), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Order Details", + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Order ID:", + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + Text( + orderId.isNotEmpty ? "#$orderId" : '-', + style: const TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Status:", + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + Text( + statusStr.isNotEmpty ? statusStr : '-', + style: TextStyle( + color: Colors.orange.shade700, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Distance:", + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + Text( + "$distanceKm km", + style: const TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Quantity:", + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.w500, + ), + ), + Text( + "${item?['Quantity'] ?? item?['quantity'] ?? '-'}", + style: const TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); +} + +// Product details bottom sheet +void _showProductDetailsSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Product Details", + style: TextStyle( + fontSize: FontConstants.xxxLarge(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + IconButton( + icon: const Icon(Icons.cancel, color: Colors.red, size: 32), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 10), + + Text( + "No product details yet", + style: TextStyle( + fontSize: 18, + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/views/Dashboard/home/homepage_banner.dart b/lib/views/Dashboard/home/homepage_banner.dart new file mode 100644 index 0000000..46be4e1 --- /dev/null +++ b/lib/views/Dashboard/home/homepage_banner.dart @@ -0,0 +1,145 @@ +// Active Delivery Banner Widget for Home Page +// ignore_for_file: unused_import + +import 'package:flutter/material.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; + +class ActiveDeliveryBanner extends StatelessWidget { + final List> activeDeliveries; + final Function(Map) onTap; + + const ActiveDeliveryBanner({ + super.key, + required this.activeDeliveries, + required this.onTap, + }); + + String _getDeliveryAddress(Map delivery) { + final address = + delivery['deliveryaddress'] ?? + delivery['DeliveryAddress'] ?? + delivery['address'] ?? + ''; + if (address.toString().length > 40) { + return '${address.toString().substring(0, 40)}...'; + } + return address.toString(); + } + + @override + Widget build(BuildContext context) { + if (activeDeliveries.isEmpty) { + return const SizedBox.shrink(); + } + + // Show first active delivery (or show count if multiple) + final delivery = activeDeliveries.first; + final count = activeDeliveries.length; + + return Container( + margin: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onTap(delivery), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + // Active indicator icon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.two_wheeler, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + // Delivery info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + count > 1 + ? '$count Active Deliveries' + : 'Active Delivery', + style: TextStyle( + color: Colors.white, + fontSize: 19, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + if (count > 1) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$count', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + _getDeliveryAddress(delivery), + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 17, + fontFamily: FontConstants.fontFamily, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Arrow icon + const Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: 20, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/orders/orderstaus_button.dart b/lib/views/Dashboard/orders/orderstaus_button.dart new file mode 100644 index 0000000..6a69373 --- /dev/null +++ b/lib/views/Dashboard/orders/orderstaus_button.dart @@ -0,0 +1,611 @@ +import 'package:flutter/material.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'dart:async'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:slide_to_submit_button/slide_to_submit_button.dart'; +import 'package:image_picker/image_picker.dart'; + +/// ------------------------------ +/// MAIN WIDGET WITH TWO BUTTONS +/// ------------------------------ +class OrderStatusRow extends StatefulWidget { + final String currentStatus; + final Future Function(String newStatus, {String? notes, String? proofImagePath}) onStatusChange; + final bool enabled; + + const OrderStatusRow({ + super.key, + required this.currentStatus, + required this.onStatusChange, + this.enabled = true, + }); + + @override + State createState() => _OrderStatusRowState(); +} + +class _OrderStatusRowState extends State { + bool _isProcessing = false; + String? _overrideStatus; + + @override + void didUpdateWidget(covariant OrderStatusRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentStatus != widget.currentStatus && + mounted && + _overrideStatus != null && + widget.currentStatus == _overrideStatus) { + setState(() { + _overrideStatus = null; + }); + } else if (oldWidget.currentStatus != widget.currentStatus && + _overrideStatus != null) { + _overrideStatus = null; + } + } + + String get _effectiveStatus => _overrideStatus ?? widget.currentStatus; + + Future _onStatusChanged(String newStatus, {String? notes, String? proofImagePath}) async { + debugPrint('[OSR] _onStatusChanged: $newStatus, proofImagePath: $proofImagePath'); + if (_isProcessing) return false; + + // Optimistic UI: flip status immediately to avoid visible lag. + setState(() { + _isProcessing = true; + _overrideStatus = newStatus; + }); + + bool success = false; + bool timedOut = false; + try { + // Hard cap wait time to keep UI from spinning indefinitely. + success = await widget + .onStatusChange(newStatus, notes: notes, proofImagePath: proofImagePath) + .timeout( + const Duration(seconds: 3), + onTimeout: () { + timedOut = true; + // Assume success on timeout to avoid UI rollback; data will + // refresh from server on next fetch. + return true; + }, + ); + } catch (_) { + success = false; + } finally { + if (!mounted) return success; + setState(() { + _isProcessing = false; + // If API failed, revert the optimistic status. + // If timed out, keep the optimistic status (server likely completed). + _overrideStatus = (success || timedOut) ? newStatus : null; + }); + } + return success; + } + + // ------------------------------ + // REJECT SHEET + // ------------------------------ + void _showRejectSheet(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + String selectedReason = ""; + bool isLoading = false; + final List reasons = [ + "Customer not reachable", + "Wrong address", + "Out of delivery area", + "Other reason", + ]; + + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: StatefulBuilder( + builder: (context, setModalState) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Reject Order", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.red), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 10), + for (String reason in reasons) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: GestureDetector( + onTap: () => setModalState(() { + selectedReason = reason; + }), + child: Container( + height: 55, + width: double.infinity, + decoration: BoxDecoration( + color: selectedReason == reason + ? Colors.red + : Colors.grey[300], + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text( + reason, + style: TextStyle( + color: selectedReason == reason + ? Colors.white + : Colors.black, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + fontSize: 16, + ), + ), + ), + ), + ), + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: selectedReason.isEmpty || isLoading + ? null + : () async { + setModalState(() => isLoading = true); + final success = await _onStatusChanged( + "REJECTED", + notes: selectedReason, + ); + if (context.mounted) { + Navigator.pop(context, success); + } + }, + child: isLoading + ? const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ) + : const Text( + "Reject Order", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + // ------------------------------ + // CANCEL SHEET + // ------------------------------ + void _showCancelSheet(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + bool confirmCancel = false; + bool isLoading = false; + + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: StatefulBuilder( + builder: (context, setModalState) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Cancel this order?", + style: + TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + const Text( + "Once cancelled, this order will return to pending state.", + textAlign: TextAlign.center, + ), + const SizedBox(height: 25), + CheckboxListTile( + value: confirmCancel, + onChanged: (value) => setModalState( + () => confirmCancel = value ?? false, + ), + title: const Text("I confirm to cancel this order"), + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: !confirmCancel + ? null + : () async { + setModalState(() => isLoading = true); + await Future.delayed(const Duration(seconds: 1)); + if (context.mounted) Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Order Cancelled"), + ), + ); + }, + child: isLoading + ? const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ) + : const Text( + "Confirm Cancel", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final String currentStatus = _effectiveStatus; + final bool showCancel = + currentStatus == "ARRIVED" || currentStatus == "PICKED"; + final String leftText = showCancel ? "CANCEL" : "REJECT"; + + return Stack( + children: [ + Row( + children: [ + // LEFT BUTTON (Reject / Cancel) + Expanded( + child: InkWell( + onTap: () { + if (showCancel) { + _showCancelSheet(context); + } else { + _showRejectSheet(context); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: const BoxDecoration( + color: Colors.red, // + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(12), + ), + ), + alignment: Alignment.center, + child: Text( + leftText, + style: TextStyle( + fontSize: 19, + color: Colors.white, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ), + + // RIGHT BUTTON (ACCEPT / ARRIVED / PICKED) + OrderStatusButton( + key: ValueKey(currentStatus), + currentStatus: currentStatus, + onStatusChange: (status, {proofImagePath}) => + _onStatusChanged(status, proofImagePath: proofImagePath), + enabled: widget.enabled && !_isProcessing, + ), + ], + ), + if (_isProcessing) + Positioned.fill( + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.12), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + child: const Center( + child: SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +/// ------------------------------ +/// STATUS BUTTON LOGIC (NO DELIVERY) +/// ------------------------------ +class OrderStatusButton extends StatefulWidget { + final String currentStatus; + final Future Function(String newStatus, {String? proofImagePath}) onStatusChange; + final bool enabled; + + const OrderStatusButton({ + super.key, + required this.currentStatus, + required this.onStatusChange, + this.enabled = true, + }); + + @override + State createState() => _OrderStatusButtonState(); +} + +class _OrderStatusButtonState extends State { + String get _buttonText => widget.currentStatus; + + Color _getButtonColor() { + switch (_buttonText) { + case "ARRIVED": + return Colors.orange; + case "PICKED": + return ColorConstants.primaryColor; + default: + return Colors.green; + } + } + + void _showStatusSheet() { + if (_buttonText == "PICKED") return; // final stage now + + bool isConfirmLoading = false; + + // Determine next status (single step) + String? nextStatus; + if (_buttonText == "ACCEPT") { + nextStatus = "ACCEPTED"; + } else if (_buttonText == "ACCEPTED") { + nextStatus = "ARRIVED"; + } else if (_buttonText == "ARRIVED") { + nextStatus = "PICKED"; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + // If for some reason we don't have a valid next status, show nothing + if (nextStatus == null) { + return const SizedBox.shrink(); + } + + return SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: StatefulBuilder( + builder: (context, setModalState) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Move Your Order to", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + Transform.translate( + offset: const Offset(0, -5), + child: IconButton( + icon: const Icon( + Icons.cancel, + color: Colors.red, + size: 36, + ), + onPressed: () => Navigator.pop(context), + ), + ), + ], + ), + const SizedBox(height: 20), + + // Slider to confirm moving to next status wrapped with SafeArea + SafeArea( + top: false, + left: false, + right: false, + bottom: true, + child: SizedBox( + width: double.infinity, + child: SlideToSubmit.custom( + height: 55, + sliderWidth: 40, + padding: const EdgeInsets.all(8), + backgroundDecoration: BoxDecoration( + color: _getButtonColor().withOpacity(0.5), + borderRadius: BorderRadius.circular(40), + ), + foregroundDecoration: BoxDecoration( + color: _getButtonColor(), + borderRadius: BorderRadius.circular(999), + ), + slider: Center( + child: ClipOval( + child: Container( + height: 40, + width: 40, + color: Colors.white, + padding: const EdgeInsets.all(8), + child: const Icon( + Icons.arrow_forward_ios, + size: 24, + color: Colors.black, + ), + ), + ), + ), + hint: Align( + alignment: Alignment.center, + child: Text( + 'Slide to mark $nextStatus', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: const Color.fromARGB(255, 15, 14, 14), + ), + ), + ), + onSubmit: (controller) async { + if (isConfirmLoading) return; + + setModalState(() => isConfirmLoading = true); + + // Close sheet after slide completes + if (context.mounted) { + Navigator.pop(context); + } + + // Trigger status change callback + WidgetsBinding.instance.addPostFrameCallback(( + _, + ) async { + if (nextStatus == "PICKED") { + debugPrint('[OSB] Status is PICKED, launching camera...'); + // Trigger Camera + final ImagePicker picker = ImagePicker(); + final XFile? photo = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 50, // Optimize size + ); + + if (photo != null) { + debugPrint('[OSB] Photo taken: ${photo.path}'); + // Process with image + await widget.onStatusChange( + nextStatus!, + proofImagePath: photo.path, + ); + } else { + debugPrint('[OSB] Camera cancelled or photo null'); + } + } else { + debugPrint('[OSB] Status NOT PICKED (is $nextStatus), normal flow'); + // Normal flow + await widget.onStatusChange(nextStatus!); + } + + try { + controller.reset(); + } catch (_) {} + }); + }, + ), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + onTap: widget.enabled ? _showStatusSheet : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: widget.enabled ? _getButtonColor() : Colors.grey, + borderRadius: const BorderRadius.only( + bottomRight: Radius.circular(12), + ), + ), + alignment: Alignment.center, + child: Text( + _buttonText, + style: TextStyle( + fontSize: 19, + color: Colors.white, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/profile/Profilepage.dart b/lib/views/Dashboard/profile/Profilepage.dart new file mode 100644 index 0000000..df73c82 --- /dev/null +++ b/lib/views/Dashboard/profile/Profilepage.dart @@ -0,0 +1,464 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; // ⭐ REQUIRED +import 'package:nearle/views/Dashboard/profile/informations/help_center.dart'; +import 'package:nearle/views/Dashboard/profile/informations/profile.dart'; +import 'package:nearle/views/Dashboard/profile/informations/saved_address.dart'; +import 'package:nearle/views/Dashboard/profile/informations/faq.dart'; +import 'package:nearle/views/Dashboard/profile/informations/notifications_page.dart'; +import 'package:nearle/views/Dashboard/profile/informations/support_ticket.dart'; + +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:nearle/views/onboardscreens/Sign_in.dart'; +import 'package:nearle/controllers/profile_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/views/Dashboard/profile/informations/order_alert_sound.dart'; +import 'package:nearle/controllers/rewards_controller.dart'; +import 'package:nearle/views/Dashboard/profile/rewards_card.dart'; +import 'package:nearle/views/Dashboard/profile/informations/rider_rewards_page.dart'; +import 'package:nearle/utils/mqtt_service.dart'; + +class ProfilePage extends StatefulWidget { + const ProfilePage({super.key}); + + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + late final ProfileController _profileController = + Get.isRegistered() + ? Get.find() + : Get.put(ProfileController(), permanent: true); + + final RewardsController _rewardsController = Get.put(RewardsController()); + + String _name = ''; + String _email = ''; + String _contact = ''; + + @override + void initState() { + super.initState(); + _loadProfilePrefs(); + ever(_profileController.userName, (_) => _assignFromController()); + ever(_profileController.userEmail, (_) => _assignFromController()); + ever(_profileController.userContact, (_) => _assignFromController()); + ever(_profileController.userAddress, (_) => _assignFromController()); + _profileController.loadFromPrefs(); + } + + Future _loadProfilePrefs() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _name = prefs.getString('user_name') ?? ''; + _email = prefs.getString('user_email') ?? ''; + _contact = prefs.getString('contactno') ?? ''; + _contact = prefs.getString('contactno') ?? ''; + }); + + final userId = prefs.getInt('userid') ?? 0; + if (userId > 0) { + _rewardsController.fetchBonusSummary(userId); + } + } + + void _assignFromController() { + setState(() { + if (_profileController.userName.value.trim().isNotEmpty) { + _name = _profileController.userName.value.trim(); + } + if (_profileController.userEmail.value.trim().isNotEmpty) { + _email = _profileController.userEmail.value.trim(); + } + if (_profileController.userContact.value.trim().isNotEmpty) { + _contact = _profileController.userContact.value.trim(); + } + }); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + backgroundColor: Colors.grey.shade200, + + appBar: AppBar( + backgroundColor: Colors.grey.shade200, + elevation: 0, + toolbarHeight: 70.h, // ⭐ responsive + title: Padding( + padding: EdgeInsets.only(top: 12.h), + child: Text( + "PROFILE", + style: TextStyle( + fontSize: FontConstants.xxxLarge(context).sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black, + ), + ), + ), + bottom: PreferredSize( + preferredSize: Size.fromHeight(1.h), + child: Divider(height: 1.h, color: Colors.grey), + ), + ), + + body: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Padding( + padding: EdgeInsets.only(bottom: 40.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 20.h), + + /// ⭐ PROFILE CARD RESPONSIVE + Container( + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: ColorConstants.primaryColor, + borderRadius: BorderRadius.circular(20.r), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.person, + color: Colors.white, + size: 22.sp, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + _name.isNotEmpty ? _name : "—", + style: TextStyle( + color: Colors.white, + fontSize: 18.sp, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + SizedBox(height: 12.h), + + Row( + children: [ + Icon( + Icons.phone, + color: Colors.white, + size: 20.sp, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + _contact.isNotEmpty ? _contact : "—", + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + ), + ), + ), + ], + ), + SizedBox(height: 12.h), + + Row( + children: [ + Icon( + Icons.email, + color: Colors.white, + size: 20.sp, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + _email.isNotEmpty ? _email : "—", + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + + /// ⭐ Circle Avatar Responsive + Obx(() { + final path = _profileController.imagePath.value; + final hasImage = + path.isNotEmpty && File(path).existsSync(); + + return CircleAvatar( + radius: 40.r, + backgroundColor: Colors.grey.shade300, + backgroundImage: hasImage + ? FileImage(File(path)) + : null, + child: !hasImage + ? Icon( + Icons.person, + color: Colors.white, + size: 40.sp, + ) + : null, + ); + }), + ], + ), + ), + + SizedBox(height: 20.h), + + // ⭐ REWARDS SECTION + RewardsCard(controller: _rewardsController), + + SizedBox(height: 20.h), + + _buildHeader("Your Information"), + _buildBox([ + _buildInfoTile(Icons.person, "Profile"), + Divider(), + _buildInfoTile(Icons.location_on, "Saved Address"), + Divider(), + _buildInfoTile(Icons.card_giftcard, "Rewards"), + Divider(), + _buildInfoTile(Icons.notifications, "Notification"), + ]), + + SizedBox(height: 20.h), + + _buildHeader("Support"), + _buildBox([ + _buildInfoTile(Icons.support_agent, "Help Centre"), + Divider(), + _buildInfoTile(Icons.local_activity, "Support tickets"), + ]), + + SizedBox(height: 20.h), + + _buildHeader("Other Information"), + _buildBox([ + _buildInfoTile(Icons.translate, "Faq"), + Divider(), + // _buildInfoTile(Icons.sticky_note_2, "Terms & Conditions"), + // Divider(), + _buildInfoTile( + Icons.notifications_active, + "Order alert sound", + ), + ]), + + SizedBox(height: 55.h), + + /// ⭐ Logout Button Responsive + SizedBox( + height: 55.h, + width: double.infinity, + child: OutlinedButton( + onPressed: () => _showLogoutDialog(context), + style: OutlinedButton.styleFrom( + backgroundColor: ColorConstants.secondaryColor, + side: BorderSide(color: Colors.black, width: 0.2.w), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + ), + child: Text( + "Logout", + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + + SizedBox(height: 15.h), + + Center( + child: Text( + "App Version 1.2.19", + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildHeader(String title) { + return Padding( + padding: EdgeInsets.all(8.r), + child: Text( + title, + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black, + ), + ), + ); + } + + Widget _buildBox(List children) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + ), + child: Column(children: children), + ); + } + + Widget _buildInfoTile(IconData icon, String title) { + return ListTile( + leading: Icon(icon, size: 26.sp), + title: Text( + title, + style: TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + ), + ), + trailing: Icon(Icons.arrow_forward_ios, size: 22.sp), + onTap: () => _handleNavigation(title), + ); + } + + void _handleNavigation(String title) { + switch (title) { + case "Profile": + Get.to(() => Profile()); + break; + case "Saved Address": + Get.to(() => const SavedAddressPage()); + break; + case "Notification": + Get.to(() => const NotificationsPage()); + break; + case "Help Centre": + Get.to(() => HelpCenter()); + break; + case "Support tickets": + Get.to(() => SupportTicket()); + break; + case "Rewards": + Get.to(() => const RiderRewardsPage()); + break; + case "Faq": + Get.to(() => const FaqPage()); + break; + // case "Terms & Conditions": + // Get.to(() => const TermsCondition()); + // break; + case "Order alert sound": + Get.to(() => const OrderAlertSoundPage()); + break; + default: + debugPrint("Tapped on $title — no page linked yet."); + } + } +} + +void _showLogoutDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext context) { + return Center( + child: FittedBox( + child: AlertDialog( + backgroundColor: ColorConstants.secondaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20.r), + ), + title: Text( + "Logout", + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + content: Text( + "Are you sure you want to logout?", + style: TextStyle( + fontSize: 19.sp, + fontFamily: FontConstants.fontFamily, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + "No", + style: TextStyle( + fontSize: 19.sp, + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color.fromARGB(255, 153, 121, 167), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + ), + onPressed: () async { + final prefs = await SharedPreferences.getInstance(); + final userId = + prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + if (userId != 0) { + await prefs.remove('skipped_orders_cache_$userId'); + } + NearleMqttService().disconnect(); + prefs.setBool('logged_out', true); + Get.offAll(() => const SignIn()); + }, + child: Text( + "Yes", + style: TextStyle( + fontSize: 19.sp, + color: Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/views/Dashboard/profile/informations/faq.dart b/lib/views/Dashboard/profile/informations/faq.dart new file mode 100644 index 0000000..6161fe5 --- /dev/null +++ b/lib/views/Dashboard/profile/informations/faq.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:get/get.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class FaqController extends GetxController { + WebViewController? webViewController; + var isLoading = true.obs; + + @override + void onInit() { + super.onInit(); + initializeWebView(); + } + + void initializeWebView() { + webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(const Color(0x00000000)) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (url) { + isLoading.value = true; + print('Started loading: $url'); + }, + onPageFinished: (url) { + isLoading.value = false; + print('Finished loading: $url'); + }, + onWebResourceError: (error) { + isLoading.value = false; + print('WebView error: ${error.description}'); + }, + ), + ); + loadFaqUrl(); + } + + Future loadFaqUrl() async { + if (webViewController != null) { + try { + await webViewController!.loadRequest( + Uri.parse('https://nearle.in/faq'), + ); + } catch (e) { + print('Error loading URL: $e'); + } + } + } +} + +class FaqPage extends StatelessWidget { + const FaqPage({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(FaqController()); + return Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + ), // :small_blue_diamond: white back arrow + onPressed: () { + Navigator.pop(context); // goes back to previous screen + }, + ), + title: const Text( + 'FAQ', + style: TextStyle( + fontSize: 26, // :small_blue_diamond: larger font size + color: Colors.white, // :small_blue_diamond: white text + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + elevation: 4, + ), + body: Obx(() { + final wvc = controller.webViewController; + return Stack( + children: [ + if (wvc != null) WebViewWidget(controller: wvc), + if (controller.isLoading.value) + const LinearProgressIndicator(minHeight: 2), + ], + ); + }), + ); + } +} diff --git a/lib/views/Dashboard/profile/informations/help_center.dart b/lib/views/Dashboard/profile/informations/help_center.dart new file mode 100644 index 0000000..3bae952 --- /dev/null +++ b/lib/views/Dashboard/profile/informations/help_center.dart @@ -0,0 +1,380 @@ +import 'package:flutter/material.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +class HelpCenter extends StatelessWidget { + const HelpCenter({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: () { + Navigator.pop(context); + }, + ), + title: const Text( + 'Help Center', + style: TextStyle( + fontSize: 26, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + elevation: 4, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header text + Text( + "We're here to help you with anything and \neverything on Nearle Xpress", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + fontFamily: FontConstants.fontFamily + ), + ), + const SizedBox(height: 8), + Text( + "We make sure your delivery experience is smooth and clear. Whether you’re on your first trip or your hundredth, we’ve got your back. Browse through frequently asked questions or reach out directly if you need further help.", + style: TextStyle(fontSize: 18,color: Colors.grey.shade700, height: 1.4, fontFamily: FontConstants.fontFamily), + ), + const SizedBox(height: 16), + + + TextField( + decoration: InputDecoration( + hintText: 'Search help', + prefixIcon: const Icon(Icons.search), + contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 12), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: ColorConstants.primaryColor, width: 1.5), + ), + ), + ), + + const SizedBox(height: 15), + Text( + 'FAQ', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily,color: ColorConstants.primaryColor), + ), + Divider(), + + + + _FaqTile( + title: 'What is Nearle Xpress?', + initiallyExpanded: true, + child: const Text( + 'Nearle Xpress is a delivery app for riders who complete local deliveries for nearby stores and markets. It helps riders accept tasks, manage pickup and drop points, and update deliveries in real time.', + style: TextStyle(fontSize: 16, color: Colors.black87, height: 1.4), + ), + ), + _FaqTile( + title: 'How do I accept a delivery task?', + child: const Text( + 'You can accept tasks from the Home screen when a new order appears. Tap on the order to view details and then press Accept.', + style: TextStyle(fontSize: 16, color: Colors.black87, height: 1.4), + ), + ), + _FaqTile( + title: 'How do I update the delivery status?', + child: const Text( + 'Open the active task and use the status buttons to mark Pickup, On the way, and Delivered. Ensure accurate updates for better tracking.', + style: TextStyle(fontSize: 16, color: Colors.black87, height: 1.4), + ), + ), + _FaqTile( + title: 'Can I view my past deliveries?', + child: const Text( + 'Yes. Go to the History section from your dashboard to see completed deliveries and earnings.', + style: TextStyle(fontSize: 16, color: Colors.black87, height: 1.4), + ), + ), + _FaqTile( + title: 'What if I face an issue during delivery?', + child: const Text( + 'Use the Help Center to report an issue or contact support. Provide order details and a short description of the problem.', + style: TextStyle(fontSize: 16, color: Colors.black87, height: 1.4), + ), + ), + SizedBox(height: 10,), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Still stuck? Help is a mail away!",style: TextStyle(fontSize: 18,fontFamily: FontConstants.fontFamily,fontWeight: FontWeight.bold,color: ColorConstants.primaryColor),), + ], + ) + + ], + ), + ), + bottomNavigationBar: Padding(padding: EdgeInsets.all(16), + child: SizedBox( + height: 55, + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + foregroundColor: ColorConstants.primaryColor, + side: BorderSide(color: ColorConstants.primaryColor, width: 1.2), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const HelpCenterMessage(), + ), + ); + }, + child: Text( + 'Send a message', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold,fontFamily: FontConstants.fontFamily,color: Colors.white), + ), + ), + ),), + ), + ); + } +} + +class _FaqTile extends StatelessWidget { + final String title; + final Widget child; + final bool initiallyExpanded; + + const _FaqTile({ + required this.title, + required this.child, + this.initiallyExpanded = false, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + initiallyExpanded: initiallyExpanded, + tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + collapsedShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text( + title, + style: TextStyle(fontWeight: FontWeight.w600,fontSize: 18,fontFamily: FontConstants.fontFamily), + ), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: child, + ), + ], + ), + ), + ); + } +} +// -------------------------------message page------------------------------------ +class HelpCenterMessage extends StatefulWidget { + const HelpCenterMessage({super.key}); + + @override + State createState() => _HelpCenterMessageState(); +} + +class _HelpCenterMessageState extends State { + final _subjectController = TextEditingController(); + final _messageController = TextEditingController(); + final _formKey = GlobalKey(); + + @override + void dispose() { + _subjectController.dispose(); + _messageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 80, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + 'Help Centre', + style: TextStyle( + fontSize: 26, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + elevation: 4, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Heading + Text( + 'Send Us a Message', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 8), + Text( + "Not finding what you're looking for in the FAQs? Don't worry—we're here to help!", + style: TextStyle( + fontSize: 18, + color: Colors.grey.shade700, + height: 1.4, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 20), + + // Subject label + Text( + 'Subject', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _subjectController, + decoration: InputDecoration( + hintText: 'Type Something', + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: ColorConstants.primaryColor, width: 1.5), + ), + ), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Please enter a subject' : null, + ), + + const SizedBox(height: 18), + + // Message label + Text( + 'Your Message', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _messageController, + minLines: 5, + maxLines: 8, + decoration: InputDecoration( + hintText: 'Type Something', + filled: true, + fillColor: Colors.white, + alignLabelWithHint: true, + contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: ColorConstants.primaryColor, width: 1.5), + ), + ), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Please enter your message' : null, + ), + SizedBox(height: 30,), + SizedBox( + height: 55, + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Message sent')), + ); + Navigator.pop(context); + } + }, + child: Text( + 'Send a message', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + ), + ), + + ), + ); + } +} diff --git a/lib/views/Dashboard/profile/informations/notifications_page.dart b/lib/views/Dashboard/profile/informations/notifications_page.dart new file mode 100644 index 0000000..9018ef0 --- /dev/null +++ b/lib/views/Dashboard/profile/informations/notifications_page.dart @@ -0,0 +1,176 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; + +class NotificationsPage extends StatefulWidget { + const NotificationsPage({super.key}); + + @override + State createState() => _NotificationsPageState(); +} + +class _NotificationsPageState extends State { + List> _items = const []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString('notifications_log'); + List> parsed = []; + if (raw != null && raw.isNotEmpty) { + try { + final list = jsonDecode(raw) as List; + parsed = list.map((e) => (e as Map).map((k, v) => MapEntry(k.toString(), v))).toList(); + } catch (_) {} + } + if (!mounted) return; + setState(() { + _items = parsed; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, // increases AppBar height + elevation: 4, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), // white back arrow + onPressed: () { + Navigator.pop(context); + }, + ), + title: Text( + 'Notifications', + style: const TextStyle( + fontSize: 26, // larger font size + color: Colors.white, // white text + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ).copyWith(fontFamily: FontConstants.fontFamily), // keep your font + ), + actions: [ + IconButton( + icon: const Icon(Icons.delete_sweep, color: Colors.white), // white icon + onPressed: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('notifications_log'); + if (!mounted) return; + setState(() => _items = const []); + // ignore: use_build_context_synchronously + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Notifications cleared')), + ); + }, + ), + ], +), + + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _items.isEmpty + ? Center(child: Text('No notifications yet',style: TextStyle(fontSize: 20,fontFamily: FontConstants.fontFamily),)) + : RefreshIndicator( + onRefresh: _load, + child: ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final it = _items[index]; + final title = (it['title'] ?? 'Nearle').toString(); + final body = (it['body'] ?? '').toString(); + final time = (it['time'] ?? '').toString(); + final imageUrl = (it['imageUrl'] ?? '').toString(); + final imagePath = (it['imagePath'] ?? '').toString(); + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 1.5, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.notifications_active, color: Colors.purple), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + if (time.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + time, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + ], + ), + ), + ], + ), + if (body.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + body, + style: TextStyle(fontFamily: FontConstants.fontFamily, fontSize: 14), + ), + ), + if (imagePath.isNotEmpty || imageUrl.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 10), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: imagePath.isNotEmpty + ? Image.file( + File(imagePath), + height: 170, + width: double.infinity, + fit: BoxFit.cover, + ) + : Image.network( + imageUrl, + height: 170, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} + + diff --git a/lib/views/Dashboard/profile/informations/order_alert_sound.dart b/lib/views/Dashboard/profile/informations/order_alert_sound.dart new file mode 100644 index 0000000..79d6d60 --- /dev/null +++ b/lib/views/Dashboard/profile/informations/order_alert_sound.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; + +class OrderAlertSoundPage extends StatefulWidget { + const OrderAlertSoundPage({super.key}); + + @override + State createState() => _OrderAlertSoundPageState(); +} + +class _OrderAlertSoundPageState extends State { + static const String _prefsKey = 'order_alert_sound'; + static const String _defaultSound = 'assets/audio/alert-1.mp3'; + + final AudioPlayer _player = AudioPlayer(); + String _selected = _defaultSound; + bool _loading = true; + + // Available sounds from assets/audio/ folder + final List<_SoundOption> _options = const [ + _SoundOption( + label: 'Alert 1 (Default)', + assetPath: 'assets/audio/alert-1.mp3', + ), + _SoundOption(label: 'Alert 2', assetPath: 'assets/audio/alert-2.mp3'), + _SoundOption(label: 'Alert 3', assetPath: 'assets/audio/alert-3.mp3'), + _SoundOption(label: 'Alert 4', assetPath: 'assets/audio/alert-4.mp3'), + _SoundOption(label: 'Alert 5', assetPath: 'assets/audio/alert-5.mp3'), + _SoundOption(label: 'Alert 6', assetPath: 'assets/audio/alert-6.mp3'), + _SoundOption(label: 'Alert 7', assetPath: 'assets/audio/alert-7.mp3'), + _SoundOption(label: 'Alert 8', assetPath: 'assets/audio/alert-8.mp3'), + _SoundOption(label: 'Alert 9', assetPath: 'assets/audio/alert-9.mp3'), + _SoundOption(label: 'Alert 10', assetPath: 'assets/audio/alert-10.mp3'), + ]; + + @override + void initState() { + super.initState(); + _loadSelection(); + } + + Future _loadSelection() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_prefsKey); + setState(() { + _selected = (saved != null && saved.isNotEmpty) ? saved : _defaultSound; + _loading = false; + }); + } + + Future _saveSelection(BuildContext context, String assetPath) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, assetPath); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Order alert sound updated'), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 2), + ), + ); + } + + Future _preview(BuildContext context, String assetPath) async { + try { + await _player.stop(); + await _player.play(AssetSource(assetPath.replaceFirst('assets/', ''))); + // Note: AssetSource expects relative to assets/ root; hence replaceFirst + } catch (_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Preview unavailable for: $assetPath'), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + duration: const Duration(seconds: 2), + ), + ); + } + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, // :small_blue_diamond: increases app bar height + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + ), // :small_blue_diamond: white back arrow + onPressed: () { + Navigator.pop(context); // goes back to previous screen + }, + ), + title: const Text( + 'Orders alert Sound', + style: TextStyle( + fontSize: 26, // :small_blue_diamond: larger font size + color: Colors.white, // :small_blue_diamond: white text + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + elevation: 4, + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + color: Colors.grey.shade200, + child: Row( + children: [ + const Icon(Icons.volume_up, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Current: ${_options.firstWhereOrNull((o) => o.assetPath == _selected)?.label ?? 'Unknown'}', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const Text( + 'Tap a sound to select', + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.separated( + itemCount: _options.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final opt = _options[index]; + final isSelected = _selected == opt.assetPath; + return ListTile( + title: Text( + opt.label, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + ), + ), + leading: Radio( + value: opt.assetPath, + groupValue: _selected, + onChanged: (value) { + if (value == null) return; + setState(() => _selected = value); + _saveSelection(context, value); + }, + ), + trailing: IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () => _preview(context, opt.assetPath), + ), + onTap: () { + setState(() => _selected = opt.assetPath); + _saveSelection(context, opt.assetPath); + }, + subtitle: isSelected + ? const Text( + 'Selected', + style: TextStyle(fontSize: 12), + ) + : null, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _SoundOption { + final String label; + final String assetPath; + const _SoundOption({required this.label, required this.assetPath}); +} diff --git a/lib/views/Dashboard/profile/informations/profile.dart b/lib/views/Dashboard/profile/informations/profile.dart new file mode 100644 index 0000000..281ef01 --- /dev/null +++ b/lib/views/Dashboard/profile/informations/profile.dart @@ -0,0 +1,280 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:nearle/controllers/profile_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:nearle/views/helpers/constants/Font_constant.dart'; + +class Profile extends StatefulWidget { + const Profile({super.key}); + + @override + State createState() => _ProfileState(); +} + +class _ProfileState extends State { + String _name = ''; + String _email = ''; + String _contact = ''; + String _address = ''; + final List _workers = []; + late final ProfileController _profileController = + Get.isRegistered() + ? Get.find() + : Get.put(ProfileController(), permanent: true); + + @override + void initState() { + super.initState(); + _loadProfile(); + // Keep in sync with controller + _workers.addAll([ + ever(_profileController.userName, (_) => _assignFromController()), + ever(_profileController.userEmail, (_) => _assignFromController()), + ever(_profileController.userContact, (_) => _assignFromController()), + ever(_profileController.userAddress, (_) => _assignFromController()), + ]); + _profileController.loadFromPrefs(); + } + + @override + void dispose() { + for (final worker in _workers) { + worker.dispose(); + } + super.dispose(); + } + + Future _loadProfile() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _name = prefs.getString('user_name') ?? ''; + _email = prefs.getString('user_email') ?? ''; + _contact = prefs.getString('contactno') ?? ''; + _address = prefs.getString('user_address') ?? ''; + }); + debugPrint('[PROFILE_DETAILS] Loaded - Name: "$_name", Email: "$_email", Contact: "$_contact"'); + } + + void _assignFromController() { + setState(() { + if (_profileController.userName.value.trim().isNotEmpty) { + _name = _profileController.userName.value.trim(); + } + if (_profileController.userEmail.value.trim().isNotEmpty) { + _email = _profileController.userEmail.value.trim(); + } + if (_profileController.userContact.value.trim().isNotEmpty) { + _contact = _profileController.userContact.value.trim(); + } + if (_profileController.userAddress.value.trim().isNotEmpty) { + _address = _profileController.userAddress.value.trim(); + } + }); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final width = size.width; + // ignore: unused_local_variable + final height = size.height; + return Scaffold( + backgroundColor: Colors.grey.shade200, + body: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.all(width * 0.04), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Profile image + Center( + child: Stack( + children: [ + // White border circle + Container( + padding: const EdgeInsets.all(4), // border thickness + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, // white border + ), + child: CircleAvatar( + radius: 60, + backgroundColor: Colors.grey.shade400, + child: const Icon( + Icons.person, + size: 60, + color: Colors.white, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 40), + + // Name + _buildLabel("Enter name", required: true), + _buildTextField( + hintText: _name.isNotEmpty ? _name : "EX: Vijayan", + value: _name.isNotEmpty ? _name : null, + readOnly: true, + disabled: true, + ), + + const SizedBox(height: 16), + + // Contact No + _buildLabel("Contact no", required: true), + _buildTextField( + hintText: _contact.isNotEmpty + ? "+91 $_contact" + : "EX: +91 8838304677", + value: _contact.isNotEmpty ? "+91 $_contact" : null, + keyboardType: TextInputType.phone, + readOnly: true, + disabled: true, + ), + + const SizedBox(height: 16), + + // Email Id + _buildLabel("Email Id"), + _buildTextField( + hintText: _email.isNotEmpty ? _email : "EX: gmail@gmail.com", + value: _email.isNotEmpty ? _email : null, + keyboardType: TextInputType.emailAddress, + readOnly: true, + disabled: true, + ), + + const SizedBox(height: 16), + + // Location + _buildLabel("Address"), + _buildTextField(hintText: _address.isNotEmpty ? _address : " EX: R.s puram", value: _address.isNotEmpty ? _address : null, readOnly: true, disabled: true), + + const SizedBox(height: 40), + ], + ), + ), + ), + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: EdgeInsets.all(width * 0.04), + child: SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + onPressed: _handleBackNavigation, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF5C1D8D), // Purple button color + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + "Back", + style: TextStyle( + fontSize: 21, + color: Colors.white, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ), + ), + ); + } + + Future _handleBackNavigation() async { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + return; + } + final rootNavigator = Get.key.currentState; + if (rootNavigator != null && rootNavigator.canPop()) { + rootNavigator.pop(); + return; + } + if (Get.isOverlaysOpen) { + Get.back(closeOverlays: true); + return; + } + Get.back(); + } + + // Text label widget + Widget _buildLabel(String text, {bool required = false}) { + return Align( + alignment: Alignment.centerLeft, + child: RichText( + text: TextSpan( + text: text, + style: TextStyle( + fontSize: 20, + color: Colors.black, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + children: required + ? const [ + TextSpan( + text: " *", + style: TextStyle(color: Colors.red), + ), + ] + : [], + ), + ), + ); + } + + // Reusable TextField + Widget _buildTextField({ + required String hintText, + String? value, + TextInputType keyboardType = TextInputType.text, + required bool readOnly, + bool disabled = false, + }) { + return Container( + margin: const EdgeInsets.only(top: 6), + child: SizedBox( + height: 55, + width: 350, + child: TextFormField( + keyboardType: keyboardType, + readOnly: readOnly, + enabled: !disabled, + enableInteractiveSelection: false, + initialValue: value, + decoration: InputDecoration( + hintText: hintText, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/profile/informations/rider_rewards_page.dart b/lib/views/Dashboard/profile/informations/rider_rewards_page.dart new file mode 100644 index 0000000..9b99f4a --- /dev/null +++ b/lib/views/Dashboard/profile/informations/rider_rewards_page.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:nearle/controllers/rewards_controller.dart'; +import 'package:nearle/views/Dashboard/profile/rewards_card.dart'; + +class RiderRewardsPage extends StatelessWidget { + const RiderRewardsPage({super.key}); + + @override + Widget build(BuildContext context) { + final RewardsController rewardsController = Get.isRegistered() + ? Get.find() + : Get.put(RewardsController()); + + return Scaffold( + backgroundColor: Colors.grey.shade100, + appBar: AppBar( + title: Text( + "REWARDS", + style: TextStyle( + color: Colors.black, + fontSize: FontConstants.xxxLarge(context).sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + backgroundColor: Colors.grey.shade100, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => Navigator.pop(context), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + child: RewardsCard( + controller: rewardsController, + showFullDetails: true, + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/profile/informations/saved_address.dart b/lib/views/Dashboard/profile/informations/saved_address.dart new file mode 100644 index 0000000..d9b04b4 --- /dev/null +++ b/lib/views/Dashboard/profile/informations/saved_address.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; + +class SavedAddressPage extends StatefulWidget { + const SavedAddressPage({super.key}); + + @override + State createState() => _SavedAddressPageState(); +} + +class _SavedAddressPageState extends State { + final TextEditingController _addressController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadAddress(); + } + + Future _loadAddress() async { + final prefs = await SharedPreferences.getInstance(); + final address = (prefs.getString('user_address') ?? '').trim(); + _addressController.text = address; + setState(() {}); + } + + @override + void dispose() { + _addressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final addressText = _addressController.text.trim(); + + return Scaffold( + backgroundColor: const Color(0xFFF8F9FB), + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, + elevation: 3, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + 'Saved Address', + style: TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.1, + ), + ), + ), + + body: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: size.width * 0.05, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 🏠 Header Section + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.15), + spreadRadius: 1, + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + color: ColorConstants.primaryColor.withOpacity(0.1), + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(10), + child: Icon( + Icons.location_on_rounded, + color: ColorConstants.primaryColor, + size: 26, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Current Address', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + Text( + addressText.isNotEmpty ? addressText : 'No address saved yet.', + style: TextStyle( + fontSize: 15, + fontFamily: FontConstants.fontFamily, + color: Colors.grey.shade700, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 28), + + // ✨ Info Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: ColorConstants.primaryColor.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline_rounded, + color: ColorConstants.primaryColor, size: 24), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Your saved address is used for deliveries, pickups, and nearby service accuracy.', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade800, + fontFamily: FontConstants.fontFamily, + height: 1.4, + ), + ), + ), + ], + ), + ), + + const Spacer(), + + + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/profile/informations/support_ticket.dart b/lib/views/Dashboard/profile/informations/support_ticket.dart new file mode 100644 index 0000000..4f8d05d --- /dev/null +++ b/lib/views/Dashboard/profile/informations/support_ticket.dart @@ -0,0 +1,503 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:nearle/controllers/support_ticket.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; + +class SupportTicket extends StatefulWidget { + const SupportTicket({super.key}); + + @override + State createState() => _SupportTicketState(); +} + +class _SupportTicketState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + // Form + final _formKey = GlobalKey(); + final _subjectCtrl = TextEditingController(); + final _messageCtrl = TextEditingController(); + String _category = 'Account'; + String _priority = 'Medium'; + int _attachmentCount = 0; + + // Image + final ImagePicker _picker = ImagePicker(); + final List _attachments = []; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(() => setState(() {})); + Get.put(SupportTicketController()); + } + + @override + void dispose() { + _subjectCtrl.dispose(); + _messageCtrl.dispose(); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Support Ticket', + style: TextStyle( + fontSize: 26, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + fontFamily: FontConstants.fontFamily, + ), + ), + elevation: 4, + ), + body: Column( + children: [ + Container( + color: Colors.white, + child: TabBar( + controller: _tabController, + indicatorColor: ColorConstants.primaryColor, + labelColor: ColorConstants.primaryColor, + unselectedLabelColor: Colors.grey, + labelStyle: TextStyle( + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + tabs: [ + Tab( + child: Text( + 'Create Tickets', + style: TextStyle(fontFamily: FontConstants.fontFamily, fontSize: 20), + ), + ), + Tab( + child: Text( + 'My Tickets', + style: TextStyle(fontFamily: FontConstants.fontFamily, fontSize: 20), + ), + ), + ], + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [_buildCreateForm(), _buildTicketsList()], + ), + ), + ], + ), + bottomNavigationBar: _tabController.index == 0 + ? Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + height: 55, + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: _submitTicket, + child: Text( + 'Submit Ticket', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ) + : null, + ), + ); + } + + // =============================================== + // CREATE FORM + // =============================================== + Widget _buildCreateForm() { + final controller = Get.find(); + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Create a new support ticket', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily), + ), + const SizedBox(height: 8), + Text( + 'Tell us what went wrong. We\'ll get back to you as soon as possible.', + style: TextStyle(fontSize: 18, color: Colors.grey.shade700, height: 1.4, fontFamily: FontConstants.fontFamily), + ), + const SizedBox(height: 20), + + // Category + Text('Category', style: _labelStyle()), + const SizedBox(height: 8), + DropdownButtonFormField( + // ignore: deprecated_member_use + value: _category, + items: ['Account', 'Orders', 'Payments', 'App issue', 'Other'] + .map((e) => DropdownMenuItem(value: e, child: Text(e, style: TextStyle(fontFamily: FontConstants.fontFamily)))) + .toList(), + onChanged: (v) => setState(() => _category = v ?? _category), + decoration: _inputDecoration(), + ), + + const SizedBox(height: 16), + + // Priority + Text('Priority', style: _labelStyle()), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: ['Low', 'Medium', 'High'].map((p) { + final selected = _priority == p; + return ChoiceChip( + label: Text(p, style: TextStyle(fontFamily: FontConstants.fontFamily, fontWeight: FontWeight.w600, fontSize: 16)), + selected: selected, + selectedColor: ColorConstants.primaryColor.withOpacity(0.15), + labelStyle: TextStyle( + color: selected ? ColorConstants.primaryColor : Colors.black87, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + onSelected: (_) => setState(() => _priority = p), + ); + }).toList(), + ), + + const SizedBox(height: 16), + + // Subject + Text('Subject', style: _labelStyle()), + const SizedBox(height: 8), + TextFormField( + controller: _subjectCtrl, + decoration: _inputDecoration(hint: 'Type Something'), + style: TextStyle(fontFamily: FontConstants.fontFamily), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Please enter a subject' : null, + ), + + const SizedBox(height: 16), + + // Message + Text('Describe the issue', style: _labelStyle()), + const SizedBox(height: 8), + TextFormField( + controller: _messageCtrl, + minLines: 5, + maxLines: 8, + decoration: _inputDecoration(hint: 'Type Something'), + style: TextStyle(fontFamily: FontConstants.fontFamily), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Please enter your message' : null, + ), + + const SizedBox(height: 16), + + // Attachments + Row( + children: [ + OutlinedButton.icon( + onPressed: _addAttachment, + icon: const Icon(Icons.attach_file), + label: Text('Add screenshot', style: TextStyle(fontFamily: FontConstants.fontFamily)), + ), + const SizedBox(width: 12), + if (_attachmentCount > 0) + Text('$_attachmentCount attached', style: TextStyle(fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily)), + ], + ), + const SizedBox(height: 8), + if (_attachments.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: _attachments.asMap().entries.map((entry) { + final idx = entry.key; + final file = entry.value; + return Stack( + clipBehavior: Clip.none, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file(File(file.path), width: 80, height: 80, fit: BoxFit.cover), + ), + Positioned( + top: -8, + right: -8, + child: InkWell( + onTap: () => setState(() { + _attachments.removeAt(idx); + _attachmentCount = _attachments.length; + }), + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle), + child: const Icon(Icons.close, size: 16, color: Colors.white), + ), + ), + ), + ], + ); + }).toList(), + ), + + // Submit loading + Obx(() => controller.isSubmitting.value + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink()), + ], + ), + ), + ); + } + + // =============================================== + // MY TICKETS LIST + // =============================================== + Widget _buildTicketsList() { + final controller = Get.find(); + + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.errorMessage.value.isNotEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 12), + Text('Failed to load tickets', style: TextStyle(fontFamily: FontConstants.fontFamily, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Text(controller.errorMessage.value, textAlign: TextAlign.center, style: TextStyle(color: Colors.grey.shade600, fontFamily: FontConstants.fontFamily)), + const SizedBox(height: 16), + ElevatedButton(onPressed: controller.fetchTickets, child: const Text('Retry')), + ], + ), + ), + ); + } + + if (controller.tickets.isEmpty) { + return _buildEmptyState(); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + itemCount: controller.tickets.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, i) { + final t = controller.tickets[i]; + final statusColor = _getPriorityColor(t.priority); + + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + child: ListTile( + contentPadding: const EdgeInsets.all(12), + title: Text(t.subject, style: TextStyle(fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text('Category: ${t.category} • Priority: ${t.priority}', style: TextStyle(fontFamily: FontConstants.fontFamily)), + const SizedBox(height: 4), + Text('Created: ${_formatDate(t.created)}', style: TextStyle(fontFamily: FontConstants.fontFamily)), + ], + ), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration(color: statusColor.withOpacity(0.15), borderRadius: BorderRadius.circular(20)), + child: Text(t.priority, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily)), + ), + ), + ); + }, + ); + }); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.support_agent, size: 48, color: Colors.grey), + const SizedBox(height: 12), + Text('No tickets yet', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily)), + const SizedBox(height: 8), + Text('Create your first ticket from the Create tab.', style: TextStyle(color: Colors.grey.shade700, fontFamily: FontConstants.fontFamily)), + ], + ), + ), + ); + } + + // =============================================== + // HELPERS + // =============================================== + TextStyle _labelStyle() => TextStyle(fontSize: 20, fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily); + + InputDecoration _inputDecoration({String? hint}) { + return InputDecoration( + hintText: hint, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.grey.shade300)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.grey.shade300)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: ColorConstants.primaryColor, width: 1.5)), + hintStyle: TextStyle(fontFamily: FontConstants.fontFamily), + ); + } + + Color _getPriorityColor(String priority) { + return switch (priority.toLowerCase()) { + 'high' => Colors.red, + 'medium' => Colors.orange, + 'low' => Colors.green, + _ => Colors.grey, + }; + } + + String _formatDate(DateTime date) { + return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute.toString().padLeft(2, '0')}'; + } + + // =============================================== + // IMAGE PICKER + // =============================================== + Future _addAttachment() async { + final source = await showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_library), + title: Text('Gallery', style: TextStyle(fontFamily: FontConstants.fontFamily)), + onTap: () => Navigator.pop(ctx, ImageSource.gallery), + ), + ListTile( + leading: const Icon(Icons.camera_alt), + title: Text('Camera', style: TextStyle(fontFamily: FontConstants.fontFamily)), + onTap: () => Navigator.pop(ctx, ImageSource.camera), + ), + ], + ), + ), + ); + + if (source == null) return; + + try { + if (source == ImageSource.gallery) { + final multi = await _picker.pickMultiImage(imageQuality: 85); + if (multi.isNotEmpty) { + setState(() => _attachments.addAll(multi)); + } else { + final one = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 85); + if (one != null) setState(() => _attachments.add(one)); + } + } else { + final captured = await _picker.pickImage(source: ImageSource.camera, imageQuality: 85); + if (captured != null) setState(() => _attachments.add(captured)); + } + } catch (_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to pick image', style: TextStyle(fontFamily: FontConstants.fontFamily))), + ); + } + + setState(() => _attachmentCount = _attachments.length); + } + + // =============================================== + // SUBMIT TICKET + // =============================================== + Future _submitTicket() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + final controller = Get.find(); + final success = await controller.createTicket( + userid: 1242, + category: _category, + priority: _priority, + subject: _subjectCtrl.text.trim(), + issue: _messageCtrl.text.trim(), + attachments: _attachments.isEmpty ? null : _attachments, + ); + + if (success) { + _subjectCtrl.clear(); + _messageCtrl.clear(); + _attachments.clear(); + _attachmentCount = 0; + setState(() {}); + + _tabController.animateTo(1); + + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Ticket Submitted!'), + content: const Text('Your ticket has been created and saved. Our team will get back to you soon.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK')), + ], + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to submit ticket: ${controller.errorMessage.value}'), + backgroundColor: Colors.red, + ), + ); + } + } + } \ No newline at end of file diff --git a/lib/views/Dashboard/profile/informations/terms_condition.dart b/lib/views/Dashboard/profile/informations/terms_condition.dart new file mode 100644 index 0000000..f8ce97e --- /dev/null +++ b/lib/views/Dashboard/profile/informations/terms_condition.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:get/get.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +// ===== Controller ===== +class TermsController extends GetxController { + WebViewController? webViewController; + var isLoading = true.obs; + + @override + void onInit() { + super.onInit(); + initializeWebView(); + } + + void initializeWebView() { + webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(const Color(0x00000000)) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (url) { + isLoading.value = true; + print('Started loading: $url'); + }, + onPageFinished: (url) { + isLoading.value = false; + print('Finished loading: $url'); + }, + onWebResourceError: (error) { + isLoading.value = false; + print('WebView error: ${error.description}'); + }, + ), + ); + loadTermsUrl(); + } + + Future loadTermsUrl() async { + if (webViewController != null) { + try { + await webViewController!.loadRequest( + Uri.parse('https://nearle.in/terms'), + ); + } catch (e) { + print('Error loading URL: $e'); + } + } + } +} + +// ===== Page ===== +class TermsCondition extends StatelessWidget { + const TermsCondition({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(TermsController()); + + return Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.primaryColor, + centerTitle: true, + toolbarHeight: 70, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + ), + onPressed: () { + Navigator.pop(context); + }, + ), + title: const Text( + 'Terms & Conditions', + style: TextStyle( + fontSize: 26, + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + elevation: 4, + ), + body: SafeArea( + child: Obx(() { + final wvc = controller.webViewController; + return Stack( + children: [ + if (wvc != null) WebViewWidget(controller: wvc), + if (controller.isLoading.value) + const LinearProgressIndicator(minHeight: 2), + ], + ); + }), + ), + ); + } +} diff --git a/lib/views/Dashboard/profile/rewards_card.dart b/lib/views/Dashboard/profile/rewards_card.dart new file mode 100644 index 0000000..a5c018f --- /dev/null +++ b/lib/views/Dashboard/profile/rewards_card.dart @@ -0,0 +1,474 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:nearle/controllers/rewards_controller.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:nearle/views/Dashboard/profile/informations/rider_rewards_page.dart'; + +class RewardsCard extends StatelessWidget { + final RewardsController controller; + final bool showFullDetails; + + const RewardsCard({ + super.key, + required this.controller, + this.showFullDetails = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Original Rewards Card (The Gradient Card) + GestureDetector( + onTap: () { + if (!showFullDetails) { + Get.to(() => const RiderRewardsPage()); + } + }, + child: Container( + width: double.infinity, + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.r), + gradient: const LinearGradient( + colors: [ + Color(0xFF2C3E50), // Dark blue/grey + Color(0xFF4CA1AF), // Tealish + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Nearle Rewards", + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 4.h), + Text( + "Ride more to get more rewards 🚴", + style: TextStyle( + fontSize: 14.sp, + color: Colors.white70, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20.r), + border: Border.all(color: Colors.white30, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.stars_rounded, // Coin-like icon + color: Colors.amberAccent, + size: 24.sp, + ), + SizedBox(width: 8.w), + Obx(() { + return Text( + controller.isLoading.value + ? "..." + : "${controller.totalPoints.value}", + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.bold, + color: Colors.amberAccent, + fontFamily: FontConstants.fontFamily, + ), + ); + }), + ], + ), + ), + ], + ), + SizedBox(height: 16.h), + Container( + padding: EdgeInsets.all(12.r), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12.r), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Keep riding correctly to get more points!", + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 4.h), + Text( + "Earn 100 points to unlock new rewards", + style: TextStyle( + fontSize: 14.sp, + color: Colors.white70, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + Icon( + Icons.emoji_events, + color: Colors.amber, + size: 32.sp, + ), + ], + ), + ), + ], + ), + ), + ), + + if (showFullDetails) ...[ + SizedBox(height: 24.h), + + // 2. Surprise Gift Section + _buildSurpriseGiftCard(), + + SizedBox(height: 24.h), + + // 3. The 4 Cards Section + Text( + "Redeem Your Points", + style: TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + SizedBox(height: 16.h), + + // Card 1: Data Recharge + _buildRewardOptionCard( + title: "Data Recharge", + subtitle: "Get free data for 1 month", + points: "300 Points", + icon: Icons.wifi, + color1: const Color(0xFF11998e), + color2: const Color(0xFF38ef7d), + ), + SizedBox(height: 16.h), + + // Card 2: Bonus Fuel + _buildRewardOptionCard( + title: "Bonus Fuel", + subtitle: "Fuel support for your vehicle", + points: "600 Points", + icon: Icons.local_gas_station, + color1: const Color(0xFFFF5F6D), + color2: const Color(0xFFFFC371), + ), + SizedBox(height: 16.h), + + // Card 3: Gadgets + _buildRewardOptionCard( + title: "Gadgets Support", + subtitle: "Powerbank or New Mobile support", + points: "1000 - 1500 Points", + icon: Icons.devices_other, + color1: const Color(0xFF2193b0), + color2: const Color(0xFF6dd5ed), + ), + SizedBox(height: 16.h), + + // Card 4: Vehicle Support + _buildRewardOptionCard( + title: "Vehicle Support", + subtitle: "New vehicle or 50% loan support", + points: "2000 Bonus Points", + description: "Earn 2000 bonus without any loses in bonus point", + icon: Icons.motorcycle, + color1: const Color(0xFF8E2DE2), + color2: const Color(0xFF4A00E0), + isPremium: true, + ), + + SizedBox(height: 30.h), + + // 4. Bottom Warning / Info Section + Container( + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: Colors.red.shade700, size: 24.sp), + SizedBox(width: 12.w), + Expanded( + child: Text( + "Note: If you miss any deliveries or if requirements are not met for each delivery, negative bonus points will affect your board.", + style: TextStyle( + fontSize: 15.sp, + color: Colors.red.shade900, + fontFamily: FontConstants.fontFamily, + height: 1.4, + ), + ), + ), + ], + ), + ), + ], + ], + ); + } + + Widget _buildSurpriseGiftCard() { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16.r), + boxShadow: [ + BoxShadow( + color: Colors.purple.withOpacity(0.1), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Stack( + children: [ + Positioned( + right: -20, + top: -20, + child: Icon( + Icons.card_giftcard, + size: 100.sp, + color: Colors.purple.withOpacity(0.05), + ), + ), + Padding( + padding: EdgeInsets.all(20.r), + child: Row( + children: [ + Container( + padding: EdgeInsets.all(12.r), + decoration: BoxDecoration( + color: Colors.purple.shade50, + borderRadius: BorderRadius.circular(12.r), + ), + child: Icon(Icons.card_giftcard, color: Colors.purple, size: 30.sp), + ), + SizedBox(width: 16.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Monthly Surprise Gift", + style: TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + SizedBox(height: 4.h), + Text( + "If you didn't skip any orders in 1 month!", + style: TextStyle( + fontSize: 14.sp, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildRewardOptionCard({ + required String title, + required String subtitle, + required String points, + required IconData icon, + required Color color1, + required Color color2, + String? description, + bool isPremium = false, + }) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.r), + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20.r), + child: Stack( + children: [ + // Decorative Background Circle + Positioned( + right: -30, + top: -30, + child: Container( + width: 120.w, + height: 120.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [color1.withOpacity(0.2), color2.withOpacity(0.0)], + begin: Alignment.bottomLeft, + end: Alignment.topRight, + ), + ), + ), + ), + + Padding( + padding: EdgeInsets.all(20.r), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: EdgeInsets.all(10.r), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [color1, color2], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + ), + child: Icon(icon, color: Colors.white, size: 24.sp), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + decoration: BoxDecoration( + color: Colors.amber.shade50, + borderRadius: BorderRadius.circular(20.r), + border: Border.all(color: Colors.amber.shade200), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + points, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.bold, + color: Colors.amber.shade900, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + SizedBox(height: 16.h), + Text( + title, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + SizedBox(height: 4.h), + Text( + subtitle, + style: TextStyle( + fontSize: 15.sp, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + if (description != null) ...[ + SizedBox(height: 12.h), + Container( + padding: EdgeInsets.all(10.r), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + children: [ + Icon(Icons.star_outline, size: 16.sp, color: Colors.blueGrey), + SizedBox(width: 6.w), + Expanded( + child: Text( + description, + style: TextStyle( + fontSize: 14.sp, + color: Colors.black87, + fontStyle: FontStyle.italic, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ) + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/summary/summary.dart b/lib/views/Dashboard/summary/summary.dart new file mode 100644 index 0000000..e9ef6af --- /dev/null +++ b/lib/views/Dashboard/summary/summary.dart @@ -0,0 +1,587 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:nearle/Models/summary/riderweeklykms.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:nearle/controllers/summary_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class Summary extends StatefulWidget { + const Summary({super.key}); + + @override + State createState() => _SummaryState(); +} + +class _SummaryState extends State { + final SummaryController controller = Get.put(SummaryController()); + int _userId = 0; + int _refreshTick = 0; + + @override + void initState() { + super.initState(); + _refreshData(); + } + + Future _refreshData() async { + try { + final prefs = await SharedPreferences.getInstance(); + final uid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + + if (mounted) { + setState(() { + _userId = uid; + }); + } + + if (uid > 0) { + await controller.fetchSummaryStats(uid); + } + } catch (e) { + debugPrint("❌ Error fetching summary: $e"); + } finally { + if (mounted) { + setState(() { + _refreshTick++; + }); + } + } + } + + // ------------------------ + // RESPONSIVE CARD + // ------------------------ + Widget _buildCard({ + required String title, + required String value, + required String imagePath, + bool isCancelled = false, + }) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isCancelled + ? const Color(0xFFFF5C5C) + : const Color.fromARGB(255, 159, 139, 163), + width: 1.2.w, + ), + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Image.asset( + imagePath, + height: 36.h, + width: 36.w, + color: isCancelled + ? const Color(0xFFFF5C5C) + : const Color(0xFF9C27B0), + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + title, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: Colors.grey.shade700, + fontSize: 20.sp, + fontWeight: FontWeight.w400, + ), + ), + ), + ], + ), + SizedBox(height: 30.h), + Text( + value, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 29.sp, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + ], + ), + ), + ); + } + + // ------------------------ + // RESPONSIVE CANCELLED CARD + // ------------------------ + Widget _buildCancelledCard(String value) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: const Color.fromARGB(255, 232, 167, 167), + width: 1.3.w, + ), + borderRadius: BorderRadius.circular(10.r), + ), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), + child: Row( + children: [ + Image.asset( + 'assets/images/cancel.png', + height: 36.h, + width: 36.w, + color: const Color(0xFFFF5C5C), + ), + SizedBox(width: 15.w), + Expanded( + child: Text( + 'Cancelled Orders', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: Colors.grey.shade700, + fontSize: 20.sp, + fontWeight: FontWeight.w400, + ), + ), + ), + Text( + value, + style: TextStyle( + fontFamily: FontConstants.fontFamily, + fontSize: 34.sp, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + ], + ), + ); + } + + // ------------------------ + // MAIN UI + // ------------------------ + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + backgroundColor: Colors.grey.shade200, + appBar: AppBar( + backgroundColor: Colors.grey.shade200, + elevation: 0, + centerTitle: false, + toolbarHeight: 70.h, + title: Padding( + padding: EdgeInsets.only(top: 12.h), + child: Text( + "SUMMARY", + style: TextStyle( + fontSize: FontConstants.xxxLarge(context).sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black, + ), + ), + ), + bottom: PreferredSize( + preferredSize: Size.fromHeight(1.h), + child: Divider(height: 1.h, color: Colors.grey), + ), + ), + body: Obx(() { + return RefreshIndicator( + onRefresh: _refreshData, + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.all(16.r), + child: Column( + children: [ + GridView.count( + crossAxisCount: 2, + crossAxisSpacing: 12.w, + mainAxisSpacing: 12.h, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + _buildCard( + title: 'Today', + value: controller.today.value.toString(), + imagePath: 'assets/images/today.png', + ), + _buildCard( + title: 'Week', + value: controller.week.value.toString(), + imagePath: 'assets/images/week.png', + ), + _buildCard( + title: 'Month', + value: controller.month.value.toString(), + imagePath: 'assets/images/week.png', + ), + _buildCard( + title: 'Total', + value: controller.total.value.toString(), + imagePath: 'assets/images/total.png', + ), + ], + ), + + SizedBox(height: 12.h), + _buildCancelledCard(controller.cancelled.value.toString()), + SizedBox(height: 12.h), + + Row( + children: [ + Text( + "Statistics", + style: TextStyle( + fontSize: 26.sp, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + ), + ], + ), + + SizedBox(height: 10.h), + TotalDistanceCard(userId: _userId, refreshTick: _refreshTick), + ], + ), + ), + ); + }), + ), + ); + } +} + +// ======================================================== +// RESPONSIVE GRAPH CARD +// ======================================================== +class TotalDistanceCard extends StatefulWidget { + final int userId; + final int refreshTick; + + const TotalDistanceCard({ + super.key, + required this.userId, + this.refreshTick = 0, + }); + + @override + State createState() => _TotalDistanceCardState(); +} + +class _TotalDistanceCardState extends State { + late Future> _futureKms; + + @override + void initState() { + super.initState(); + _futureKms = _fetchWeeklyKms(); + } + + @override + void didUpdateWidget(covariant TotalDistanceCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.userId != widget.userId || + oldWidget.refreshTick != widget.refreshTick) { + setState(() { + _futureKms = _fetchWeeklyKms(); + }); + } + } + + double _toDouble(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + Future> _fetchWeeklyKms() async { + if (widget.userId == 0) { + return {'details': [], 'total_kms': 0.0}; + } + + try { + final uri = Uri.parse( + 'https://jupiter.nearle.app/live/api/v1/partners/getriderweeklykms?userid=${widget.userId}', + ); + final response = await http.get(uri); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + + if (data is Map && data['status'] == true) { + final rawDetails = (data['details'] is List) + ? data['details'] as List + : const []; + + final details = rawDetails + .map((e) => RiderWeeklyKms.fromJson(e)) + .toList(); + + final total = _toDouble(data['total_kms']); + return {'details': details, 'total_kms': total}; + } + } + } catch (e) { + debugPrint('❌ _fetchWeeklyKms Error: $e'); + } + + return {'details': [], 'total_kms': 0.0}; + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + + return FutureBuilder>( + future: _futureKms, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 24.h), + child: const Center( + child: CircularProgressIndicator(color: Colors.deepPurple), + ), + ); + } + + final List details = + snapshot.data?['details'] ?? []; + + final double totalKms = snapshot.data?['total_kms'] ?? 0.0; + + return _buildDistanceCard(details, totalKms, size); + }, + ); + } + + Widget _buildDistanceCard( + List details, + double totalKms, + Size size, + ) { + final double maxY = details.isEmpty ? 10 : _getMaxY(details); + final double chartHeight = (size.height * 0.25).clamp(160.h, 280.h); + final double leftInterval = _calculateInterval(maxY); + final double maxK = _getMaxKms(details); + + return Container( + width: double.infinity, + margin: EdgeInsets.only(top: 4.h), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: const Color.fromARGB(255, 222, 161, 235), + width: 1.3.w, + ), + borderRadius: BorderRadius.circular(10.r), + ), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total Distance", + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + Text( + "${totalKms.toStringAsFixed(2)} Km", + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + ], + ), + + SizedBox(height: 30.h), + + if (details.isEmpty) + Padding( + padding: EdgeInsets.symmetric(vertical: 8.h), + child: Center( + child: Text( + "No weekly data available", + style: TextStyle(color: Colors.grey, fontSize: 16.sp), + ), + ), + ), + + SizedBox( + height: chartHeight, + width: double.infinity, + child: BarChart( + BarChartData( + maxY: maxY, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (value) => FlLine( + color: Colors.grey.withOpacity(0.12), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + alignment: BarChartAlignment.spaceAround, + + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 60.w, + interval: leftInterval, + getTitlesWidget: (value, _) => Text( + "${value.toInt()} km", + style: TextStyle( + fontSize: 14.sp, + color: Colors.black87, + ), + ), + ), + ), + + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, _) { + final idx = value.toInt(); + if (idx >= 0 && idx < details.length) { + return Text( + details[idx].day, + style: TextStyle( + fontSize: 14.sp, + color: Colors.black87, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ), + + barGroups: List.generate(details.isEmpty ? 7 : details.length, ( + i, + ) { + final kms = details.isEmpty ? 0.0 : details[i].kms.toDouble(); + + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: kms, + color: (details.isNotEmpty && kms == maxK) + ? const Color(0xFF8124DB) + : const Color(0xFFB274F3), + width: 20.w, + borderRadius: BorderRadius.circular(6.r), + ), + ], + ); + }), + + barTouchData: BarTouchData( + enabled: true, + touchTooltipData: BarTouchTooltipData( + tooltipPadding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 8.h, + ), + getTooltipItem: (group, index, rod, rodIndex) { + return BarTooltipItem( + "${rod.toY.toStringAsFixed(2)} Km", + TextStyle( + color: Colors.white, + fontSize: 18.sp, + fontWeight: FontWeight.bold, + ), + ); + }, + ), + ), + ), + ), + ), + ], + ), + ); + } + + double _getMaxY(List details) { + if (details.isEmpty) return 10; + double maxVal = details.map((e) => e.kms).reduce(max); + if (maxVal <= 5) return 10; + final double withPadding = maxVal * 1.2; + return _roundUpNice(withPadding); + } + + double _roundUpNice(double v) { + final exponent = pow(10, (log(v) / ln10).floor()); + final mantissa = v / exponent; + + double niceMantissa; + if (mantissa <= 1) { + niceMantissa = 1; + } else if (mantissa <= 2) + niceMantissa = 2; + else if (mantissa <= 5) + niceMantissa = 5; + else + niceMantissa = 10; + + return (niceMantissa * exponent).ceilToDouble(); + } + + double _calculateInterval(double maxY) { + const int desiredTicks = 5; + double rough = max(1, (maxY / desiredTicks)); + + final exponent = pow(10, (log(rough) / ln10).floor()); + final mantissa = rough / exponent; + + double niceMantissa; + if (mantissa <= 1) { + niceMantissa = 1; + } else if (mantissa <= 2) + niceMantissa = 2; + else if (mantissa <= 5) + niceMantissa = 5; + else + niceMantissa = 10; + + return (niceMantissa * exponent).toDouble(); + } + + double _getMaxKms(List details) { + if (details.isEmpty) return 0; + return details.map((e) => e.kms).reduce(max); + } +} diff --git a/lib/views/helpers/constants/Colorconstants.dart b/lib/views/helpers/constants/Colorconstants.dart new file mode 100644 index 0000000..694f047 --- /dev/null +++ b/lib/views/helpers/constants/Colorconstants.dart @@ -0,0 +1,36 @@ + +import 'package:flutter/material.dart'; + +class ColorConstants { + static const primaryColor = Color(0xFF662582); + static Color? primaryColor1 = const Color(0xFFE7D3EF); + static Color? secondaryColor = Colors.white; + static Color? ternaryColor = "#E7D3EF".toColor(); + static Color? darkGreyColor = "575756".toColor(); + static Color? lightGrey = "b2b2b2".toColor(); + static Color? lightGreyBg = Colors.grey.shade100; + static Color? greenColor = "00b894".toColor(); + static Color? mintColor = "69c0ac".toColor(); + static Color restaurantColor = Colors.amber[100]!; + static Color groceriesColor = Colors.purple[100]!; + static Color shoppingColor = Colors.orange[100]!; + static Color healthColor = Colors.cyan[100]!; + static Color handymanColor = Colors.red[100]!; + static const blueColor = 0xff007AC2; + static const redColor = 0xffEF3F42; + static const orangeColor = 0xffFAAB53; + static Color? lightColor = const Color.fromRGBO(244, 244, 244, 1); +} + +extension ColorExtenstion on String { + // ignore: body_might_complete_normally_nullable + Color? toColor() { + var hexColor = replaceAll("#", ""); + if (hexColor.length == 6) { + hexColor = "FF$hexColor"; + } + if (hexColor.length == 8) { + return Color(int.parse("0x$hexColor")); + } + } +} \ No newline at end of file diff --git a/lib/views/helpers/constants/Font_constant.dart b/lib/views/helpers/constants/Font_constant.dart new file mode 100644 index 0000000..60c68a8 --- /dev/null +++ b/lib/views/helpers/constants/Font_constant.dart @@ -0,0 +1,111 @@ +// ignore: file_names +import 'package:flutter/material.dart'; + +class FontConstants { + static String fontFamily = 'Proxima Nova'; + + // Base screen width for scaling (iPhone standard: 375) + static const double _baseWidth = 375.0; + + // Base font sizes (for baseWidth = 375) + static const double _baseExtraSmall = 10.0; + static const double _baseSmall = 12.0; + static const double _baseMedium = 14.0; + static const double _baseRegular = 16.0; + static const double _baseLarge = 20.0; + static const double _baseXLarge = 21.0; + static const double _baseXXLarge = 22.0; + static const double _baseXXXLarge = 24.0; + static const double _baseHuge = 30.0; + + /// Get fixed font size (no width scaling) so small & large phones look same. + static double getResponsiveFontSize(BuildContext context, double baseSize) { + return baseSize; + } + + /// Extra Small Text (10px base) - For labels, captions + static double extraSmall(BuildContext context) => + getResponsiveFontSize(context, _baseExtraSmall); + + /// Small Text (12px base) - For small labels, timestamps + static double small(BuildContext context) => + getResponsiveFontSize(context, _baseSmall); + + /// Medium Text (14px base) - For body text, descriptions + static double medium(BuildContext context) => + getResponsiveFontSize(context, _baseMedium); + + /// Regular Text (16px base) - Standard body text, most common + static double regular(BuildContext context) => + getResponsiveFontSize(context, _baseRegular); + + /// Large Text (18px base) - For subheadings, important text + static double large(BuildContext context) => + getResponsiveFontSize(context, _baseLarge); + + /// Extra Large Text (20px base) - For headings, titles + static double xLarge(BuildContext context) => + getResponsiveFontSize(context, _baseXLarge); + + /// 2X Large Text (22px base) - For main headings + static double xxLarge(BuildContext context) => + getResponsiveFontSize(context, _baseXXLarge); + + /// 3X Large Text (24px base) - For prominent headings + static double xxxLarge(BuildContext context) => + getResponsiveFontSize(context, _baseXXXLarge); + + /// Huge Text (26px base) - For hero text, very prominent headings + static double huge(BuildContext context) => + getResponsiveFontSize(context, _baseHuge); +} + +class ReusableTextWidget extends StatelessWidget { + final String text; + final double? fontSize; + final double? textHeight; + final String? fontFamily; + final FontWeight? fontWeight; + final FontStyle? fontStyle; + final Color? color; + final TextAlign? textAlign; + final int? maxLines; + final TextDecoration? isUnderText; + + const ReusableTextWidget({ + super.key, + required this.text, + this.fontSize, + this.textHeight, + this.fontFamily, + this.fontWeight, + this.fontStyle, + this.color, + this.textAlign, + this.maxLines, + this.isUnderText, + }); + + @override + Widget build(BuildContext context) { + return Text( + text, + softWrap: true, + style: TextStyle( + fontSize: fontSize ?? FontConstants.medium(context), + decoration: isUnderText, + fontFamily: fontFamily ?? FontConstants.fontFamily, + decorationColor: color, + fontWeight: fontWeight ?? FontWeight.normal, + fontStyle: fontStyle ?? FontStyle.normal, + color: color ?? Colors.grey.shade900, + overflow: TextOverflow.ellipsis, + decorationStyle: TextDecorationStyle.solid, + decorationThickness: 1, + height: textHeight, + ), + maxLines: maxLines, + textAlign: textAlign ?? TextAlign.start, + ); + } +} \ No newline at end of file diff --git a/lib/views/helpers/constants/apiconstants.dart b/lib/views/helpers/constants/apiconstants.dart new file mode 100644 index 0000000..81b877c --- /dev/null +++ b/lib/views/helpers/constants/apiconstants.dart @@ -0,0 +1,78 @@ +class ApiConstants { + static String mainDev = "dev"; + static String mainRoute = "live"; + + //Delivery Queue - v2 + static String deliveryQueueDev = + "https://jupiter.nearle.app/$mainDev/api/v2/deliveries/getdeliveryqueues"; + static String deliveryQueueLive = + "https://jupiter.nearle.app/$mainRoute/api/v2/deliveries/getdeliveryqueues"; + + //Current Delivery - v1 + static String currentDeliveryDev = + "https://jupiter.nearle.app/$mainDev/api/v1/deliveries/getdeliveries"; + static String currentDeliveryLive = + "https://jupiter.nearle.app/$mainRoute/api/v1/deliveries/getdeliveries"; + + //Current Delivery V3 - v3 (date-bounded) + static String currentDeliveryV3Dev = + "https://jupiter.nearle.app/$mainDev/api/v3/deliveries/getdeliveries"; + static String currentDeliveryV3Live = + "https://jupiter.nearle.app/$mainRoute/api/v3/deliveries/getdeliveries"; + + //Update Delivery - v1 + static String updateDeliveryDev = + "https://queue.workolik.com/live/api/v1/deliveries/updatedelivery"; + static String updateDeliveryLive = + "https://queue.workolik.com/live/api/v1/deliveries/updatedelivery"; + + //Get Rider Log - v1 + static String getRiderLogDev = + "https://jupiter.nearle.app/$mainDev/api/v1/partners/getriderlog"; + static String getRiderLogLive = + "https://jupiter.nearle.app/$mainRoute/api/v1/partners/getriderlog"; + + //Create Rider Log - v2 + static String createRiderLogDev = + "https://queue.workolik.com/live/api/v2/partners/createriderlog"; + static String createRiderLogLive = + "https://queue.workolik.com/live/api/v2/partners/createriderlog"; + + //Update Rider Log - v1 + static String updateRiderLogDev = + "https://jupiter.nearle.app/$mainDev/api/v1/partners/updateriderlog"; + static String updateRiderLogLive = + "https://jupiter.nearle.app/$mainRoute/api/v1/partners/updateriderlog"; + + //Get Rider Count - v1 + static String getRiderCountDev = + "https://jupiter.nearle.app/$mainDev/api/v1/partners/getridercount"; + static String getRiderCountLive = + "https://jupiter.nearle.app/$mainRoute/api/v1/partners/getridercount"; + + //Create Break Rider Log - v2 + static String createBreakRiderLogDev = + "https://queue.workolik.com/live/api/v2/partners/createbreaklog"; + static String createBreakRiderLogLive = + "https://queue.workolik.com/live/api/v2/partners/createbreaklog"; + + //Update Break Rider Log - v2 + static String updateBreakRiderLogDev = + "https://queue.workolik.com/live/api/v2/partners/updatebreaklog"; + static String updateBreakRiderLogLive = + "https://queue.workolik.com/live/api/v2/partners/updatebreaklog"; + + //Create Delivery Log - v2 + static String createDeliveryLogDev = + "https://queue.workolik.com/live/api/v2/deliveries/createdeliverylog"; + static String createDeliveryLogLive = + "https://queue.workolik.com/live/api/v2/deliveries/createdeliverylog"; + + //Summary API - v2 + static String summaryApiLive = + 'https://jupiter.nearle.app/$mainRoute/api/v2/partners'; + + //Summary Rider Weekly KMs - v1 + static String summaryriderkmLive = + 'https://jupiter.nearle.app/$mainRoute/api/v1/partners/getriderweeklykms'; +} diff --git a/lib/views/helpers/constants/mqtt_constants.dart b/lib/views/helpers/constants/mqtt_constants.dart new file mode 100644 index 0000000..e4e99a8 --- /dev/null +++ b/lib/views/helpers/constants/mqtt_constants.dart @@ -0,0 +1,18 @@ +class MqttConstants { + static const String brokerHost = '66.116.225.226'; // Updated with VPS IP + static const int brokerPort = 1883; + static const String username = 'admin'; + static const String passwordString = 'Package@321#'; // Provided by user + + // Topic Structure + static const String topicRiderStatus = 'nearle/riders/{riderId}/status'; + static const String topicRiderProfile = 'nearle/riders/{riderId}/profile'; + static const String topicRiderLocation = 'nearle/riders/{riderId}/location'; + static const String topicRiderTelemetry = 'nearle/riders/{riderId}/telemetry'; + static const String topicRiderLogs = 'nearle/riders/{riderId}/logs'; + + // Status Values + static const String statusOnline = 'Online'; + static const String statusOffline = 'Offline'; + static const String statusIdle = 'Idle'; +} diff --git a/lib/views/introscreens/intro1.dart b/lib/views/introscreens/intro1.dart new file mode 100644 index 0000000..e4ab765 --- /dev/null +++ b/lib/views/introscreens/intro1.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; + +class Intro1 extends GetResponsiveView { + Intro1({super.key}); + + @override + Widget builder() { + // 🔹 Use `screen.height` and `screen.width` safely here + final height = screen.height; + final width = screen.width; + + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark.copyWith( + // Make the status bar area white instead of black, with dark icons + statusBarColor: Colors.white, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: Colors.white, + ), + child: Scaffold( + backgroundColor: Colors.white, + + appBar: PreferredSize( + preferredSize: Size.fromHeight(height * 0.12), + child: AppBar( + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + elevation: 0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.white, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: Colors.white, + ), + flexibleSpace: Align( + alignment: Alignment.topLeft, + child: Container( + height: height * 0.14, + width: width * 0.25, + decoration: BoxDecoration( + color: ColorConstants.primaryColor, + borderRadius: BorderRadius.only( + bottomRight: Radius.circular(width * 0.25), + ), + ), + ), + ), + ), + ), + + body: SafeArea( + child: Stack( + children: [ + Positioned( + bottom: -height * 0.12, + left: -width * 0.1, + right: -width * 0.1, + child: Container( + width: width * 1.2, + height: height * 0.28, + decoration: BoxDecoration( + color: const Color(0xFFF3EAF9), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(width * 0.8), + topRight: Radius.circular(width * 0.8), + ), + ), + ), + ), + Center( + child: Padding( + padding: EdgeInsets.only(top: height * 0.08), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + children: [ + Transform.translate( + offset: Offset(0, -height * 0.08), + child: Text( + 'Welcome to', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: height * 0.045, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Transform.translate( + offset: Offset(0, -height * 0.1), + child: Text( + 'Nearle !', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: height * 0.045, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + Transform.translate( + offset: Offset(0, -height * 0.08), + child: Text( + 'Find delivery opportunities anytime,\nanywhere | Earn with ease!', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: height * 0.022, + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Transform.translate( + offset: Offset(0, -height * 0.09), + child: Image.asset( + 'assets/images/intro1.png', + height: height * 0.35, + width: width * 0.75, + fit: BoxFit.contain, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/introscreens/intro2.dart b/lib/views/introscreens/intro2.dart new file mode 100644 index 0000000..cdb1590 --- /dev/null +++ b/lib/views/introscreens/intro2.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; + +class Intro2 extends GetResponsiveView { + Intro2({super.key}); + + @override + Widget builder() { + final height = screen.height; + final width = screen.width; + + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark.copyWith( + // Make the status bar area white instead of black, with dark icons + statusBarColor: Colors.white, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: Colors.white, + ), + child: Scaffold( + backgroundColor: Colors.white, + appBar: PreferredSize( + preferredSize: Size.fromHeight(height * 0.12), + child: AppBar( + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + elevation: 0, + systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.white, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: Colors.white, + ), + flexibleSpace: Align( + alignment: Alignment.topRight, + child: Container( + height: height * 0.14, + width: width * 0.25, + decoration: BoxDecoration( + color: ColorConstants.primaryColor, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(width * 0.25), + ), + ), + ), + ), + ), + ), + + body: SafeArea( + child: Stack( + children: [ + // Bottom curve + Positioned( + bottom: -height * 0.12, + left: -width * 0.1, + right: -width * 0.1, + child: Container( + width: width * 1.2, + height: height * 0.28, + decoration: BoxDecoration( + color: const Color(0xFFF3EAF9), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(width * 0.8), + topRight: Radius.circular(width * 0.8), + ), + ), + ), + ), + + // Center content + Center( + child: Padding( + padding: EdgeInsets.only(top: height * 0.08), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Title + Column( + children: [ + Transform.translate( + offset: Offset(0, -height * 0.08), + child: Text( + 'Orders That', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: height * 0.045, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Transform.translate( + offset: Offset(0, -height * 0.1), + child: Text( + 'Find You', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: height * 0.045, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + Transform.translate( + offset: Offset(0, -height * 0.08), + child: Text( + 'Get assigned deliveries based on your\nlocation for faster and smarter work.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: height * 0.022, + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + + // Image in center + Transform.translate( + offset: Offset(0, -height * 0.09), + child: Image.asset( + 'assets/images/intro2.png', + height: height * 0.35, + width: width * 0.75, + fit: BoxFit.contain, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/introscreens/intro3.dart b/lib/views/introscreens/intro3.dart new file mode 100644 index 0000000..d01e3c8 --- /dev/null +++ b/lib/views/introscreens/intro3.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; + +class Intro3 extends GetResponsiveView { + final VoidCallback onFinish; + + Intro3({ + super.key, + required this.onFinish, + required PageController controller, + }); + + @override + Widget builder() { + final height = screen.height; + final width = screen.width; + + return AnnotatedRegion( + value: SystemUiOverlayStyle.light.copyWith( + // Keep status bar transparent over the purple gradient on this screen + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemNavigationBarColor: Colors.white, + ), + child: Scaffold( + backgroundColor: Colors.white, + appBar: PreferredSize( + preferredSize: Size.fromHeight(height * 0.35), + child: AppBar( + automaticallyImplyLeading: false, + elevation: 0, + backgroundColor: Colors.transparent, + systemOverlayStyle: SystemUiOverlayStyle.light.copyWith( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + flexibleSpace: Container( + width: double.infinity, + height: height * 0.36, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + ColorConstants.primaryColor, + ColorConstants.primaryColor, + ], + ), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(width * 0.49), + bottomRight: Radius.circular(width * 0.49), + ), + ), + child: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: width * 0.06, + vertical: height * 0.02, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: height * 0.06), + Text( + 'Deliver. Earn.\nRepeat.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: height * 0.045, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1.2, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: height * 0.02), + Text( + 'Track your trips, and enjoy \npayouts every week.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: height * 0.019, + color: Colors.white.withOpacity(0.9), + height: 1.4, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + ), + ), + + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: width * 0.05), + child: Transform.translate( + offset: Offset(0, -height * 0.01), + child: Image.asset( + 'assets/images/intro3.png', + height: height * 0.35, + width: width * 0.75, + fit: BoxFit.contain, + ), + ), + ), + ), + + SizedBox(height: height * 0.04), + + // Button at bottom + Padding( + padding: EdgeInsets.symmetric(horizontal: width * 0.08), + child: SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + onPressed: onFinish, + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + "Start", + style: TextStyle( + fontSize: 21, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.white, + ), + ), + ), + ), + ), + SizedBox(height: height * 0.03), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/introscreens/introscreen.dart b/lib/views/introscreens/introscreen.dart new file mode 100644 index 0000000..66a34b4 --- /dev/null +++ b/lib/views/introscreens/introscreen.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/introscreens/intro1.dart'; +import 'package:nearle/views/introscreens/intro2.dart'; +import 'package:nearle/views/introscreens/intro3.dart'; +import 'package:nearle/views/onboardscreens/Sign_in.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:smooth_page_indicator/smooth_page_indicator.dart'; + +class Introscreen extends StatefulWidget { + const Introscreen({super.key}); + + @override + State createState() => _IntroscreenState(); +} + +class _IntroscreenState extends State { + late final PageController _controller; + static const String _prefsHasSeenIntroKey = 'has_seen_intro'; + + void _finishOnboarding() { + _completeOnboarding(); + } + + Future _completeOnboarding() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsHasSeenIntroKey, true); + } catch (_) {} + + if (!mounted) return; + Get.offAll(() => const SignIn()); + } + + @override + void initState() { + super.initState(); + _controller = PageController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + alignment: Alignment.bottomCenter, // positions the indicator + children: [ + PageView( + controller: _controller, + children: [ + Intro1(), + Intro2(), + Intro3(controller: _controller, onFinish: _finishOnboarding), + ], + ), + Container( + // Move indicator slightly up so it doesn't sit under the bottom curve + alignment: const Alignment(0, 0.55), + child: SmoothPageIndicator( + controller: _controller, + count: 3, + effect: ExpandingDotsEffect( + expansionFactor: 3, // How much the active dot expands + dotHeight: 7, + dotWidth: 7, + spacing: 8, + dotColor: Colors.grey, + activeDotColor: ColorConstants.primaryColor, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/introscreens/splashscreen.dart b/lib/views/introscreens/splashscreen.dart new file mode 100644 index 0000000..b41a5a0 --- /dev/null +++ b/lib/views/introscreens/splashscreen.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'dart:async'; + +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/introscreens/introscreen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/widget/Bottom_page.dart'; +import 'package:nearle/views/onboardscreens/Sign_in.dart'; +import 'package:nearle/views/onboardscreens/signin_banner.dart'; +import 'package:nearle/views/updatescreen/UpdateScreen.dart'; +import 'package:new_version_plus/new_version_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:nearle/views/onboardscreens/Mpin.dart'; +import 'package:nearle/controllers/auth.dart'; + +class Splashscreen extends StatefulWidget { + const Splashscreen({super.key}); + + @override + State createState() => _SplashscreenState(); +} + +class _SplashscreenState extends State { + late final ImageProvider _logoProvider; + bool _imagePrecached = false; + bool _hasCheckedUpdate = false; + + @override + void initState() { + super.initState(); + + _logoProvider = const AssetImage("assets/images/nearlesplash2.png"); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + await precacheImage(_logoProvider, context); + if (mounted) { + setState(() { + _imagePrecached = true; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _imagePrecached = true; + }); + } + } + }); + + // FAST splash: 1 second + Timer(const Duration(seconds: 1), () { + if (mounted) { + _startNextStep(); + } + }); + } + + // FAST second step: 0.2 sec + void _startNextStep() { + Timer(const Duration(milliseconds: 200), () { + if (mounted && !_hasCheckedUpdate) { + _checkForUpdateAndNavigate(); + } + }); + } + + Future _checkForUpdateAndNavigate() async { + if (_hasCheckedUpdate) return; + _hasCheckedUpdate = true; + + try { + final newVersion = NewVersionPlus( + iOSId: '284882215', + androidId: "com.nearle.partner", + ); + + final status = await newVersion.getVersionStatus(); + + if (status != null && status.canUpdate) { + if (mounted) { + Get.offAll( + () => UpdateScreen( + mCurrentVersion: status.localVersion, + mUpdateVersion: status.storeVersion, + mIsForceUpdate: true, + ), + transition: Transition.fadeIn, + ); + } + return; + } + } catch (e) {} + + _navigateToNextScreen(); + } + + Future _navigateToNextScreen() async { + final prefs = await SharedPreferences.getInstance(); + final bool isLoggedOut = prefs.getBool('logged_out') ?? false; + final bool hasSeenIntro = prefs.getBool('has_seen_intro') ?? false; + final int? savedUserId = prefs.getInt('userid'); + + // 🚀 Check for App Update (Force Login if version changed) + try { + final packageInfo = await PackageInfo.fromPlatform(); + final currentVersion = packageInfo.version; + final lastRunVersion = prefs.getString('last_run_version'); + + if (lastRunVersion != null && lastRunVersion != currentVersion) { + debugPrint( + '[SPLASH] App update detected: $lastRunVersion -> $currentVersion. Forcing re-verification.', + ); + + final String? savedPhone = prefs.getString('contactno'); + final int? savedUserId = prefs.getInt('userid'); + + // Update stored version + await prefs.setString('last_run_version', currentVersion); + + if (savedUserId != null && savedPhone != null && savedPhone.isNotEmpty) { + debugPrint('[SPLASH] User was logged in. Redirecting to MPIN page.'); + + // Initialize AuthController and set the phone for MPIN verification + final auth = Get.put(AuthController()); + auth.currentPhone = savedPhone; + + // Clear sensitive session data to force re-verification + await prefs.remove('userid'); + await prefs.remove('partnerid'); + await prefs.remove('onduty'); + await prefs.setBool('logged_out', true); + + if (mounted) { + Get.offAll(() => Mpin()); + } + return; + } else { + debugPrint('[SPLASH] User was not logged in. Redirecting to Sign In.'); + if (mounted) { + Get.offAll(() => const SignIn()); + } + return; + } + } + // Save current version for next run + await prefs.setString('last_run_version', currentVersion); + } catch (e) { + debugPrint('[SPLASH] Version check error: $e'); + } + + if (!mounted) return; + + if (!isLoggedOut && savedUserId != null && savedUserId > 0) { + final onduty = prefs.getInt('onduty') ?? 0; + + if (onduty == 0) { + Get.offAll(() => const SigninBanner()); + } else { + Get.offAll(() => const BottomPage()); + } + } else { + if (hasSeenIntro) { + Get.offAll(() => const SignIn()); + } else { + Get.offAll(() => Introscreen()); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.secondaryColor, + body: SafeArea( + child: Center( + child: _imagePrecached + ? Image( + image: _logoProvider, + fit: BoxFit.contain, + ) + : const SizedBox(), + ), + ), + ); + } +} diff --git a/lib/views/offline/offline_page.dart b/lib/views/offline/offline_page.dart new file mode 100644 index 0000000..3617a9d --- /dev/null +++ b/lib/views/offline/offline_page.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:get/get.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:nearle/views/introscreens/splashscreen.dart'; + +class OfflinePage extends StatelessWidget { + const OfflinePage({super.key}); + + Future _handleRetry(BuildContext context) async { + try { + final results = await Connectivity().checkConnectivity(); + final isOnline = results.isNotEmpty && results.any((r) => r != ConnectivityResult.none); + if (isOnline) { + // Return to normal app flow; Splashscreen decides login vs home + Get.offAll(() => Splashscreen()); + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Still offline. Please check your internet connection.'), + backgroundColor: Colors.black87, + behavior: SnackBarBehavior.floating, + ), + ); + } catch (_) { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final height = MediaQuery.of(context).size.height; + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Stack( + children: [ + Positioned( + bottom: -height * 0.12, + left: -width * 0.1, + right: -width * 0.1, + child: Container( + width: width * 1.2, + height: height * 0.28, + decoration: BoxDecoration( + color: const Color(0xFFF3EAF9), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(width * 0.8), + topRight: Radius.circular(width * 0.8), + ), + ), + ), + ), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: ColorConstants.primaryColor.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.wifi_off, + color: ColorConstants.primaryColor, + size: 64, + ), + ), + const SizedBox(height: 24), + Text( + 'You are offline', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black, + ), + ), + const SizedBox(height: 12), + Text( + 'Please check your internet connection. We\'ll reconnect automatically when you\'re back online.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.grey[700], + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(height: 24), + SizedBox( + width: 180, + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => _handleRetry(context), + child: const Text( + 'Retry', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + + diff --git a/lib/views/onboardscreens/Creat_mpin.dart b/lib/views/onboardscreens/Creat_mpin.dart new file mode 100644 index 0000000..5014a43 --- /dev/null +++ b/lib/views/onboardscreens/Creat_mpin.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; + +import 'package:nearle/controllers/auth.dart'; +import 'package:nearle/views/onboardscreens/Mpin.dart'; + +class CreateMpin extends GetResponsiveView { + CreateMpin({super.key}); + + @override + Widget builder() { + return const _CreateMpinBody(); + } +} + +class _CreateMpinBody extends StatefulWidget { + const _CreateMpinBody(); + + @override + State<_CreateMpinBody> createState() => _CreateMpinBodyState(); +} + +class _CreateMpinBodyState extends State<_CreateMpinBody> { + final AuthController _auth = Get.put(AuthController()); + final List _newMpinControllers = List.generate( + 4, + (_) => TextEditingController(), + ); + final List _confirmMpinControllers = List.generate( + 4, + (_) => TextEditingController(), + ); + + final List _newFocusNodes = List.generate(4, (_) => FocusNode()); + final List _confirmFocusNodes = List.generate( + 4, + (_) => FocusNode(), + ); + + bool isLoading = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _newFocusNodes[0].requestFocus(); + }); + } + + @override + void dispose() { + for (var c in [..._newMpinControllers, ..._confirmMpinControllers]) { + c.dispose(); + } + for (var f in [..._newFocusNodes, ..._confirmFocusNodes]) { + f.dispose(); + } + super.dispose(); + } + + void _onMpinChange( + String value, + int index, + List controllers, + List nodes, + ) { + if (value.isNotEmpty && index < 3) { + nodes[index + 1].requestFocus(); + } else if (value.isEmpty && index > 0) { + nodes[index - 1].requestFocus(); + } + + final isGroupFilled = controllers.every((c) => c.text.isNotEmpty); + if (controllers == _newMpinControllers && isGroupFilled) { + _confirmFocusNodes[0].requestFocus(); + } + if (controllers == _confirmMpinControllers && isGroupFilled) { + FocusScope.of(context).unfocus(); + } + + setState(() {}); + } + + String getMpin(List controllers) => + controllers.map((c) => c.text).join(); + + bool get isMpinMatched => + getMpin(_newMpinControllers) == getMpin(_confirmMpinControllers); + + bool get isAllFilled => [ + ..._newMpinControllers, + ..._confirmMpinControllers, + ].every((c) => c.text.isNotEmpty); + + @override + Widget build(BuildContext context) { + final screen = context.width < 600 + ? "mobile" + : context.width < 1100 + ? "tablet" + : "desktop"; + + final height = Get.height; + final width = Get.width; + + // Adjust scale based on device + final scale = screen == "mobile" + ? 1.0 + : screen == "tablet" + ? 1.3 + : 1.6; + + return Scaffold( + backgroundColor: Colors.white, + body: Stack( + children: [ + SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: width * 0.06), + child: Column( + children: [ + SizedBox(height: height * 0.04 * scale), + + SizedBox( + height: height * 0.25 * scale, + width: width * 0.6, + child: Image.asset( + "assets/images/CreateMpin.png", + fit: BoxFit.contain, + ), + ), + + SizedBox(height: height * 0.03 * scale), + + Text( + "Create Your MPIN", + style: TextStyle( + fontSize: height * 0.04 * scale, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primaryColor, + ), + ), + + SizedBox(height: height * 0.015 * scale), + + Text( + "Enter a 4-digit MPIN and confirm it to secure your account.", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: height * 0.02 * scale, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + + SizedBox(height: height * 0.04 * scale), + + _buildMpinField( + "Enter New MPIN", + _newMpinControllers, + _newFocusNodes, + scale, + ), + + SizedBox(height: height * 0.03 * scale), + + _buildMpinField( + "Confirm MPIN", + _confirmMpinControllers, + _confirmFocusNodes, + scale, + ), + ], + ), + ), + ), + + Positioned( + top: height * 0.05, + left: width * 0.04, + child: InkWell( + onTap: () => Get.back(), + borderRadius: BorderRadius.circular(30), + child: Container( + padding: EdgeInsets.all(width * 0.02), + decoration: const BoxDecoration( + color: Colors.black12, + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_back, + color: Colors.black, + size: width * 0.06, + ), + ), + ), + ), + ], + ), + + bottomNavigationBar: SafeArea( + child: Padding( + padding: EdgeInsets.all(width * 0.04), + child: SizedBox( + width: double.infinity, + height: height * 0.065 * scale, + child: ElevatedButton( + onPressed: isAllFilled && isMpinMatched && !isLoading + ? () async { + setState(() => isLoading = true); + final newPin = getMpin(_newMpinControllers); + // Set PIN directly - user ID should already be available from login flow + final ok = await _auth.setPin(newPin); + setState(() => isLoading = false); + if (ok) { + // After setting PIN, go to verify PIN page to sign-in with new PIN + Get.to(() => Mpin()); + } else { + Get.snackbar( + 'Failed', + 'Unable to set PIN. Please try again.', + ); + } + } + : null, + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(width * 0.03), + ), + ), + child: isLoading + ? SizedBox( + height: height * 0.03, + width: height * 0.03, + child: const CircularProgressIndicator( + strokeWidth: 3, + color: Colors.white, + ), + ) + : Text( + "Continue", + style: TextStyle( + fontSize: height * 0.024 * scale, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildMpinField( + String label, + List controllers, + List focusNodes, + double scale, + ) { + final height = Get.height; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: height * 0.02 * scale, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: height * 0.015 * scale), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(4, (index) { + return SizedBox( + width: 50 * scale, + height: 55 * scale, + child: TextField( + controller: controllers[index], + focusNode: focusNodes[index], + textAlign: TextAlign.center, + obscureText: true, + maxLength: 1, + keyboardType: TextInputType.number, + style: TextStyle( + fontSize: height * 0.025 * scale, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + decoration: InputDecoration( + counterText: "", + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8 * scale), + borderSide: const BorderSide(color: Colors.grey), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8 * scale), + borderSide: const BorderSide( + color: ColorConstants.primaryColor, + width: 2, + ), + ), + ), + onChanged: (val) => + _onMpinChange(val, index, controllers, focusNodes), + ), + ); + }), + ), + ], + ); + } +} diff --git a/lib/views/onboardscreens/Mpin.dart b/lib/views/onboardscreens/Mpin.dart new file mode 100644 index 0000000..f34289b --- /dev/null +++ b/lib/views/onboardscreens/Mpin.dart @@ -0,0 +1,343 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:nearle/views/onboardscreens/Sign_in.dart'; +import 'package:nearle/views/onboardscreens/otp_page.dart'; +import 'package:nearle/widget/Bottom_page.dart'; +import 'package:nearle/controllers/auth.dart'; +import 'package:nearle/views/onboardscreens/signin_banner.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter/services.dart'; + +class Mpin extends GetResponsiveView { + Mpin({super.key}); + + @override + Widget builder() { + return const _MpinView(); + } +} + +class _MpinView extends StatefulWidget { + const _MpinView(); + + @override + State<_MpinView> createState() => _MpinViewState(); +} + +class _MpinViewState extends State<_MpinView> { + final AuthController _auth = Get.put(AuthController()); + final List _mpinControllers = List.generate( + 4, + (_) => TextEditingController(), + ); + final List _focusNodes = List.generate(4, (_) => FocusNode()); + + bool isVerifying = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNodes[0].requestFocus(); + _maybeShowMasterPinReminder(); + }); + } + + @override + void dispose() { + for (var c in _mpinControllers) { + c.dispose(); + } + for (var f in _focusNodes) { + f.dispose(); + } + super.dispose(); + } + + void _onMpinChange(String value, int index) { + if (value.isNotEmpty && index < 3) { + _focusNodes[index + 1].requestFocus(); + } else if (value.isEmpty && index > 0) { + _focusNodes[index - 1].requestFocus(); + } + + final filled = _mpinControllers.every((c) => c.text.isNotEmpty); + if (filled) { + FocusScope.of(context).unfocus(); + _submitMpin(); // auto-verify when 4 digits are filled + } + + setState(() {}); + } + + String getMpin() => _mpinControllers.map((c) => c.text).join(); + bool get isMpinFilled => _mpinControllers.every((c) => c.text.isNotEmpty); + + void _clearMpinAndFocus() { + for (final c in _mpinControllers) { + c.clear(); + } + if (_focusNodes.isNotEmpty) { + FocusScope.of(context).requestFocus(_focusNodes[0]); + } + setState(() {}); + } + + Future _submitMpin() async { + if (!mounted || isVerifying || !isMpinFilled) return; + + // Register retry callback so AuthController bottom-sheet "Retry" button + // can clear MPIN boxes and bring back keyboard when PIN is wrong. + _auth.onPinRetry = _clearMpinAndFocus; + + setState(() => isVerifying = true); + final mpin = getMpin(); + final ok = await _auth.verifyPinWithServer(mpin); + + if (!mounted) return; + setState(() => isVerifying = false); + + if (ok) { + // ✅ Wait a moment to ensure onduty is saved, then read it + await Future.delayed(const Duration(milliseconds: 100)); + final prefs = await SharedPreferences.getInstance(); + final onduty = prefs.getInt('onduty') ?? 0; + + debugPrint('[MPIN] After verification - onduty=$onduty'); + + // Navigate based on onduty value + if (onduty == 0) { + debugPrint('[MPIN] Navigating to Introscreen (onduty=0)'); + Get.offAll(() => SigninBanner()); + } else { + debugPrint('[MPIN] Navigating to BottomPage (onduty=1)'); + Get.offAll(() => const BottomPage()); + } + } else { + // Wrong MPIN: show alert/snackbar and keep user on MPIN screen + } + } + + Future _maybeShowMasterPinReminder() async { + try { + final prefs = await SharedPreferences.getInstance(); + final forceMasterPin = + prefs.getBool(AuthController.forceMasterPinPrefKey) ?? false; + if (forceMasterPin) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Use ${AuthController.masterPinValue} as PIN to continue.', + ), + duration: const Duration(seconds: 4), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final height = size.height; + final width = size.width; + + double scale = 1.0; + if (Get.width < 380) scale = 0.9; + if (Get.width > 800) scale = 1.2; + + return Scaffold( + backgroundColor: Colors.white, + body: Stack( + children: [ + SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: width * 0.05), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: height * 0.04), + + SizedBox( + height: height * 0.25 * scale, + width: width * 0.6, + child: Image.asset( + "assets/images/Mpin.png", + fit: BoxFit.contain, + ), + ), + + SizedBox(height: height * 0.04), + + Text( + "Enter Your MPIN", + style: TextStyle( + fontSize: height * 0.05 * scale, + fontWeight: FontWeight.bold, + color: ColorConstants.primaryColor, + fontFamily: FontConstants.fontFamily, + ), + ), + + SizedBox(height: height * 0.01), + + Text( + "Access your account securely", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: height * 0.024 * scale, + color: Colors.black54, + fontFamily: FontConstants.fontFamily, + ), + ), + + SizedBox(height: height * 0.06), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(4, (index) { + final isFilled = _mpinControllers[index].text.isNotEmpty; + return SizedBox( + width: 50 * scale, + height: 55 * scale, + + child: RawKeyboardListener( + focusNode: FocusNode(), + + onKey: (event) { + if (event is RawKeyDownEvent && + event.logicalKey == + LogicalKeyboardKey.backspace && + _mpinControllers[index].text.isEmpty && + index > 0) { + _mpinControllers[index - 1].clear(); + _focusNodes[index - 1].requestFocus(); + setState(() {}); + } + }, + + child: TextField( + controller: _mpinControllers[index], + focusNode: _focusNodes[index], + textAlign: TextAlign.center, + textAlignVertical: TextAlignVertical.center, + obscureText: true, + keyboardType: TextInputType.number, + maxLength: 1, + style: TextStyle( + fontSize: height * 0.028 * scale, + fontWeight: FontWeight.bold, + color: isFilled ? Colors.white : Colors.black, + ), + decoration: InputDecoration( + counterText: "", + contentPadding: EdgeInsets.zero, + filled: true, + fillColor: isFilled + ? ColorConstants.primaryColor + : Colors.transparent, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8 * scale), + borderSide: const BorderSide( + color: Colors.grey, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8 * scale), + borderSide: const BorderSide( + color: Color(0xFF662582), + width: 2, + ), + ), + ), + onChanged: (value) => _onMpinChange(value, index), + ), + ), + ); + }), + ), + + SizedBox(height: height * 0.02), + + // Retry + Forget Pin row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + // Clear all MPIN boxes and focus first, bringing up keyboard + for (final c in _mpinControllers) { + c.clear(); + } + if (_focusNodes.isNotEmpty) { + FocusScope.of(context).requestFocus(_focusNodes[0]); + } + setState(() {}); + }, + child: Text( + "", + style: TextStyle( + fontSize: height * 0.022 * scale, + fontWeight: FontWeight.w600, + color: Colors.black87, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + InkWell( + onTap: () async { + await _auth.sendOtp(); + Get.to(OtpPage()); + }, + child: Transform.translate( + offset: Offset(-18, 0), + child: Text( + "Forget Pin?", + style: TextStyle( + fontSize: height * 0.025 * scale, + fontWeight: FontWeight.bold, + color: ColorConstants.primaryColor, + fontFamily: FontConstants.fontFamily, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + + Positioned( + top: height * 0.05, + left: width * 0.04, + child: InkWell( + onTap: () { + Get.to(SignIn()); + }, + borderRadius: BorderRadius.circular(30), + child: Container( + padding: EdgeInsets.all(width * 0.02), + decoration: const BoxDecoration( + color: Colors.black12, + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_back, + color: Colors.black, + size: width * 0.06, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/onboardscreens/Sign_in.dart b/lib/views/onboardscreens/Sign_in.dart new file mode 100644 index 0000000..6eaaac2 --- /dev/null +++ b/lib/views/onboardscreens/Sign_in.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'package:nearle/views/onboardscreens/otp_page.dart'; +import 'package:nearle/views/onboardscreens/Mpin.dart'; +import 'package:nearle/controllers/auth.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter/gestures.dart'; +import 'package:nearle/providers/notifications/notificationservce.dart'; + +class LoginController extends GetxController { + var isChecked = true.obs; + final AuthController auth = Get.put(AuthController()); +} + +class SignIn extends StatefulWidget { + const SignIn({super.key}); + + @override + State createState() => _SignInState(); +} + +class _SignInState extends State { + final LoginController controller = Get.put(LoginController()); + final TextEditingController phoneController = TextEditingController(); + final FocusNode _phoneFocusNode = FocusNode(); + + bool isPhoneValid = false; + bool isLoading = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + FocusScope.of(context).requestFocus(_phoneFocusNode); + // Request notification permission the first time Sign In screen is shown + NotificationServce.initialize(context); + }); + } + + bool _validatePhoneNumber(String number) { + final RegExp regExp = RegExp(r'^[6-9]\d{9}$'); + return regExp.hasMatch(number); + } + + @override + void dispose() { + _phoneFocusNode.dispose(); + phoneController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final height = size.height; + final width = size.width; + double scale = 1.0; + if (width < 380) scale = 0.9; + if (width > 800) scale = 1.2; + + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.white, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: Colors.white, + ), + child: Scaffold( + backgroundColor: Colors.white, + + body: SafeArea( + top: false, + left: true, + right: true, + bottom: true, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: width * 0.05, + vertical: height * 0.02, + ), + child: GetBuilder( + builder: (_) => Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: height * 0.04), + // Restore hero image at the top like before + Image.asset( + 'assets/images/Nearle Bike.png', + height: height * 0.28, + fit: BoxFit.contain, + ), + SizedBox(height: height * 0.03), + Text( + "Sign In", + style: TextStyle( + fontSize: height * 0.05 * scale, + fontWeight: FontWeight.bold, + color: ColorConstants.primaryColor, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: height * 0.02), + Text( + "Enter your mobile number to get started.", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black54, + fontSize: FontConstants.xLarge(context), + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: height * 0.05), + + // Phone number field + SizedBox( + height: height * 0.07, + width: width * 0.9, + child: TextField( + controller: phoneController, + focusNode: _phoneFocusNode, + keyboardType: TextInputType.number, + maxLength: 10, + style: TextStyle( + fontSize: FontConstants.large(context), + fontWeight: FontWeight.w500, + color: Colors.black, + ), + onChanged: (value) { + setState(() { + isPhoneValid = _validatePhoneNumber(value); + }); + + if (value.length == 10 && isPhoneValid) { + FocusScope.of(context).unfocus(); + } + }, + decoration: InputDecoration( + counterText: '', + labelText: 'Enter mobile number', + labelStyle: TextStyle( + color: Colors.grey, + fontSize: width * 0.04, + fontFamily: FontConstants.fontFamily, + ), + prefixIcon: Padding( + padding: EdgeInsets.symmetric( + horizontal: width * 0.02, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + "assets/images/in.png", + height: height * 0.045, + width: width * 0.09, + ), + SizedBox(width: width * 0.01), + Text( + "+91", + style: TextStyle( + fontSize: FontConstants.large(context), + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Color(0xFF662582), + width: 1.5, + ), + ), + ), + ), + ), + + // Validation message + if (!isPhoneValid && phoneController.text.isNotEmpty) + Padding( + padding: EdgeInsets.only( + left: width * 0.02, + top: height * 0.005, + ), + child: Text( + 'Enter a valid 10-digit mobile number', + style: TextStyle( + color: Colors.red.shade700, + fontSize: width * 0.03, + ), + ), + ), + + SizedBox(height: height * 0.02), + + // Terms text with clickable T&C and Privacy Policy + RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: TextStyle( + fontSize: 15, + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + children: [ + const TextSpan(text: 'By continuing, you agree to '), + TextSpan( + text: 'T&C', + style: const TextStyle( + color: Colors.blue, + decoration: TextDecoration.none, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + final uri = Uri.parse( + 'https://nearle.in/terms', + ); + final ok = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + if (!ok) { + await launchUrl( + uri, + mode: LaunchMode.inAppWebView, + ); + } + }, + ), + const TextSpan(text: ' and '), + TextSpan( + text: 'Privacy Policy', + style: const TextStyle( + color: Colors.blue, + decoration: TextDecoration.none, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + final uri = Uri.parse( + 'https://nearle.in/privacy', + ); + final ok = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + if (!ok) { + await launchUrl( + uri, + mode: LaunchMode.inAppWebView, + ); + } + }, + ), + ], + ), + ), + SizedBox(height: height * 0.02), + ], + ), + ), + ), + ), + ), + + // Bottom Button + bottomNavigationBar: SafeArea( + child: Padding( + padding: EdgeInsets.all(width * 0.04), + child: SizedBox( + height: height * 0.065, + width: double.infinity, + child: ElevatedButton( + onPressed: isPhoneValid && !isLoading + ? () async { + setState(() => isLoading = true); + final decision = await controller.auth.precheckPhone( + phoneController.text, + ); + setState(() => isLoading = false); + if (decision == AuthNext.notRegistered) { + return; + } else if (decision == AuthNext.otp) { + await controller.auth.sendOtp(phoneController.text); + Get.to(OtpPage()); + } else if (decision == AuthNext.verifyPin) { + Get.to(() => Mpin()); + } else { + Get.snackbar( + 'Error', + 'Unable to proceed. Please try again.', + ); + } + } + : null, + style: ElevatedButton.styleFrom( + backgroundColor: isPhoneValid + ? const Color(0xFF662582) + : Colors.grey.shade400, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: EdgeInsets.symmetric(vertical: height * 0.015), + ), + child: isLoading + ? SizedBox( + height: height * 0.035, + width: height * 0.035, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ) + : Text( + 'Next', + style: TextStyle( + color: Colors.white, + fontSize: width * 0.06, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/onboardscreens/otp_page.dart b/lib/views/onboardscreens/otp_page.dart new file mode 100644 index 0000000..4170526 --- /dev/null +++ b/lib/views/onboardscreens/otp_page.dart @@ -0,0 +1,395 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; + +import 'package:nearle/views/onboardscreens/Sign_in.dart'; +import 'package:nearle/controllers/auth.dart'; +import 'package:nearle/views/onboardscreens/Creat_mpin.dart'; +import 'package:sms_autofill/sms_autofill.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class OtpPage extends GetResponsiveView { + OtpPage({super.key}); + + @override + Widget? phone() => _OtpPageLayout(); + @override + Widget? tablet() => _OtpPageLayout(scale: 1.2); + @override + Widget? desktop() => _OtpPageLayout(scale: 1.3); +} + +class _OtpPageLayout extends StatefulWidget { + final double scale; + const _OtpPageLayout({this.scale = 1.0}); + + @override + State<_OtpPageLayout> createState() => _OtpPageLayoutState(); +} + +class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill { + final AuthController _auth = Get.put(AuthController()); + final List _otpControllers = List.generate( + 6, + (_) => TextEditingController(), + ); + final List _focusNodes = List.generate(6, (_) => FocusNode()); + + bool isVerifying = false; + int _secondsRemaining = 60; + Timer? _timer; + String _appSignature = ''; + // Consent fallback removed due to plugin AGP incompatibility + int _smsDefaultProvider = 0; // 0 = normal, 1 = passkey provider + int? _smsPassKey; // when provider is passkey based + + @override + void initState() { + super.initState(); + _startTimer(); + _listenForOtp(); + _loadOtpProviderPrefs(); + // Ensure cursor starts in first box + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _focusNodes.isNotEmpty) { + _focusNodes[0].requestFocus(); + } + }); + } + + void _startTimer() { + _secondsRemaining = 60; + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_secondsRemaining > 0) { + setState(() => _secondsRemaining--); + } else { + timer.cancel(); + } + }); + } + + Future _resendOtp() async { + setState(() => _secondsRemaining = 60); + _timer?.cancel(); + _startTimer(); + await _auth.sendOtp(); + } + + Future _listenForOtp() async { + try { + await SmsAutoFill().unregisterListener(); + listenForCode(); + // Fetch and cache app hash for SMS retriever compatibility + try { + final sig = await SmsAutoFill().getAppSignature; + if (sig.isNotEmpty) { + _appSignature = sig; + // Helpful for integrating with SMS provider templates + debugPrint('[OTP] App signature hash: $_appSignature'); + } + } catch (_) {} + } catch (_) {} + + // Consent fallback temporarily disabled; use SMS Retriever with app hash + } + + Future _loadOtpProviderPrefs() async { + try { + final prefs = await SharedPreferences.getInstance(); + _smsDefaultProvider = prefs.getInt('smsDefaultProvider') ?? 0; + _smsPassKey = prefs.getInt('smsPassKey'); + if (_smsDefaultProvider == 1 && _smsPassKey != null) { + final passKeyStr = _smsPassKey!.toString().padLeft(6, '0'); + // Prefill only if fields are empty + if (mounted && !_otpControllers.any((c) => c.text.isNotEmpty)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + for (int i = 0; i < 6 && i < passKeyStr.length; i++) { + _otpControllers[i].text = passKeyStr[i]; + } + setState(() {}); + }); + } + } + } catch (_) {} + } + + @override + void codeUpdated() { + final received = code ?? ''; + if (received.isNotEmpty) { + final digits = received.replaceAll(RegExp(r'\D'), ''); + if (digits.length >= 6) { + final otp = digits.substring(0, 6); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + for (int i = 0; i < 6; i++) { + _otpControllers[i].text = otp[i]; + } + setState(() {}); + // hide keyboard on autofill and verify with delay before navigation + FocusScope.of(context).unfocus(); + _autoVerify(delayBeforeNav: true); + }); + } + } + } + + Future _autoVerify({bool delayBeforeNav = false}) async { + if (!mounted) return; + if (!_otpControllers.every((c) => c.text.isNotEmpty)) return; + if (isVerifying) return; + setState(() => isVerifying = true); + final entered = getOtp(); + bool ok = false; + // Accept provider passkey as valid OTP when enabled + if (_smsDefaultProvider == 1 && + _smsPassKey != null && + entered == _smsPassKey!.toString().padLeft(6, '0')) { + ok = true; + } else { + ok = await _auth.verifyOtp(entered); + } + setState(() => isVerifying = false); + if (ok) { + if (delayBeforeNav) { + await Future.delayed(const Duration(seconds: 3)); + } + Get.to(() => CreateMpin()); + } + } + + @override + void dispose() { + for (final controller in _otpControllers) { + controller.dispose(); + } + for (final node in _focusNodes) { + node.dispose(); + } + _timer?.cancel(); + try { + cancel(); + } catch (_) {} + super.dispose(); + } + + void _onOtpChange(String value, int index) { + if (value.isNotEmpty && index < 5) { + _focusNodes[index + 1].requestFocus(); + } else if (value.isNotEmpty && index == 5) { + FocusScope.of(context).unfocus(); + } + if (value.isEmpty && index > 0) { + // backspace: jump focus back + _focusNodes[index - 1].requestFocus(); + _otpControllers[index - 1].selection = TextSelection( + baseOffset: 0, + extentOffset: _otpControllers[index - 1].text.length, + ); + } + setState(() {}); + } + + String getOtp() => _otpControllers.map((e) => e.text).join(); + bool get isOtpFilled => _otpControllers.every((c) => c.text.isNotEmpty); + + @override + Widget build(BuildContext context) { + final scale = widget.scale; + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Stack( + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: 20 * scale, + vertical: 20 * scale, + ), + child: SingleChildScrollView( + child: Column( + children: [ + SizedBox(height: 60 * scale), + Image.asset( + 'assets/images/verify.png', + fit: BoxFit.contain, + height: 200 * scale, + ), + SizedBox(height: 24 * scale), + Text( + "Verify OTP", + style: TextStyle( + fontSize: 28 * scale, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primaryColor, + ), + ), + SizedBox(height: 24 * scale), + Text( + "Enter the 6-digit code sent to your number", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black54, + fontSize: 19 * scale, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 40 * scale), + + // OTP Fields (6 boxes) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(6, (index) { + final isFilled = _otpControllers[index].text.isNotEmpty; + return SizedBox( + width: 45 * scale, + height: 50 * scale, + child: TextField( + controller: _otpControllers[index], + focusNode: _focusNodes[index], + autofocus: index == 0, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + maxLength: 1, + style: TextStyle( + fontSize: 17 * scale, + fontWeight: FontWeight.bold, + color: isFilled ? Colors.white : Colors.black, + ), + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: isFilled + ? ColorConstants.primaryColor + : Colors.transparent, + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide( + color: Colors.grey, + ), + borderRadius: BorderRadius.circular(8 * scale), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: ColorConstants.primaryColor, + width: 2, + ), + borderRadius: BorderRadius.circular(8 * scale), + ), + ), + onChanged: (value) => _onOtpChange(value, index), + ), + ); + }), + ), + SizedBox(height: 16 * scale), + + // Resend OTP + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: _secondsRemaining == 0 + ? () async { + await _resendOtp(); + } + : null, + child: Transform.translate( + offset: Offset(-10, 0), + child: Text( + _secondsRemaining == 0 + ? "Resend OTP" + : "Resend in 00:${_secondsRemaining.toString().padLeft(2, '0')}", + style: TextStyle( + fontSize: 18 * scale, + color: _secondsRemaining == 0 + ? ColorConstants.primaryColor + : Colors.black, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + + // Back button + Positioned( + top: 40 * scale, + left: 16 * scale, + child: InkWell( + onTap: () => Get.to(() => SignIn()), + borderRadius: BorderRadius.circular(30 * scale), + child: Container( + padding: EdgeInsets.all(8 * scale), + decoration: BoxDecoration( + color: Colors.black12, + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_back, + color: Colors.black, + size: 28 * scale, + ), + ), + ), + ), + ], + ), + ), + + bottomNavigationBar: SafeArea( + child: Padding( + padding: EdgeInsets.all(16 * scale), + child: SizedBox( + height: 55 * scale, + width: double.infinity, + child: ElevatedButton( + onPressed: isOtpFilled && !isVerifying + ? () async { + // Reuse common verification flow (same as auto-verify) + await _autoVerify(); + } + : null, + style: ElevatedButton.styleFrom( + backgroundColor: isOtpFilled + ? ColorConstants.primaryColor + : Colors.grey.shade400, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10 * scale), + ), + ), + child: isVerifying + ? SizedBox( + height: 30 * scale, + width: 30 * scale, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ) + : Text( + "Verify", + style: TextStyle( + fontSize: 21 * scale, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/onboardscreens/signin_banner.dart b/lib/views/onboardscreens/signin_banner.dart new file mode 100644 index 0000000..06d97e8 --- /dev/null +++ b/lib/views/onboardscreens/signin_banner.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart'; +import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.dart'; +import 'package:get/get.dart'; +import 'package:nearle/controllers/riderlog.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/widget/Bottom_page.dart'; + +class SigninBanner extends StatefulWidget { + const SigninBanner({super.key}); + + @override + State createState() => _SigninBannerState(); +} + +class _SigninBannerState extends State { + String _shiftText = 'Your shift: -'; + + @override + void initState() { + super.initState(); + // ✅ CRITICAL: Load shift info in background, don't block UI + _loadShiftInfo(); + } + + Future _loadShiftInfo() async { + try { + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + setState(() { + final s = (prefs.getString('starttime') ?? '').trim(); + final e = (prefs.getString('endtime') ?? '').trim(); + _shiftText = (s.isEmpty || e.isEmpty) + ? 'Your shift: -' + : 'Your shift: $s – $e'; + }); + } + } catch (e) { + // Keep default text on error + } + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final width = size.width; + final height = size.height; + + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + ), + backgroundColor: Colors.white, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: width * 0.01), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: height * 0.02), + + Text( + "Welcome Back!", + style: TextStyle( + fontSize: width * 0.07, + fontWeight: FontWeight.bold, + color: const Color(0xFF6A1B9A), + ), + textAlign: TextAlign.center, + ), + SizedBox(height: height * 0.01), + + Text( + "Start your ride and make today amazing!", + style: TextStyle( + fontSize: width * 0.04, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + SizedBox(height: height * 0.04), + + CircleAvatar( + radius: width * 0.14, + backgroundColor: const Color(0xFFEDE7F6), + child: Icon( + Icons.person, + color: const Color(0xFF6A1B9A), + size: width * 0.12, + ), + ), + SizedBox(height: height * 0.015), + + // ✅ CRITICAL: Show shift text immediately (no FutureBuilder blocking) + Text( + _shiftText, + style: TextStyle( + color: Colors.grey[700], + fontSize: width * 0.04, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: height * 0.04), + + Image.asset('assets/images/signin_banner.png'), + SizedBox(height: height * 0.05), + + LayoutBuilder( + builder: (context, constraints) { + final sliderWidth = constraints.maxWidth; + return Padding( + padding: const EdgeInsets.all(8.0), + child: SliderButton( + properties: SliderButtonProperties( + height: height * 0.07, + width: sliderWidth, + buttonSize: height * 0.065, + disable: false, + isLoading: false, + backgroundColor: const Color(0xFF6A1B9A), + disableButtonColor: const Color(0xFFCCCCDD), + dismissThresholds: 0.9, + action: () async { + try { + final rlc = Get.find(); + // Ensure any previous break is ended when coming online + debugPrint( + '[SIGNIN_BANNER] Ending break before going online', + ); + final breakEnded = await rlc + .endBreakAuto() + .timeout( + const Duration(seconds: 12), + onTimeout: () => false, + ); + debugPrint( + '[SIGNIN_BANNER] endBreakAuto -> $breakEnded', + ); + // Set rider ON duty (onduty = 1) + final ok = await rlc.setOnDuty(true); + if (ok && context.mounted) { + Get.offAll(() => const BottomPage()); + } + // Show snackbar if still on this screen (unlikely after navigation) + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + ok + ? "You're now on duty" + : "Failed to update status", + ), + backgroundColor: ok + ? Colors.deepPurple + : Colors.red, + ), + ); + } + } catch (e) { + debugPrint( + '[SIGNIN_BANNER] Error updating status: $e', + ); + } + return false; + }, + label: Text( + 'Slide to Start', + style: TextStyle( + fontSize: width * 0.05, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + alignLabel: Alignment.center, + icon: ClipOval( + child: Material( + color: Colors.white, + child: SizedBox( + width: height * 0.065, + height: height * 0.065, + child: const Icon( + Icons.arrow_forward_ios_outlined, + color: Color(0xFF6A1B9A), + ), + ), + ), + ), + ), + ), + ); + }, + ), + SizedBox(height: height * 0.04), + // Add extra bottom padding for devices with navigation bars + SizedBox(height: MediaQuery.of(context).padding.bottom), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/onboardscreens/splashscreen.dart b/lib/views/onboardscreens/splashscreen.dart new file mode 100644 index 0000000..edb1a9e --- /dev/null +++ b/lib/views/onboardscreens/splashscreen.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + + +import 'dart:async'; + +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/introscreens/introscreen.dart'; + + +class Splashscreen extends StatefulWidget { + const Splashscreen({super.key}); + + @override + State createState() => _SplashscreenState(); +} + +class _SplashscreenState extends State { + @override + void initState() { + super.initState(); + Timer(const Duration(seconds: 3), () { + Get.to(() => Introscreen()); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: ColorConstants.secondaryColor, + elevation: 0, + ), + backgroundColor: ColorConstants.secondaryColor, + body: SafeArea( + child: Column( + + children: [ + SizedBox(height: 230,), + Center(child: Image.asset("assets/images/splashimg.png",fit: BoxFit.contain,height:180,width: 180,)), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/views/updatescreen/UpdateScreen.dart b/lib/views/updatescreen/UpdateScreen.dart new file mode 100644 index 0000000..1f198bd --- /dev/null +++ b/lib/views/updatescreen/UpdateScreen.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +import 'dart:io'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:new_version_plus/new_version_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nearle/views/introscreens/introscreen.dart'; +import 'package:nearle/widget/Bottom_page.dart'; +import 'package:nearle/views/onboardscreens/signin_banner.dart'; + +class UpdateScreen extends StatefulWidget { + final bool mIsForceUpdate; + final String mCurrentVersion; + final String mUpdateVersion; + + const UpdateScreen({ + super.key, + this.mIsForceUpdate = true, + required this.mCurrentVersion, + required this.mUpdateVersion, + }); + + @override + State createState() => _UpdateScreenState(); +} + +class _UpdateScreenState extends State + with WidgetsBindingObserver { + bool _isCheckingVersion = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && !_isCheckingVersion) { + checkVersionAndNavigate(); + } + } + + Future checkVersionAndNavigate() async { + if (_isCheckingVersion) return; + _isCheckingVersion = true; + + try { + await Future.delayed(const Duration(seconds: 1)); + + final newVersion = NewVersionPlus( + iOSId: '284882215', + androidId: "com.nearle.partner", + ); + + final status = await newVersion.getVersionStatus(); + + if (status != null) { + if (!status.canUpdate) { + if (mounted) { + _navigateToNextScreen(); + } + } + } + } catch (e) { + print("Error checking version: $e"); + } finally { + _isCheckingVersion = false; + } + } + + Future _navigateToNextScreen() async { + final prefs = await SharedPreferences.getInstance(); + final isLoggedOut = prefs.getBool('logged_out') == true; + final savedUserId = prefs.getInt('userid'); + + if (!mounted) return; + + if (!isLoggedOut && savedUserId != null && savedUserId > 0) { + final onduty = prefs.getInt('onduty') ?? 0; + + if (onduty == 0) { + Get.offAll(() => const SigninBanner()); + } else { + Get.offAll(() => const BottomPage()); + } + } else { + Get.offAll(() => Introscreen()); + } + } + + @override + Widget build(BuildContext context) { + double h = Get.height; + double w = Get.width; + + return WillPopScope( + onWillPop: () async { + if (widget.mIsForceUpdate) { + SystemNavigator.pop(); + return false; + } + return false; + }, + child: Scaffold( + backgroundColor: Colors.white, + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + surfaceTintColor: Colors.transparent, + systemOverlayStyle: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + ), + ), + + body: SafeArea( + top: true, + bottom: false, // Bottom safe area handled in bottomNavigationBar + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + children: [ + /// top spacing (responsive) + SizedBox(height: h * 0.06), + + /// 🚀 Image (auto scales) + Padding( + padding: EdgeInsets.symmetric(horizontal: w * 0.08), + child: Image.asset( + "assets/images/update.png", + height: h * 0.28, + fit: BoxFit.contain, + ), + ), + + SizedBox(height: h * 0.03), + + /// 🔥 Title + Text( + "A New Update is Available!", + style: TextStyle( + fontSize: h * 0.025, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + + SizedBox(height: h * 0.015), + + /// 📄 Subtitle + Padding( + padding: EdgeInsets.symmetric(horizontal: w * 0.08), + child: Text( + "New features are here to make your app experience even smoother and more user-friendly!", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: h * 0.018, + color: Colors.grey, + height: 1.4, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + + SizedBox(height: h * 0.03), + + /// Version Text + Text( + "Available version: ${widget.mUpdateVersion}", + style: TextStyle( + fontSize: h * 0.017, + color: Colors.grey, + fontFamily: FontConstants.fontFamily, + ), + ), + + Spacer(), // pushes content for large screens + ], + ), + ), + ), + ); + }, + ), + ), + + /// ⭐ Bottom Button (fixed, responsive) with bottom safe area + bottomNavigationBar: SafeArea( + top: false, + bottom: true, + child: Padding( + padding: EdgeInsets.fromLTRB( + w * 0.06, + 0, + w * 0.06, + h * 0.03, + ), + child: SizedBox( + width: double.infinity, + height: h * 0.065, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => downloadActions(), + child: Text( + "Update Now", + style: TextStyle( + fontSize: h * 0.022, + fontFamily: FontConstants.fontFamily, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + ); + } + + void downloadActions() async { + String url; + var s = Platform.isAndroid ? "Android" : "Ios"; + + if (s == "Android") { + url = 'https://play.google.com/store/apps/details?id=com.nearle.partner'; + } else { + url = 'https://apps.apple.com/us/app/nearle/id1596895375ls=1'; + } + + final uri = Uri.parse(url); + + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + throw 'Could not launch App'; + } + } +} diff --git a/lib/widget/Bottom_page.dart b/lib/widget/Bottom_page.dart new file mode 100644 index 0000000..74455f4 --- /dev/null +++ b/lib/widget/Bottom_page.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:nearle/views/Dashboard/deliveries/deliveries.dart'; +import 'package:nearle/views/Dashboard/home/homepage.dart'; +import 'package:nearle/views/Dashboard/summary/summary.dart'; +import 'package:nearle/views/Dashboard/profile/Profilepage.dart'; +import 'package:nearle/views/helpers/constants/Colorconstants.dart'; +import 'package:nearle/views/helpers/constants/Font_constant.dart'; +class BottomPage extends StatefulWidget { + final int initialIndex; + final Widget? overridePage; + final List acceptedOrders; + const BottomPage({ + super.key, + this.initialIndex = 0, + this.overridePage, + this.acceptedOrders = const [], + }); + @override + State createState() => _BottomPageState(); +} +class _BottomPageState extends State { + late int selected; + late final List _pages; + @override + void initState() { + super.initState(); + selected = widget.initialIndex; + _pages = [ + widget.overridePage ?? const Homepage(), + const MyDeliveries(), + const Summary(), // Removed Cartpage - active deliveries now shown in deliveries page banner + const ProfilePage(), + ]; + } + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + textTheme: Theme.of( + context, + ).textTheme.apply(fontFamily: FontConstants.fontFamily), + ), + child: Scaffold( + body: IndexedStack(index: selected, children: _pages), + bottomNavigationBar: Container( + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, -2), + ), + ], + ), + child: BottomNavigationBar( + backgroundColor: Colors.white, + currentIndex: selected, + onTap: (index) => setState(() => selected = index), + type: BottomNavigationBarType.fixed, + selectedItemColor: ColorConstants.primaryColor, + unselectedItemColor: Colors.grey, + selectedFontSize: 14, + unselectedFontSize: 12, + showUnselectedLabels: true, + items: const [ + BottomNavigationBarItem( + icon: ImageIcon( + AssetImage("assets/images/homeicon.png"), + size: 30, + color: Colors.grey, + ), + activeIcon: ImageIcon( + AssetImage("assets/images/selecthome.png"), + size: 30, + color: ColorConstants.primaryColor, + ), + label: 'HOME', + ), + BottomNavigationBarItem( + icon: ImageIcon( + AssetImage("assets/images/deliveryicon.png"), + size: 30, + color: Colors.grey, + ), + activeIcon: ImageIcon( + AssetImage("assets/images/selecteddelivery.png"), + size: 30, + color: ColorConstants.primaryColor, + ), + label: 'DELIVERIES', + ), + BottomNavigationBarItem( + icon: ImageIcon( + AssetImage("assets/images/summary.png"), + size: 30, + color: Colors.grey, + ), + activeIcon: ImageIcon( + AssetImage("assets/images/selectedsummary.png"), + size: 30, + color: ColorConstants.primaryColor, + ), + label: 'SUMMARY', + ), + BottomNavigationBarItem( + icon: ImageIcon( + AssetImage("assets/images/profileicon.png"), + size: 30, + color: Colors.grey, + ), + activeIcon: ImageIcon( + AssetImage("assets/images/selectedprofile.png"), + size: 30, + color: ColorConstants.primaryColor, + ), + label: 'PROFILE', + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..c7ea17f --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..e1fe31c --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "nearle") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.nearle") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..27860e8 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..d8a4062 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..04f81f4 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux + file_selector_linux + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..7ed6f3e --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..4340ffc --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..8034e9d --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "nearle"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "nearle"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..8f20fb5 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..d4e0569 --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..f022c34 --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..f022c34 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..63b8115 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,42 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import audioplayers_darwin +import battery_plus +import connectivity_plus +import device_info_plus +import file_selector_macos +import firebase_core +import firebase_messaging +import flutter_local_notifications +import flutter_tts +import geolocator_apple +import package_info_plus +import path_provider_foundation +import shared_preferences_foundation +import url_launcher_macos +import wakelock_plus +import webview_flutter_wkwebview + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7d215be --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* nearle.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "nearle.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* nearle.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* nearle.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.nearle.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nearle.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nearle"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.nearle.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nearle.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nearle"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.nearle.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nearle.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nearle"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..fc6bf80 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ea5f0cd --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..59c6d39 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..fc6bf80 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..c5c474d --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..8d4e7cb --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..4632c69 --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..d33671c --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = nearle + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.nearle + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..b398823 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..d93e5dc --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..fb4d7d3 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..51d0967 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..3733c1a --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..ab30cba --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..04336df --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..21fe1ab --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/nearlerider-keystore.jks b/nearlerider-keystore.jks new file mode 100644 index 0000000..7f4d142 Binary files /dev/null and b/nearlerider-keystore.jks differ diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..b8616c6 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1642 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "8a1f5f3020ef2a74fb93f7ab3ef127a8feea33a7a2276279113660784ee7516a" + url: "https://pub.dev" + source: hosted + version: "1.3.64" + alp_animated_splashscreen: + dependency: "direct main" + description: + name: alp_animated_splashscreen + sha256: b39c655e4de1248028d7cfaffdcd9dced6cb20d6e9465eb42d1a567f8197e5ce + url: "https://pub.dev" + source: hosted + version: "0.0.6" + animated_stack: + dependency: "direct main" + description: + name: animated_stack + sha256: "78a2ae12cde6db384a481f749e1fcd2de7c2cd94c557e2484dab4ce71e74d575" + url: "https://pub.dev" + source: hosted + version: "0.3.3" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + url: "https://pub.dev" + source: hosted + version: "6.5.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.dev" + source: hosted + version: "4.2.1" + battery_plus: + dependency: "direct main" + description: + name: battery_plus + sha256: ad16fcb55b7384be6b4bbc763d5e2031ac7ea62b2d9b6b661490c7b9741155bf + url: "https://pub.dev" + source: hosted + version: "7.0.0" + battery_plus_platform_interface: + dependency: transitive + description: + name: battery_plus_platform_interface + sha256: e8342c0f32de4b1dfd0223114b6785e48e579bfc398da9471c9179b907fa4910 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + bottom_inset_observer: + dependency: transitive + description: + name: bottom_inset_observer + sha256: cbfb01e0e07cc4922052701786d5e607765a6f54e1844f41061abf8744519a7d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + bottom_sheet: + dependency: "direct main" + description: + name: bottom_sheet + sha256: efd28f52357d23e1c01eaeb45466b407f1e29318305bd6d10baf814fda18bd7e + url: "https://pub.dev" + source: hosted + version: "4.0.4" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + circular_countdown_timer: + dependency: "direct main" + description: + name: circular_countdown_timer + sha256: "608d166c8c659af5740ffec8859e1429a4849b706675a529dc381ef40784697b" + url: "https://pub.dev" + source: hosted + version: "0.2.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + confetti: + dependency: "direct main" + description: + name: confetti + sha256: "79376a99648efbc3f23582f5784ced0fe239922bd1a0fb41f582051eba750751" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + contained_tab_bar_view: + dependency: "direct main" + description: + name: contained_tab_bar_view + sha256: "87e35f47992764e45ab6205493f2f90c1e3fac4ad84f2b2285b73414727b756e" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + container_tab_indicator: + dependency: transitive + description: + name: container_tab_indicator + sha256: b0bdd73bb495c31c5711cefa363511b10bb3ebcfc007b603a2599401ebe6b2d9 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dots_indicator: + dependency: transitive + description: + name: dots_indicator + sha256: c070af5058a084ba7b354df4b4c26c719595d70a3531eea6edd8af8716684ba3 + url: "https://pub.dev" + source: hosted + version: "4.0.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + event_bus: + dependency: transitive + description: + name: event_bus + sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "1f2dfd9f535d81f8b06d7a50ecda6eac1e6922191ed42e09ca2c84bd2288927c" + url: "https://pub.dev" + source: hosted + version: "4.2.1" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: ff18fabb0ad0ed3595d2f2c85007ecc794aadecdff5b3bb1460b7ee47cded398 + url: "https://pub.dev" + source: hosted + version: "3.3.0" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "22086f857d2340f5d973776cfd542d3fb30cf98e1c643c3aa4a7520bb12745bb" + url: "https://pub.dev" + source: hosted + version: "16.0.4" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: a59920cbf2eb7c83d34a5f354331210ffec116b216dc72d864d8b8eb983ca398 + url: "https://pub.dev" + source: hosted + version: "4.7.4" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "1183e40e6fd2a279a628951cc3b639fcf5ffe7589902632db645011eb70ebefb" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + floating: + dependency: "direct main" + description: + name: floating + sha256: e51ce1dbcab3ea83da0ad1b07f2091b99c0e8680184c780a000567f41234d454 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animate: + dependency: transitive + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + flutter_foreground_task: + dependency: "direct main" + description: + name: flutter_foreground_task + sha256: bd01a61896c1190a8f203fb5cfc136c884ae9f28c037e7a6f9a441853180f9c0 + url: "https://pub.dev" + source: hosted + version: "7.5.2" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_temp_fork: + dependency: transitive + description: + name: flutter_keyboard_visibility_temp_fork + sha256: e3d02900640fbc1129245540db16944a0898b8be81694f4bf04b6c985bed9048 + url: "https://pub.dev" + source: hosted + version: "0.1.5" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_launcher_icons: + dependency: "direct main" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + url: "https://pub.dev" + source: hosted + version: "19.5.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_native_splash: + dependency: "direct main" + description: + name: flutter_native_splash + sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_polyline_points: + dependency: "direct main" + description: + name: flutter_polyline_points + sha256: c775fe59fbcf1f925d611c039555c7f58ed6d9411747b7a2915bbd9c5e730a51 + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_screenutil: + dependency: "direct main" + description: + name: flutter_screenutil + sha256: "8239210dd68bee6b0577aa4a090890342d04a136ce1c81f98ee513fc0ce891de" + url: "https://pub.dev" + source: hosted + version: "5.9.3" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_slidable: + dependency: "direct main" + description: + name: flutter_slidable + sha256: ea369262929d3cc6ebf9d8a00c196127966f117fe433a5e5cb47fb08008ca203 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: bdf2fc4483e74450dc9fc6fe6a9b6a5663e108d4d0dad3324a22c8e26bf48af4 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geocoding: + dependency: "direct main" + description: + name: geocoding + sha256: "606be036287842d779d7ec4e2f6c9435fc29bbbd3c6da6589710f981d8852895" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + geocoding_android: + dependency: transitive + description: + name: geocoding_android + sha256: ba810da90d6633cbb82bbab630e5b4a3b7d23503263c00ae7f1ef0316dcae5b9 + url: "https://pub.dev" + source: hosted + version: "4.0.1" + geocoding_ios: + dependency: transitive + description: + name: geocoding_ios + sha256: "18ab1c8369e2b0dcb3a8ccc907319334f35ee8cf4cfef4d9c8e23b13c65cb825" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + sha256: "8c2c8226e5c276594c2e18bfe88b19110ed770aeb7c1ab50ede570be8b92229b" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3 + url: "https://pub.dev" + source: hosted + version: "0.2.3" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + get: + dependency: "direct main" + description: + name: get + sha256: "5ed34a7925b85336e15d472cc4cfe7d9ebf4ab8e8b9f688585bf6b50f4c3d79a" + url: "https://pub.dev" + source: hosted + version: "4.7.3" + google_fonts: + dependency: transitive + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + google_maps: + dependency: transitive + description: + name: google_maps + sha256: "5d410c32112d7c6eb7858d359275b2aa04778eed3e36c745aeae905fb2fa6468" + url: "https://pub.dev" + source: hosted + version: "8.2.0" + google_maps_flutter: + dependency: "direct main" + description: + name: google_maps_flutter + sha256: "819985697596a42e1054b5feb2f407ba1ac92262e02844a40168e742b9f36dca" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + google_maps_flutter_android: + dependency: transitive + description: + name: google_maps_flutter_android + sha256: "3835f6ae5e8b8d4d454d913575069513c9f216e088b87aa5c18cb3610951c6b4" + url: "https://pub.dev" + source: hosted + version: "2.18.6" + google_maps_flutter_ios: + dependency: transitive + description: + name: google_maps_flutter_ios + sha256: "115b03c2e637e74d084a78c0e1faf42884fcd8e65d1a9ce58d909ca5493afa32" + url: "https://pub.dev" + source: hosted + version: "2.15.7" + google_maps_flutter_platform_interface: + dependency: transitive + description: + name: google_maps_flutter_platform_interface + sha256: e8b1232419fcdd35c1fdafff96843f5a40238480365599d8ca661dde96d283dd + url: "https://pub.dev" + source: hosted + version: "2.14.1" + google_maps_flutter_web: + dependency: transitive + description: + name: google_maps_flutter_web + sha256: d416602944e1859f3cbbaa53e34785c223fa0a11eddb34a913c964c5cbb5d8cf + url: "https://pub.dev" + source: hosted + version: "0.5.14+3" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + url: "https://pub.dev" + source: hosted + version: "0.8.13+3" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + introduction_screen: + dependency: "direct main" + description: + name: introduction_screen + sha256: "47ad51281f86c3ed47e0c1a0008899ad253ca71e8b626fd862e55993825a271b" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + lottie: + dependency: "direct main" + description: + name: lottie + sha256: "8ae0be46dbd9e19641791dc12ee480d34e1fd3f84c749adc05f3ad9342b71b95" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + minio: + dependency: "direct main" + description: + name: minio + sha256: ee2ce47766e46c7d164f960f2f5ed6a9a82844d877f6b82574f6876ec50c56d1 + url: "https://pub.dev" + source: hosted + version: "3.5.8" + mqtt_client: + dependency: "direct main" + description: + name: mqtt_client + sha256: fd22ea00a4c7b5623e01000a91a256d62a8bacba38e9812170458070c52affed + url: "https://pub.dev" + source: hosted + version: "10.11.9" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + new_version_plus: + dependency: "direct main" + description: + name: new_version_plus + sha256: "13081e189d3334c45bd708ffb42a0f1043a316e8c77d458d1ccb486d5572e2c6" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + pin_input_text_field: + dependency: transitive + description: + name: pin_input_text_field + sha256: f45683032283d30b670ec343781660655e3e1953438b281a0bc6e2d358486236 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + raindrop_animated_splash_screen: + dependency: "direct main" + description: + name: raindrop_animated_splash_screen + sha256: "5756e139c2ef7e395c92795ae155da43c22d20e84dddbf13e8b6a88ff7e28709" + url: "https://pub.dev" + source: hosted + version: "0.0.2" + sanitize_html: + dependency: transitive + description: + name: sanitize_html + sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + scratcher: + dependency: "direct main" + description: + name: scratcher + sha256: "540be49eb4773e7e300d4a85a9f93c600f7386cd2a82abb5b40a7f8f245e9785" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + url: "https://pub.dev" + source: hosted + version: "2.4.18" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + slide_to_submit_button: + dependency: "direct main" + description: + name: slide_to_submit_button + sha256: d3d71ceb8545a6ae72d310684d8138d641075e54495b4613b2c14279fd1f52ac + url: "https://pub.dev" + source: hosted + version: "1.0.3" + slider_button_lite: + dependency: "direct main" + description: + name: slider_button_lite + sha256: "941b9b7e335d7387afe4def4ca3e832b77ef5ed7c11e4d3ccbafad103a0cbb83" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + smooth_page_indicator: + dependency: "direct main" + description: + name: smooth_page_indicator + sha256: b21ebb8bc39cf72d11c7cfd809162a48c3800668ced1c9da3aade13a32cf6c1c + url: "https://pub.dev" + source: hosted + version: "1.2.1" + sms_autofill: + dependency: "direct main" + description: + name: sms_autofill + sha256: c65836abe9c1f62ce411bb78d5546a09ece4297558070b1bd871db1db283aaf9 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + tab_indicator_styler: + dependency: "direct main" + description: + name: tab_indicator_styler + sha256: "9e7e90367e20f71f3882fc6578fdcced35ab1c66ab20fcb623cdcc20d2796c76" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + upower: + dependency: transitive + description: + name: upower + sha256: cf042403154751180affa1d15614db7fa50234bc2373cd21c3db666c38543ebf + url: "https://pub.dev" + source: hosted + version: "0.7.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vibration: + dependency: "direct main" + description: + name: vibration + sha256: "1fd51cb0f91c6d512734ca0e282dd87fbc7f389b6da5f03c77709ba2cf8fa901" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "4134fbfcd427b59a7a91f8733292e4e9b29a7f1e8224ff0d80f5745fbf0743c6" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510 + url: "https://pub.dev" + source: hosted + version: "4.10.11" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: e49f378ed066efb13fc36186bbe0bd2425630d4ea0dbc71a18fdd0e4d8ed8ebc + url: "https://pub.dev" + source: hosted + version: "3.23.5" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.0-0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..cea98e5 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,101 @@ +name: nearle +description: Nearle Rider - Smarter Deliveries, Powered by AI +publish_to: "none" + +version: 1.2.28+145 + + +environment: + sdk: ^3.9.2 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + introduction_screen: ^4.0.0 + smooth_page_indicator: ^1.2.1 + get: ^4.7.2 + http: ^1.2.2 + device_info_plus: ^12.2.0 + firebase_core: ^4.2.0 + firebase_messaging: ^16.0.3 + image_picker: ^1.1.2 + shared_preferences: ^2.3.3 + sms_autofill: ^2.3.0 + webview_flutter: ^4.8.0 + audioplayers: ^6.5.1 + flutter_local_notifications: ^19.5.0 + path_provider: ^2.1.4 + geolocator: ^14.0.2 + connectivity_plus: ^7.0.0 + slide_to_submit_button: ^1.0.3 + flutter_tts: ^4.0.2 + vibration: ^3.1.4 + google_maps_flutter: ^2.13.1 + contained_tab_bar_view: ^0.8.0 + tab_indicator_styler: ^2.0.0 + geocoding: ^4.0.0 + fl_chart: ^1.1.1 + flutter_polyline_points: ^3.1.0 + battery_plus: ^7.0.0 + provider: ^6.1.5+1 + slider_button_lite: ^1.0.1 + url_launcher: ^6.3.2 + circular_countdown_timer: ^0.2.4 + lottie: ^3.3.2 + flutter_foreground_task: ^7.0.3 + floating: ^6.0.0 + bottom_sheet: ^4.0.4 + wakelock_plus: ^1.1.1 + minio: ^3.5.8 + package_info_plus: ^8.0.0 + flutter_native_splash: ^2.4.7 + new_version_plus: ^0.1.1 + raindrop_animated_splash_screen: ^0.0.2 + alp_animated_splashscreen: ^0.0.6 + flutter_slidable: ^4.0.3 + animated_stack: ^0.3.3 + flutter_screenutil: ^5.9.3 + shimmer: ^3.0.0 + flutter_launcher_icons: ^0.14.4 + scratcher: ^2.5.0 + confetti: ^0.8.0 + permission_handler: ^12.0.1 + mqtt_client: ^10.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + + +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/images/nearlelauncher.png" + remove_alpha_ios: true +flutter_native_splash: + color: "#FFFFFF" + image: assets/images/white.png + android_12: + image: assets/images/white.png + icon_background_color: "#FFFFFF" + + + +flutter: + uses-material-design: true + + assets: + - assets/images/ + - assets/audio/ + - assets/lotties/ + + fonts: + - family: Proxima Nova + fonts: + - asset: assets/fonts/ProximaNova/proximanova_regular.ttf + weight: 400 + - asset: assets/fonts/ProximaNova/proximanova_bold.otf + weight: 700 diff --git a/release-key.jks b/release-key.jks new file mode 100644 index 0000000..bd3a1a9 Binary files /dev/null and b/release-key.jks differ diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..5a96c32 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:nearle/views/introscreens/splashscreen.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const Splashscreen()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/test_riderlog.py b/test_riderlog.py new file mode 100644 index 0000000..bd53e94 --- /dev/null +++ b/test_riderlog.py @@ -0,0 +1,66 @@ +import requests +import json +import socket +import urllib3 + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +HOSTNAME = "queue.workolik.com" +PHONE_IP = "125.21.240.67" # IP the phone resolved to + +# Show what IP the PC resolves to +pc_ip = socket.gethostbyname(HOSTNAME) +print(f"\nPC resolves {HOSTNAME} to: {pc_ip}") +print(f"Phone resolved {HOSTNAME} to: {PHONE_IP}") +print(f"Same IP? {pc_ip == PHONE_IP}") + +payload = { + "logid": None, + "userid": 1036, + "partnerid": 44, + "shiftid": 1, + "logdate": "2026-04-27 17:27:32", + "login": "17:27:32", + "latitude": "11.005080", + "longitude": "76.950849", + "raw_latitude": "11.005081", + "raw_longitude": "76.950849", + "velocity_lat": "0.0000", + "velocity_lng": "0.0000", + "speed": "0.00", + "heading": "0.00", + "onduty": 1, + "status": "idle", + "contactno": "8072675538", + "tenantid": 0, + "locationid": 0, + "applocationid": 1, + "userfcmtoken": "abc123", + "orderid": "1-20232131", + "username": "Vignesh S", + "firstname": "Vignesh", + "lastname": "S" +} + +headers = {"Content-Type": "application/json", "Accept": "application/json"} + +tests = [ + ("Via hostname (normal)", f"https://{HOSTNAME}/live/api/v2/partners/createriderlog"), + ("Direct to phone IP (125.21.240.67)", f"https://{PHONE_IP}/live/api/v2/partners/createriderlog"), +] + +for label, url in tests: + print(f"\n{'='*60}") + print(f"Test: {label}") + print(f"URL : {url}") + try: + h = dict(headers) + if PHONE_IP in url: + h["Host"] = HOSTNAME # tell Nginx the virtual host + r = requests.post(url, json=payload, headers=h, verify=False, timeout=10) + print(f"Status : {r.status_code}") + print(f"Response: {r.text[:300]}") + except Exception as e: + print(f"ERROR: {e}") + +print(f"\n{'='*60}\nDone.") diff --git a/upload_certificate.pem b/upload_certificate.pem new file mode 100644 index 0000000..95fadb9 --- /dev/null +++ b/upload_certificate.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDejCCAmKgAwIBAgIJAPGsje/uwReYMA0GCSqGSIb3DQEBCwUAMGoxCzAJBgNV +BAYTAjkxMRMwEQYDVQQIEwp0YW1pbCBuYWR1MRMwEQYDVQQHEwpDb2ltYmF0b3Jl +MQ8wDQYDVQQKEwZuZWFybGUxDzANBgNVBAsTBm5lYXJsZTEPMA0GA1UEAxMGbmVh +cmxlMCAXDTI1MTEwNjEyMjMyNFoYDzIwNTMwMzI0MTIyMzI0WjBqMQswCQYDVQQG +EwI5MTETMBEGA1UECBMKdGFtaWwgbmFkdTETMBEGA1UEBxMKQ29pbWJhdG9yZTEP +MA0GA1UEChMGbmVhcmxlMQ8wDQYDVQQLEwZuZWFybGUxDzANBgNVBAMTBm5lYXJs +ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMECSWjFk/pFoQDZQFnf +S9s/vp2QNGTtTrOZ6MqD1ZSN3D8dOVvs2RZy3flwq8nwe1jMFrQIzypLO+VQrwkO +U5KLg9d8aS616GrWcWgiNsfmBMxkPeTFuFy2I9ALdDlaSJFuxbGymYFsFhsNM+Lr +1xhIF/huZL176o4DaPpAIK9z4BWPWr1T/TIxMYBeIZgPzzPO+gjPLXV5DRs5dve9 +ObbkT/AW5/rFExEdqJjKiKjBk1MXwqAljKoEDhrpxKmRtLEOT9nXWuhTj3KHmGfm +NeEYNO8XBN5p5kdEG3iTK8hp62J0/w3lDO7Of4joNhqpUInjoE3uVdrxfC3p5Jrf +Pa0CAwEAAaMhMB8wHQYDVR0OBBYEFKFd61BMFF3EynKkbekHEnoxoX7IMA0GCSqG +SIb3DQEBCwUAA4IBAQCALZNYrG8UtLcTuPyqWpu9QXnVpulZgQY3UypwHu+coDHu +jerbkhSxdUaq6gTKuIwfUjI3b4tFapgrFcBkHt3/ovkEAO65tGFjQKoRP4LkvlkX +PR1wSVpBt9THU9Ws7oKb52Xeoo24CVc8b1nCKWqbfDA8fYuUULK5yxxB+KuPKrk1 +4W6WNoG0kwK/qFyvSHgFRFLGTpyJ9Dn0R+eN8otsBSYjtgvJ5p2i8rMDPjfXX1LE +DS3eMG6J5jWkGBrf9HMtak2HIqAhMbVGdcxK4bL76A368LFWLBQNzEs5rf9rC4B/ +9t3ynGMAxXWhgys7QiIY7YoPrJRmNfg0j7Ov4Hy+ +-----END CERTIFICATE----- diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..bbbe512 --- /dev/null +++ b/web/index.html @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + nearle + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..b9d60fe --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "nearle", + "short_name": "nearle", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/web/splash/img/dark-1x.png b/web/splash/img/dark-1x.png new file mode 100644 index 0000000..0b6728c Binary files /dev/null and b/web/splash/img/dark-1x.png differ diff --git a/web/splash/img/dark-2x.png b/web/splash/img/dark-2x.png new file mode 100644 index 0000000..fda7bb7 Binary files /dev/null and b/web/splash/img/dark-2x.png differ diff --git a/web/splash/img/dark-3x.png b/web/splash/img/dark-3x.png new file mode 100644 index 0000000..9aeb9a9 Binary files /dev/null and b/web/splash/img/dark-3x.png differ diff --git a/web/splash/img/dark-4x.png b/web/splash/img/dark-4x.png new file mode 100644 index 0000000..388af23 Binary files /dev/null and b/web/splash/img/dark-4x.png differ diff --git a/web/splash/img/light-1x.png b/web/splash/img/light-1x.png new file mode 100644 index 0000000..0b6728c Binary files /dev/null and b/web/splash/img/light-1x.png differ diff --git a/web/splash/img/light-2x.png b/web/splash/img/light-2x.png new file mode 100644 index 0000000..fda7bb7 Binary files /dev/null and b/web/splash/img/light-2x.png differ diff --git a/web/splash/img/light-3x.png b/web/splash/img/light-3x.png new file mode 100644 index 0000000..9aeb9a9 Binary files /dev/null and b/web/splash/img/light-3x.png differ diff --git a/web/splash/img/light-4x.png b/web/splash/img/light-4x.png new file mode 100644 index 0000000..388af23 Binary files /dev/null and b/web/splash/img/light-4x.png differ diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..ec4098a --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..9f49716 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(nearle LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "nearle") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..efb62eb --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..fbb6c69 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,38 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + BatteryPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BatteryPlusWindowsPlugin")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FlutterTtsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTtsPlugin")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..fb1fda1 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,33 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows + battery_plus + connectivity_plus + file_selector_windows + firebase_core + flutter_tts + geolocator_windows + permission_handler_windows + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..2041a04 --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..3547f2f --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "nearle" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "nearle" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "nearle.exe" "\0" + VALUE "ProductName", "nearle" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..c819cb0 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..28c2383 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..c142e73 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"nearle", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..ddc7f3e --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..4b962bb --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..259d85b --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3f0e05c --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..b5ba2a0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..49b847f --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_