diff --git a/.github/workflows/firebase-distribution.yml b/.github/workflows/firebase-distribution.yml new file mode 100644 index 0000000..3f56d81 --- /dev/null +++ b/.github/workflows/firebase-distribution.yml @@ -0,0 +1,76 @@ +name: Build & Upload to Firebase App Distribution + +on: + pull_request: + types: [labeled] + +jobs: + build-and-distribute: + if: github.event.label.name == 'ReadyForTesting' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + - name: Clean Generated Code + run: flutter pub run build_runner clean + + - name: Generate Code (build_runner) + run: dart run build_runner build --delete-conflicting-outputs + + - name: Generate Localization Keys + run: flutter pub run easy_localization:generate -S assets/translations -f keys -o locale_keys.g.dart + + - name: Build APK + run: flutter build apk --release + + - name: Run tests + run: flutter test + + - name: Extract release notes and testers from PR + uses: actions/github-script@v6 + id: pr_data + with: + script: | + const title = context.payload.pull_request.title; + const body = context.payload.pull_request.body || ''; + + const testersMatch = body.match(/Testers:\n([\s\S]*)/); + let testers = 'testers'; + + if (testersMatch) { + testers = testersMatch[1] + .split('\n') + .map(e => e.trim()) + .filter(e => e) + .join(','); + } + + const releaseNotes = `PR Title:\n${title}\n\nDescription:\n${body}`; + + core.setOutput('testers', testers); + core.setOutput('releaseNotes', releaseNotes); + + - name: Upload APK to Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@v1 + with: + appId: ${{ secrets.FIREBASE_APP_ID }} + serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} + testers: ${{ steps.pr_data.outputs.testers }} + releaseNotes: ${{ steps.pr_data.outputs.releaseNotes }} + file: build/app/outputs/flutter-apk/app-release.apk diff --git a/.github/workflows/pr-name-validations.yml b/.github/workflows/pr-name-validations.yml new file mode 100644 index 0000000..a77ab8d --- /dev/null +++ b/.github/workflows/pr-name-validations.yml @@ -0,0 +1,37 @@ +name: Validate PR Title + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +jobs: + validate-pr-title: + runs-on: ubuntu-latest + steps: + - name: Validate PR title format + uses: actions/github-script@v6 + with: + script: | + const prTitle = context.payload.pull_request.title; + /* + Format: + type(scope): TICKET-task-name + + Example: + feat(ECOM-27): ECOM-27-initialize-project + */ + const pattern = /^(fix|release|feat|hotfix|build|test)\((JIRA-\d+|[A-Z]+-\d+)\):\s\2-[a-zA-Z0-9-]+$/; + + if (!pattern.test(prTitle)) { + core.setFailed( + '❌ Invalid PR title: "' + prTitle + '"\n\n' + + 'Expected format:\n' + + 'type(scope): TICKET-task-name\n\n' + + 'Allowed types: fix, release, feat, hotfix, build, test\n' + + 'Allowed scopes: JIRA- or PROJECT-\n\n' + + 'Example:\n' + + 'feat(ECOM-27): ECOM-27-initialize-project' + ); + } else { + console.log('✅ PR title is valid:', prTitle); + } diff --git a/.github/workflows/unit-testing-github-action.yml b/.github/workflows/unit-testing-github-action.yml new file mode 100644 index 0000000..e6769f9 --- /dev/null +++ b/.github/workflows/unit-testing-github-action.yml @@ -0,0 +1,39 @@ +name: Verify PR Unit Tests + +# هذا الجزء يحدد متى يعمل الـ Action +# هنا سيعمل عند فتح أي Pull Request أو تعديله +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + unit-testing: + name: Flutter Unit Tests + runs-on: ubuntu-latest + + steps: + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + - name: Clean Generated Code + run: flutter pub run build_runner clean + + - name: Generate Code (build_runner) + run: dart run build_runner build --delete-conflicting-outputs + + - name: Generate Localization Keys + run: flutter pub run easy_localization:generate -S assets/translations -f keys -o locale_keys.g.dart + + - name: Generate Localization Keys + run: flutter pub run easy_localization:generate -S assets/translations -f keys -o locale_keys.g.dart + - name: Run unit tests + run: flutter test \ No newline at end of file diff --git a/.github/workflows/validate_branch_name.yaml b/.github/workflows/validate_branch_name.yaml new file mode 100644 index 0000000..9a41cc8 --- /dev/null +++ b/.github/workflows/validate_branch_name.yaml @@ -0,0 +1,32 @@ +name: Validate Branch Name + +on: + push: + branches-ignore: + - main + - dev + +jobs: + validate-pr-name: + runs-on: ubuntu-latest + + steps: + - name: Check Branch Name + run: | + BRANCH_NAME="${GITHUB_REF#refs/heads/}" + echo "Checking branch name: $BRANCH_NAME" + + REGEX="^(feature|bugfix|hotfix|release)\/[A-Z]+-[0-9]+-[a-z]+(-[a-z]+)*$" + + if [[ ! "$BRANCH_NAME" =~ $REGEX ]]; then + echo "❌ Invalid branch name!" + echo "Expected format: //" + echo "Allowed prefixes: feature, bugfix, hotfix, release" + echo "Example: feature/ECOM-27-initialize-project" + exit 1 + fi + + - name: Validate Branch Name Action + uses: goshencollege/validate-branch-name@v1.0.1 + with: + pattern: "^(feature|bugfix|hotfix|release)\\/[A-Z]+-[0-9]+-[a-z]+(-[a-z]+)*$" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3820a95..acebbea 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/.metadata b/.metadata index 83b34eb..0691157 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" + revision: "9f455d2486bcb28cad87b062475f42edc959f636" channel: "stable" project_type: app @@ -13,26 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 - platform: android - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - platform: ios - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - platform: linux - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - platform: macos - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - platform: web - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - platform: windows - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 # User provided section diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2501c66 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "tracking_app", + "request": "launch", + "type": "dart" + }, + { + "name": "tracking_app (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "tracking_app (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5c595fb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "cSpell.words": [ + "Forgetpassword", + "Onboardingscreen", + "Resetpassword" + ] +} \ No newline at end of file diff --git a/android/android/.gitignore b/android/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/android/.gitignore @@ -0,0 +1,14 @@ +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 +key.properties +**/*.keystore +**/*.jks diff --git a/android/android/app/build.gradle.kts b/android/android/app/build.gradle.kts new file mode 100644 index 0000000..536ab03 --- /dev/null +++ b/android/android/app/build.gradle.kts @@ -0,0 +1,49 @@ + + + +plugins { + id("com.android.application") + id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") + id("kotlin-android") + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.tracking_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + + kotlinOptions { + jvmTarget = "17" + } + + defaultConfig { + applicationId = "com.example.tracking_app" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + + signingConfig = signingConfigs.getByName("debug") + } + } +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + +flutter { + source = "../.." +} diff --git a/android/android/app/google-services.json b/android/android/app/google-services.json new file mode 100644 index 0000000..57c8e9a --- /dev/null +++ b/android/android/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "725835190067", + "project_id": "elevate-flower-app", + "storage_bucket": "elevate-flower-app.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:725835190067:android:50a3f907dd986f7ce53846", + "android_client_info": { + "package_name": "com.example.flower_shop" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyB1-EtHvgb14c5UzVggOoJRa6j8oto53Jg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:725835190067:android:1a8871c3f15cdafae53846", + "android_client_info": { + "package_name": "com.example.tracking_app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyB1-EtHvgb14c5UzVggOoJRa6j8oto53Jg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/android/app/src/debug/AndroidManifest.xml b/android/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/android/app/src/main/AndroidManifest.xml b/android/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2cc440e --- /dev/null +++ b/android/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/android/app/src/main/kotlin/com/example/tracking_app/MainActivity.kt b/android/android/app/src/main/kotlin/com/example/tracking_app/MainActivity.kt new file mode 100644 index 0000000..2fee2b8 --- /dev/null +++ b/android/android/app/src/main/kotlin/com/example/tracking_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.tracking_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/android/app/src/main/res/drawable-v21/launch_background.xml b/android/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/android/app/src/main/res/drawable/launch_background.xml b/android/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/android/app/src/main/res/values-night/styles.xml b/android/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/android/app/src/main/res/values/styles.xml b/android/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/android/app/src/profile/AndroidManifest.xml b/android/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/android/build.gradle.kts b/android/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/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/android/gradle.properties b/android/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/android/gradle/wrapper/gradle-wrapper.properties b/android/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/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.14-all.zip diff --git a/android/android/settings.gradle.kts b/android/android/settings.gradle.kts new file mode 100644 index 0000000..d6b1b1b --- /dev/null +++ b/android/android/settings.gradle.kts @@ -0,0 +1,30 @@ +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.11.1" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + id("com.google.firebase.crashlytics") version("2.8.1") apply false + // END: FlutterFire Configuration + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6d11e45..28f16a8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,29 +1,31 @@ + + + plugins { id("com.android.application") + id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") 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.tracking_app" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + jvmTarget = "17" } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.example.tracking_app" - // 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 @@ -32,13 +34,16 @@ android { 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") } } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..57c8e9a --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "725835190067", + "project_id": "elevate-flower-app", + "storage_bucket": "elevate-flower-app.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:725835190067:android:50a3f907dd986f7ce53846", + "android_client_info": { + "package_name": "com.example.flower_shop" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyB1-EtHvgb14c5UzVggOoJRa6j8oto53Jg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:725835190067:android:1a8871c3f15cdafae53846", + "android_client_info": { + "package_name": "com.example.tracking_app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyB1-EtHvgb14c5UzVggOoJRa6j8oto53Jg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2cc440e..6da2591 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,13 @@ + + + + + + diff --git a/assets/images/bg.svg b/assets/images/bg.svg new file mode 100644 index 0000000..e4aa060 --- /dev/null +++ b/assets/images/bg.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/check-circle.svg b/assets/images/check-circle.svg new file mode 100644 index 0000000..19b88a4 --- /dev/null +++ b/assets/images/check-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/driver_location.png b/assets/images/driver_location.png new file mode 100644 index 0000000..4925894 Binary files /dev/null and b/assets/images/driver_location.png differ diff --git a/assets/images/flower_logo.png b/assets/images/flower_logo.png new file mode 100644 index 0000000..52e92c5 Binary files /dev/null and b/assets/images/flower_logo.png differ diff --git a/assets/images/flowery_location.png b/assets/images/flowery_location.png new file mode 100644 index 0000000..e7d6ad7 Binary files /dev/null and b/assets/images/flowery_location.png differ diff --git a/assets/images/user_location.png b/assets/images/user_location.png new file mode 100644 index 0000000..1bca739 Binary files /dev/null and b/assets/images/user_location.png differ diff --git a/assets/images/whatsapp.png b/assets/images/whatsapp.png new file mode 100644 index 0000000..a45d019 Binary files /dev/null and b/assets/images/whatsapp.png differ diff --git a/assets/screen_shots/Apply 1.png b/assets/screen_shots/Apply 1.png new file mode 100644 index 0000000..2f1dd94 Binary files /dev/null and b/assets/screen_shots/Apply 1.png differ diff --git a/assets/screen_shots/Apply 2.png b/assets/screen_shots/Apply 2.png new file mode 100644 index 0000000..9e3ce90 Binary files /dev/null and b/assets/screen_shots/Apply 2.png differ diff --git a/assets/screen_shots/Edit Profile.png b/assets/screen_shots/Edit Profile.png new file mode 100644 index 0000000..83021b8 Binary files /dev/null and b/assets/screen_shots/Edit Profile.png differ diff --git a/assets/screen_shots/Edit Vehicle.png b/assets/screen_shots/Edit Vehicle.png new file mode 100644 index 0000000..18300ae Binary files /dev/null and b/assets/screen_shots/Edit Vehicle.png differ diff --git a/assets/screen_shots/Emal Verify.png b/assets/screen_shots/Emal Verify.png new file mode 100644 index 0000000..083f330 Binary files /dev/null and b/assets/screen_shots/Emal Verify.png differ diff --git a/assets/screen_shots/Forget Pass.png b/assets/screen_shots/Forget Pass.png new file mode 100644 index 0000000..9faffbb Binary files /dev/null and b/assets/screen_shots/Forget Pass.png differ diff --git a/assets/screen_shots/Home.png b/assets/screen_shots/Home.png new file mode 100644 index 0000000..3d86800 Binary files /dev/null and b/assets/screen_shots/Home.png differ diff --git a/assets/screen_shots/Localization.png b/assets/screen_shots/Localization.png new file mode 100644 index 0000000..f0e0e13 Binary files /dev/null and b/assets/screen_shots/Localization.png differ diff --git a/assets/screen_shots/Login.png b/assets/screen_shots/Login.png new file mode 100644 index 0000000..cd0eb1f Binary files /dev/null and b/assets/screen_shots/Login.png differ diff --git a/assets/screen_shots/New Pass.png b/assets/screen_shots/New Pass.png new file mode 100644 index 0000000..96afb29 Binary files /dev/null and b/assets/screen_shots/New Pass.png differ diff --git a/assets/screen_shots/Order Details 1.png b/assets/screen_shots/Order Details 1.png new file mode 100644 index 0000000..eb93b72 Binary files /dev/null and b/assets/screen_shots/Order Details 1.png differ diff --git a/assets/screen_shots/Order Details 2.png b/assets/screen_shots/Order Details 2.png new file mode 100644 index 0000000..ed077c0 Binary files /dev/null and b/assets/screen_shots/Order Details 2.png differ diff --git a/assets/screen_shots/Order Details 3.png b/assets/screen_shots/Order Details 3.png new file mode 100644 index 0000000..0cde5d5 Binary files /dev/null and b/assets/screen_shots/Order Details 3.png differ diff --git a/assets/screen_shots/Order Details 4.png b/assets/screen_shots/Order Details 4.png new file mode 100644 index 0000000..148f67b Binary files /dev/null and b/assets/screen_shots/Order Details 4.png differ diff --git a/assets/screen_shots/Order Details 5.png b/assets/screen_shots/Order Details 5.png new file mode 100644 index 0000000..b6b4e4c Binary files /dev/null and b/assets/screen_shots/Order Details 5.png differ diff --git a/assets/screen_shots/Order Details 6.png b/assets/screen_shots/Order Details 6.png new file mode 100644 index 0000000..c6dd6b8 Binary files /dev/null and b/assets/screen_shots/Order Details 6.png differ diff --git a/assets/screen_shots/Orders.png b/assets/screen_shots/Orders.png new file mode 100644 index 0000000..89bb0a8 Binary files /dev/null and b/assets/screen_shots/Orders.png differ diff --git a/assets/screen_shots/Profile ar.png b/assets/screen_shots/Profile ar.png new file mode 100644 index 0000000..7884a90 Binary files /dev/null and b/assets/screen_shots/Profile ar.png differ diff --git a/assets/screen_shots/Profile.png b/assets/screen_shots/Profile.png new file mode 100644 index 0000000..16a1361 Binary files /dev/null and b/assets/screen_shots/Profile.png differ diff --git a/assets/screen_shots/Reset Pass.png b/assets/screen_shots/Reset Pass.png new file mode 100644 index 0000000..13db92b Binary files /dev/null and b/assets/screen_shots/Reset Pass.png differ diff --git a/assets/screen_shots/Splash.png b/assets/screen_shots/Splash.png new file mode 100644 index 0000000..e34bfff Binary files /dev/null and b/assets/screen_shots/Splash.png differ diff --git a/assets/translations/ar.json b/assets/translations/ar.json new file mode 100644 index 0000000..390c475 --- /dev/null +++ b/assets/translations/ar.json @@ -0,0 +1,271 @@ +{ + "firstName": "الاسم الأول", + "lastName": "اسم العائلة", + "email": "البريد الإلكتروني", + "password": "كلمة المرور", + "confirmPassword": "تأكيد كلمة المرور", + "phone": "رقم الهاتف", + "gender": "النوع", + "enterFirstName": "أدخل الاسم الأول", + "enterLastName": "أدخل اسم العائلة", + "enterEmail": "أدخل بريدك الإلكتروني", + "enterPassword": "أدخل كلمة المرور", + "enterPhoneNumber": "أدخل رقم الهاتف", + "enterRePassword": "أعد إدخال كلمة المرور", + "femaleGender": "أنثى", + "maleGender": "ذكر", + "femaleValue": "female", + "maleValue": "male", + "createAccount": "بإنشاء حساب، فإنك توافق على", + "termsAndConditions": "الشروط والأحكام", + "alreadyHaveAccount": "لديك حساب بالفعل؟", + "login": "تسجيل الدخول", + "signup": "إنشاء حساب", + "emailRequired": "البريد الإلكتروني مطلوب", + "emailInvalid": "البريد الإلكتروني غير صالح", + "passwordRequired": "كلمة المرور مطلوبة", + "passwordLengthInvalid": "يجب ألا تقل كلمة المرور عن 6 أحرف", + "passwordUpperLetterInvalid": "يجب أن تحتوي على حرف كبير واحد على الأقل", + "passwordLowerLetterInvalid": "يجب أن تحتوي على حرف صغير واحد على الأقل", + "passwordNumbersInvalid": "يجب أن تحتوي على رقم واحد على الأقل", + "passwordSpecialCharInvalid": "يجب أن تحتوي على رمز خاص واحد على الأقل", + "confirmPasswordRequired": "تأكيد كلمة المرور مطلوب", + "passwordsDoNotMatch": "كلمتا المرور غير متطابقتين", + "phoneRequired": "رقم الهاتف مطلوب", + "phoneInvalid": "رقم غير صالح، يجب أن يكون بالصيغة +201XXXXXXXXX", + "firstNameRequired": "الاسم الأول مطلوب", + "lastNameRequired": "اسم العائلة مطلوب", + "nameInvalid": "يجب أن يكون الاسم بين 3 و50 حرفًا", + "genderRequired": "النوع مطلوب", + "loading": "جارٍ التحميل...", + "registrationSuccessful": "تم التسجيل بنجاح", + "ok": "حسناً", + "error": "خطأ", + "success": "نجاح", + "emailVerification": "تأكيد البريد الإلكتروني", + "rememberMe": "تذكرني", + "forgotPassword": "نسيت كلمة المرور؟", + "forgotPasswordTitle": "نسيت كلمة المرور", + "continueAsGuest": "المتابعة كزائر", + "dontHaveAnAccount": "ليس لديك حساب؟", + "signUp": "إنشاء حساب", + "enterYourEmail": "أدخل بريدك الإلكتروني", + "enterYourPassword": "أدخل كلمة المرور", + "associatedEmail": "يرجى إدخال البريد الإلكتروني المرتبط بحسابك", + "userName": "اسم المستخدم", + "newPassword": "كلمة مرور جديدة", + "confirm": "تأكيد", + "continueTxt": "متابعة", + "instruction": "يرجى إدخال الرمز المرسل إلى بريدك الإلكتروني", + "didNotReceive": "لم تستلم الرمز؟", + "resend": "إعادة الإرسال", + "resetPassword": "إعادة تعيين كلمة المرور", + "yourEmailVerified": "تم تأكيد بريدك الإلكتروني", + "check_email_for_verification_code": "تحقق من بريدك الإلكتروني للحصول على رمز التحقق", + "passwordValidation": "يجب ألا تكون كلمة المرور فارغة وأن تحتوي على 6 أحرف على الأقل، بما في ذلك حرف كبير ورقم", + "connectionTimeout": "انتهت مهلة الاتصال", + "noInternet": "لا يوجد اتصال بالإنترنت", + "unauthorized": "طلب غير مصرح به", + "serverError": "حدث خطأ في الخادم", + "unknownError": "حدث خطأ ما، يرجى المحاولة لاحقًا", + "an_error_occurred": "حدث خطأ", + "weakPassword": "كلمة المرور ضعيفة", + "passwordWithCapital": "يجب أن تحتوي على حرف كبير", + "passwordWithNumber": "يجب أن تحتوي على رقم", + "passwordDontMatch": "كلمتا المرور غير متطابقتين", + "confirmPasswordMsg": "أكد كلمة المرور", + "invalidNumber": "رقم هاتف مصري غير صالح", + "required": "مطلوب", + "least3Characters": "يجب ألا يقل عن 3 أحرف", + "least6Characters": "يجب ألا يقل عن 6 أحرف", + "invalidName": "اسم غير صالح", + "phoneNumber": "رقم الهاتف", + "passwordUpdated": "تم تحديث كلمة المرور بنجاح", + "addToCard": "أضف إلى السلة", + "noProductsfound": "لا توجد منتجات", + "viewAll": "عرض الكل", + "search": "بحث", + "categories": "التصنيفات", + "bestSelling": "الأكثر مبيعًا", + "occasions": "المناسبات", + "allPricesIncludeTax": "جميع الأسعار تشمل الضريبة", + "productAddedToCart": "تمت إضافة المنتج إلى السلة", + "something_went_wrong": "حدث خطأ ما", + "cart": "السلة", + "items": "العناصر", + "deliverTo": "التوصيل إلى", + "egp": "جنيه", + "subTotal": "المجموع الفرعي", + "deliveryFee": "رسوم التوصيل", + "total": "الإجمالي", + "checkout": "الدفع", + "productDeletedSuccessfully": "تم حذف المنتج بنجاح", + "productUpdated": "تم تحديث المنتج", + "currentPassword": "كلمة المرور الحالية", + "enterCurrentPassword": "أدخل كلمة المرور الحالية", + "enterNewPassword": "أدخل كلمة مرور جديدة", + "confirmNewPassword": "تأكيد كلمة المرور الجديدة", + "update": "تحديث", + "changePassword": "تغيير كلمة المرور", + "no_products_found": "لا توجد منتجات", + "change_language": "تغيير اللغة", + "arabic": "العربية", + "english": "الإنجليزية", + "initialSearchMsg": "ابحث عن المنتج الذي تريده", + "welcomeMessage": "مرحبًا بك في متجر Flowery", + "home": "الرئيسية", + "profile": "الملف الشخصي", + "defaultErrorMessage": "حدث خطأ ما، يرجى المحاولة لاحقًا", + "bestseller": "الأكثر مبيعًا", + "sessionExpiredMessage": "انتهت الجلسة، يرجى تسجيل الدخول مرة أخرى", + "notificationsKey": "تم تفعيل الإشعارات", + "noProfileFound": "لا يوجد ملف شخصي", + "register": "تسجيل", + "pleaseLoginToAccessProfile": "يرجى تسجيل الدخول للوصول إلى ملفك الشخصي", + "aboutUs": "من نحن", + "language": "الغة", + "notifications": "الإشعارات", + "savedAddresses": "العناوين المحفوظة", + "myOrders": "طلباتي", + "noName": "بدون اسم", + "noEmail": "بدون بريد إلكتروني", + "editProfile": "تعديل الملف الشخصي", + "logout": "تسجيل الخروج", + "logoutFailed": "فشل تسجيل الخروج", + "order_success": "تم تنفيذ الطلب بنجاح", + "failed_load_addresses": "فشل تحميل العناوين", + "no_addresses": "لا توجد عناوين", + "order_status": "حالة الطلب", + "delivered": "تم التوصيل", + "paid": "مدفوع", + "pending": "قيد الانتظار", + "instant_delivery_info": "التوصيل الفوري متاح", + "schedule": "جدولة", + "delivery_address": "عنوان التوصيل", + "add_new": "إضافة جديد", + "payment_method": "طريقة الدفع", + "cash_on_delivery": "الدفع عند الاستلام", + "credit_card": "بطاقة ائتمان", + "it_is_a_gift": "هذه هدية", + "recipient_name": "اسم المستلم", + "recipient_phone": "رقم هاتف المستلم", + "place_order": "إتمام الطلب", + "instant": "فوري ", + "arrive_by_datetime": "يصل بحلول 03 سبتمبر 2026، 11 صباحًا", + "in_cart": "في السلة", + "invalidRecipientName": "اسم المستلم غير صحيح", + "invalidAddress": "العنوان غير صحيح", + "requiredRecipientName": "يرجى إدخال اسم المستلم", + "requiredAddress": "يرجى إدخال العنوان", + "requiredCity": "يرجى إدخال المدينه", + "requiredArea": "يرجى إدخال المنطقه", + "address": "العنوان", + "enter_address": "أدخل العنوان", + "phone_number": "رقم الهاتف", + "enter_phone_number": "أدخل رقم الهاتف", + "enter_recipient_name": "أدخل اسم المستلم", + "save_address": "حفظ العنوان", + "area": "المنطقة", + "city": "المدينة", + "location_permission": "إذن الموقع", + "location_service_off_message": "يرجى تمكين خدمات الموقع (GPS) لاختيار عنوانك على الخريطة.", + "location_permission_denied_forever_message": "تم رفض إذن الموقع بشكل دائم. يرجى تمكينه من الإعدادات.", + "location_permission_denied_message": "نحتاج إلى إذن الموقع لتحديد عنوانك بدقة على الخريطة.", + "open_settings": "فتح الإعدادات", + "open_location_settings": "فتح إعدادات الموقع", + "allow_location": "السماح بالموقع", + "move_map_to_choose_location": "حرك الخريطة لاختيار الموقع", + "address_saved_successfully": "تم حفظ العنوان بنجاح", + "failed_to_save_address": "فشل حفظ العنوان", + "addNewAddress": "إضافة عنوان جديد", + "savedAddress": "تم حفظ العنوان", + "sortBy": "ترتيب حسب", + "lowestPrice": "أدنى سعر", + "highestPrice": "أعلى سعر", + "newest": "الأحدث", + "oldest": "الأقدم", + "discount": "الخصومات", + "filter": "فلتر", + "active": "نشط", + "completed": "مكتمل", + "no_orders_found": "لا توجد طلبات", + "track_order": "تتبع الطلب", + "order_number": "رقم الطلب#", + "all_notifications_cleared": "تم مسح جميع الإشعارات", + "notification_deleted_successfully": "تم حذف الإشعار بنجاح", + "clear_all": "مسح الكل", + "no_notifications_yet": "لا توجد اشعارات حاليا", + "orders": "الطلبات", + "onboardingTitle": "مرحبًا بك ", + "onboardingDescription": "في تطبيق سائق Flowery", + "applyNow": "قدّم الآن", + "wrongEmailOrPassword": "البريد الإلكتروني أو كلمة المرور غير صحيحة", + "apply": "التقديم", + "welcomeApply": "مرحباً!!", + "joinTeamMessage": "هل تريد أن تكون عامل توصيل؟\\nانضم إلى فريقنا", + "country": "الدولة", + "firstLegalName": "الاسم القانوني الأول", + "enterFirstLegalName": "أدخل الاسم القانوني الأول", + "secondLegalName": "الاسم القانوني الثاني", + "enterSecondLegalName": "أدخل الاسم القانوني الثاني", + "vehicleType": "نوع المركبة", + "vehicleNumber": "رقم المركبة", + "enterVehicleNumber": "أدخل رقم المركبة", + "vehicleLicense": "رخصة المركبة", + "uploadLicensePhoto": "تحميل صورة الرخصة", + "idNumber": "رقم الهوية", + "enterNationalId": "أدخل رقم الهوية الوطنية", + "idImage": "صورة الهوية", + "uploadIdImage": "تحميل صورة الهوية", + "female": "أنثى", + "male": "ذكر", + "continueText": "متابعة", + "requiredField": "مطلوب", + "licensePhotoRequired": "صورة الرخصة مطلوبة", + "idImageRequired": "صورة الهوية مطلوبة", + "failedToLoadCountries": "فشل تحميل الدول", + "failedToLoadVehicles": "فشل تحميل أنواع المركبات", + "applicationSubmittedSuccessfully": "تم تقديم الطلب بنجاح!", + "submissionFailed": "فشل التقديم", + "applicationSubmitted": "تم تقديم الطلب!", + "congratulationsMessage": "تهانينا! تم تقديم طلبك بنجاح.", + "reviewMessage": "سنقوم بمراجعة طلبك والرد عليك قريباً عبر البريد الإلكتروني.", + "backToLogin": "العودة إلى تسجيل الدخول", + "checkEmailMessage": "تحقق من بريدك الإلكتروني بانتظام للحصول على تحديثات حول حالة طلبك.", + "change": "تغيير", + "vehicle_type": "نوع المركبة", + "vehicle_number": "رقم المركبة", + "vehicle_license": "رخصة المركبة", + "editDriverProfile": "تعديل الملف الشخصي", + "editVehicle": "تعديل المركبة", + "cannotBeSame": "كلمة المرور الجديدة لا يجب أن تطابق الحالية", + "orderDetails": "بيانات الطلب", + "status": "الحالة", + "orderId": "رقم الطلب : ", + "pickupAddress": "عنوان الاستلام", + "floweryStore": "متجر فلوري", + "userAddress": "عنوان المستخدم", + "arrivedAtPickupPoint": "وصلت الى نقطة الالتقاء", + "startDelivery": "بدء التوصيل", + "arriverAtDestination": "وصلت إلى نقطة التسليم", + "confirmDelivery": "تأكيد التسليم", + "deliveryConfirmed": "تم تأكيد التسليم", + "orderCompleted": "تم إكمال الطلب", + "accepted": "مقبول", + "pickedUp": "تم الاستلام", + "outForDelivery": "في الطريق للتسليم", + "arrived": "وصلت", + "driverOrderTitle": "طلب زهور", + "unknownStore": "متجر غير معروف", + "noAddress": "لا يوجد عنوان", + "accept": "قبول", + "reject": "رفض", + "noPendingOrders": "لا توجد طلبات معلقة", + "floweryRider": "سائق فلاوري", + "btnArrivedAtPickupPoint": "وصلت الى نقطة الالتقاء", + "btnStartDeliver": "بدء التوصيل", + "btnArrivedToUser": "وصلت إلى المستخدم", + "btnDeliveredToUser": "تم التوصيل للمستخدم", + "finishYourOrder": "عليك انهاء توصيل الطلب اولا" + +} \ No newline at end of file diff --git a/assets/translations/en.json b/assets/translations/en.json new file mode 100644 index 0000000..20db732 --- /dev/null +++ b/assets/translations/en.json @@ -0,0 +1,273 @@ +{ + "firstName": "First Name", + "lastName": "Last Name", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "phone": "Phone Number", + "gender": "Gender", + "enterFirstName": "Enter first name", + "enterLastName": "Enter last name", + "enterEmail": "Enter your email", + "enterPassword": "Enter password", + "enterPhoneNumber": "Enter phone number", + "enterRePassword": "Re-enter password", + "femaleGender": "Female", + "maleGender": "Male", + "femaleValue": "female", + "maleValue": "male", + "createAccount": "By creating an account, you agree to", + "termsAndConditions": "our Terms & Conditions", + "alreadyHaveAccount": "Already have an account?", + "login": "Login", + "signup": "Sign Up", + "emailRequired": "Email is required", + "emailInvalid": "This email is not valid", + "passwordRequired": "Password is required", + "passwordLengthInvalid": "At least 6 characters required", + "passwordUpperLetterInvalid": "Use at least one uppercase letter", + "passwordLowerLetterInvalid": "Use at least one lowercase letter", + "passwordNumbersInvalid": "Use at least one number", + "passwordSpecialCharInvalid": "Use at least one special character", + "confirmPasswordRequired": "Confirm password is required", + "passwordsDoNotMatch": "Passwords do not match", + "phoneRequired": "Phone number is required", + "phoneInvalid": "Invalid number, must be in the format +201XXXXXXXXX", + "firstNameRequired": "First name is required", + "lastNameRequired": "Last name is required", + "nameInvalid": "Name must be between 3-50 characters", + "genderRequired": "Gender is required", + "loading": "Loading...", + "registrationSuccessful": "Registration successful", + "ok": "OK", + "error": "Error", + "success": "Success", + "emailVerification": "Email verification", + "rememberMe": "Remember me", + "forgotPassword": "Forgot Password?", + "forgotPasswordTitle": "Forgot Password", + "continueAsGuest": "Continue as Guest", + "dontHaveAnAccount": "Don't have an account?", + "signUp": "Sign Up", + "enterYourEmail": "Enter your email", + "enterYourPassword": "Enter your password", + "associatedEmail": "Please enter the email associated with your account", + "userName": "User Name", + "newPassword": "New Password", + "confirm": "Confirm", + "continueTxt": "Continue", + "instruction": "Please enter the code sent to your email address", + "didNotReceive": "Didn't receive code?", + "resend": "Resend", + "resetPassword": "Reset Password", + "yourEmailVerified": "Your email verified", + "check_email_for_verification_code": "Check your email for verification code", + "passwordValidation": "Password must not be empty and must contain at least 6 characters, including one uppercase letter and one number.", + "connectionTimeout": "Connection timed out", + "noInternet": "No internet connection", + "unauthorized": "Unauthorized request", + "serverError": "Server error occurred", + "unknownError": "Something went wrong, please try again later", + "an_error_occurred": "An error occurred", + "weakPassword": "Password too weak", + "passwordWithCapital": "Must contain a capital letter", + "passwordWithNumber": "Must contain a number", + "passwordDontMatch": "Passwords do not match", + "confirmPasswordMsg": "Confirm your password", + "invalidNumber": "Invalid Egyptian phone number", + "required": "Required", + "least3Characters": "Must be at least 3 characters", + "least6Characters": "At least 6 characters required", + "invalidName": "Invalid name", + "phoneNumber": "Phone number", + "passwordUpdated": "Password updated successfully", + "addToCard": "Add to cart", + "noProductsfound": "No products found", + "viewAll": "View All", + "search": "Search", + "categories": "Categories", + "bestSelling": "Best Selling", + "occasions": "Occasions", + "allPricesIncludeTax": "All prices include tax", + "productAddedToCart": "Product added to cart", + "something_went_wrong": "Something went wrong", + "cart": "Cart", + "items": "Items", + "deliverTo": "Deliver to", + "egp": "EGP", + "subTotal": "Sub Total", + "deliveryFee": "Delivery Fee", + "total": "Total", + "checkout": "Checkout", + "productDeletedSuccessfully": "Product deleted successfully", + "productUpdated": "Product updated", + "currentPassword": "Current Password", + "enterCurrentPassword": "Enter current password", + "enterNewPassword": "Enter new password", + "confirmNewPassword": "Confirm new password", + "update": "Update", + "changePassword": "Change Password", + "profileUpdatedSuccessfully": "Profile updated successfully", + "tokenNotFound": "Authentication token not found. Please login again.", + "editProfile": "Edit Profile", + "no_products_found": "No products found", + "change_language": "Change Language", + "arabic": "Arabic", + "english": "English", + "initialSearchMsg": "Search For Any Product You Want", + "welcomeMessage": "Welcome to Flowery Shop", + "home": "Home", + "profile": "Profile", + "defaultErrorMessage": "Something went wrong, please try again later", + "bestseller": "Best Sellers", + "sessionExpiredMessage": "Session expired. Please login again.", + "notificationsKey": "Notifications enabled", + "noProfileFound": "No profile found", + "register": "Register", + "pleaseLoginToAccessProfile": "You need to login to access your profile", + "aboutUs": "About Us", + "language": "Language", + "notifications": "Notifications", + "savedAddresses": "Saved Addresses", + "myOrders": "My Orders", + "noName": "No Name", + "noEmail": "No Email", + "logout": "Logout", + "logoutFailed": "Logout failed", + "order_success": "Your order has been placed successfully", + "failed_load_addresses": "Failed to load addresses", + "no_addresses": "No addresses found", + "order_status": "Order Status", + "delivered": "Delivered", + "paid": "Paid", + "pending": "Pending", + "instant_delivery_info": "Instant delivery available", + "schedule": "Schedule", + "delivery_address": "Delivery Address", + "add_new": "Add New", + "payment_method": "Payment Method", + "cash_on_delivery": "Cash on Delivery", + "credit_card": "Credit Card", + "it_is_a_gift": "It is a gift", + "Recipient_phone": "Recipient phone number", + "place_order": "Place Order", + "instant": "Instant ", + "arrive_by_datetime": "Arrive by 03 sep 2026, 11 AM", + "in_cart": "In Cart", + "invalidRecipientName": "Invalid Recipient Name", + "invalidAddress": "Invalid Address", + "requiredRecipientName": "Required Recipient Name", + "requiredAddress": "Required Address", + "requiredCity": "Required city", + "requiredArea": "Required Area", + "address": "Address", + "enter_address": "Enter the address", + "phone_number": "Phone number", + "enter_phone_number": "Enter the phone number", + "recipient_name": "Recipient name", + "enter_recipient_name": "Enter the recipient name", + "save_address": "Save address", + "area": "Area", + "city": "City", + "location_permission": "Location Permission", + "location_service_off_message": "Please enable Location Services (GPS) to pick your address on the map.", + "location_permission_denied_forever_message": "Location permission is permanently denied. Please enable it from Settings.", + "location_permission_denied_message": "We need location permission to select your address precisely on the map.", + "open_settings": "Open Settings", + "open_location_settings": "Open Location Settings", + "allow_location": "Allow Location", + "move_map_to_choose_location": "Move map to choose location", + "address_saved_successfully": "Address saved successfully", + "failed_to_save_address": "Failed to save address", + "addNewAddress": "Add New Address", + "savedAddress": "Saved Address", + "recipient_phone": "Recipient phone", + "sortBy": "Sort By", + "lowestPrice": "Lowest Price", + "highestPrice": "Highest Price", + "newest": "Newest", + "oldest": "Oldest", + "discount": "Discounts", + "filter": "Filter", + "active": "Active", + "completed": "Completed", + "no_orders_found": "No orders found", + "track_order": "Track order", + "order_number": "Order number#", + "all_notifications_cleared": "All notifications cleared", + "notification_deleted_successfully": "Notification deleted successfully", + "clear_all": "Clear all", + "no_notifications_yet": "No Notifications Yet", + "orders": "Orders", + "onboardingTitle": "Welcome to ", + "onboardingDescription": "Flowery rider app ", + "applyNow": "Apply Now", + "wrongEmailOrPassword": "Wrong email or password", + "apply": "Apply", + "welcomeApply": "Welcome!!", + "joinTeamMessage": "You want to be a delivery man?\nJoin our team", + "country": "Country", + "firstLegalName": "First legal name", + "enterFirstLegalName": "Enter first legal name", + "secondLegalName": "Second legal name", + "enterSecondLegalName": "Enter second legal name", + "vehicleType": "Vehicle type", + "vehicleNumber": "Vehicle number", + "enterVehicleNumber": "Enter vehicle number", + "vehicleLicense": "Vehicle license", + "uploadLicensePhoto": "Upload license photo", + "idNumber": "ID number", + "enterNationalId": "Enter national ID number", + "idImage": "ID image", + "uploadIdImage": "Upload ID image", + "female": "Female", + "male": "Male", + "continueText": "Continue", + "requiredField": "Required", + "licensePhotoRequired": "License photo is required", + "idImageRequired": "ID image is required", + "failedToLoadCountries": "Failed to load countries", + "failedToLoadVehicles": "Failed to load vehicles", + "applicationSubmittedSuccessfully": "Application submitted successfully!", + "submissionFailed": "Submission failed", + "applicationSubmitted": "Application Submitted!", + "congratulationsMessage": "Congratulations! Your application has been submitted successfully.", + "reviewMessage": "We will review your application and get back to you soon via email.", + "backToLogin": "Back to Login", + "checkEmailMessage": "Check your email regularly for updates on your application status.", + "change": "Change", + "vehicle_type": "Vehicle Type", + "vehicle_number": "Vehicle Number", + "vehicle_license": "Vehicle License", + "editDriverProfile": "Edit Driver Profile", + "editVehicle": "Edit Vehicle", + "cannotBeSame": "New password cann't be same", + "orderDetails": "Order details", + "status": "Status : ", + "orderId": "Order ID : # ", + "pickupAddress": "Pickup address", + "floweryStore": "Flowery Store", + "userAddress": "User address", + "arrivedAtPickupPoint": "Arrived at Pickup point", + "startDelivery": "Start deliver", + "arriverAtDestination": "Arrived to the user", + "confirmDelivery": "Delivered to the user", + "deliveryConfirmed": "Delivered to the user", + "orderCompleted": "Delivered", + "accepted": "Accepted", + "pickedUp": "Picked", + "outForDelivery": "Out for delivery", + "arrived": "Arrived", + "driverOrderTitle": "Flower order", + "unknownStore": "Unknown Store", + "noAddress": "No address", + "accept": "Accept", + "reject": "Reject", + "noPendingOrders": "No pending orders", + "floweryRider": "Flowery Rider", + "btnArrivedAtPickupPoint": "Arrived at Pickup point", + "btnStartDeliver": "Start deliver", + "btnArrivedToUser": "Arrived to the user", + "btnDeliveredToUser": "Delivered to the user", + "finishYourOrder": "You must finish the order first" +} \ 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/firebase.json b/firebase.json new file mode 100644 index 0000000..2f7aab7 --- /dev/null +++ b/firebase.json @@ -0,0 +1,21 @@ +{ + "flutter": { + "platforms": { + "android": { + "default": { + "projectId": "elevate-flower-app", + "appId": "1:725835190067:android:1a8871c3f15cdafae53846", + "fileOutput": "android/app/google-services.json" + } + }, + "dart": { + "lib/firebase_options.dart": { + "projectId": "elevate-flower-app", + "configurations": { + "web": "1:725835190067:web:86225b1572d53a90e53846" + } + } + } + } + } +} \ No newline at end of file diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..7cf9b6a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import Flutter import UIKit +import GoogleMaps @main @objc class AppDelegate: FlutterAppDelegate { @@ -7,6 +8,7 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + GMSServices.provideAPIKey("AIzaSyBRplvYc2qNr0KuGUndmcJQHiVdBLIO1IA") GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d6b8652..a6a1641 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -45,5 +45,11 @@ UIApplicationSupportsIndirectInputEvents + NSPhotoLibraryUsageDescription + This app requires access to the photo library to upload vehicle license and ID images. + NSCameraUsageDescription + This app requires access to the camera to take photos of vehicle license and ID. + NSMicrophoneUsageDescription + This app requires access to the microphone for video recording. diff --git a/lib/app/config/auth_storage/auth_storage.dart b/lib/app/config/auth_storage/auth_storage.dart new file mode 100644 index 0000000..d9b7748 --- /dev/null +++ b/lib/app/config/auth_storage/auth_storage.dart @@ -0,0 +1,75 @@ +import 'package:injectable/injectable.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +@lazySingleton +class AuthStorage { + static const _tokenKey = 'auth_token'; + static const _userKey = 'user_data'; + static const _rememberMeKey = 'remember_me'; + static const _orderIdKey = 'order_id'; + + Future get _prefs async => + await SharedPreferences.getInstance(); + + Future saveOrderId(String orderId) async { + final prefs = await _prefs; + await prefs.setString(_orderIdKey, orderId); + } + + Future getOrderId() async { + final prefs = await _prefs; + return prefs.getString(_orderIdKey); + } + + Future clearOrderId() async { + final prefs = await _prefs; + await prefs.remove(_orderIdKey); + } + + Future saveToken(String token) async { + final prefs = await _prefs; + await prefs.setString(_tokenKey, token); + } + + Future getToken() async { + final prefs = await _prefs; + return prefs.getString(_tokenKey); + } + + Future clearToken() async { + final prefs = await _prefs; + await prefs.remove(_tokenKey); + } + + Future saveUserJson(String json) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_userKey, json); + } + + Future getUserJson() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_userKey); + } + + Future clearUser() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_userKey); + } + + Future setRememberMe(bool value) async { + final prefs = await _prefs; + await prefs.setBool(_rememberMeKey, value); + } + + Future getRememberMe() async { + final prefs = await _prefs; + return prefs.getBool(_rememberMeKey) ?? false; + } + + Future clearAll() async { + await clearToken(); + await clearUser(); + await setRememberMe(false); + await clearOrderId(); + } +} diff --git a/lib/app/config/base_state/base_state.dart b/lib/app/config/base_state/base_state.dart new file mode 100644 index 0000000..dce2e34 --- /dev/null +++ b/lib/app/config/base_state/base_state.dart @@ -0,0 +1,28 @@ +enum Status { loading, success, error, initial } + +class Resource { + final Status status; + T? data; + String? error; + + String? message; + Resource({required this.status, this.data, this.error}); + + factory Resource.success(T? data) { + return Resource(status: Status.success, data: data); + } + factory Resource.loading() { + return Resource(status: Status.loading); + } + factory Resource.error(String error) { + return Resource(status: Status.error, error: error); + } + factory Resource.initial() { + return Resource(status: Status.initial); + } + + bool get isSuccess => status == Status.success; + bool get isLoading => status == Status.loading; + bool get isError => status == Status.error; + bool get isInitial => status == Status.initial; +} diff --git a/lib/app/config/di/di.config.dart b/lib/app/config/di/di.config.dart new file mode 100644 index 0000000..4891c79 --- /dev/null +++ b/lib/app/config/di/di.config.dart @@ -0,0 +1,351 @@ +// dart format width=80 +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// InjectableConfigGenerator +// ************************************************************************** + +// ignore_for_file: type=lint +// coverage:ignore-file + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:cloud_firestore/cloud_firestore.dart' as _i974; +import 'package:dio/dio.dart' as _i361; +import 'package:get_it/get_it.dart' as _i174; +import 'package:injectable/injectable.dart' as _i526; + +import '../../../features/app_sections/presentation/manager/app_section_cubit.dart' + as _i959; +import '../../../features/auth/api/datasource/auth_remote_datasource_impl.dart' + as _i777; +import '../../../features/auth/data/datasource/auth_remote_datasource.dart' + as _i708; +import '../../../features/auth/data/datasource/country_local_datasource.dart' + as _i783; +import '../../../features/auth/data/repos/auth_repo_impl.dart' as _i566; +import '../../../features/auth/domain/repos/auth_repo.dart' as _i712; +import '../../../features/auth/domain/usecase/apply_usecase.dart' as _i412; +import '../../../features/auth/domain/usecase/change_password_usecase.dart' + as _i991; +import '../../../features/auth/domain/usecase/forgetpassword_usecase.dart' + as _i769; +import '../../../features/auth/domain/usecase/get_all_vehicles_usecase.dart' + as _i1015; +import '../../../features/auth/domain/usecase/get_countries_usecase.dart' + as _i940; +import '../../../features/auth/domain/usecase/login_usecase.dart' as _i75; +import '../../../features/auth/domain/usecase/logout_usecase.dart' as _i27; +import '../../../features/auth/domain/usecase/resertpassword_usecase.dart' + as _i294; +import '../../../features/auth/domain/usecase/verifyreaset_usecase.dart' + as _i112; +import '../../../features/auth/presentation/apply/manager/apply_cubit.dart' + as _i377; +import '../../../features/auth/presentation/forget_pass/manager/cubit/forget_pass_cubit.dart' + as _i614; +import '../../../features/auth/presentation/login/manager/login_cubit.dart' + as _i810; +import '../../../features/auth/presentation/logout/manager/logout_cubit.dart' + as _i1023; +import '../../../features/auth/presentation/reset_password/manager/change_password_cubit.dart' + as _i14; +import '../../../features/auth/presentation/reset_password/manager/reset_password_cubit.dart' + as _i378; +import '../../../features/auth/presentation/verify_reset/manger/cubit/verify_reset_cubit.dart' + as _i466; +import '../../../features/driver_orders_details/api/datasource/order_details_remote_datasource_impl.dart' + as _i860; +import '../../../features/driver_orders_details/data/datasource/order_details_remote_datasource.dart' + as _i114; +import '../../../features/driver_orders_details/data/repos/order_details_repo_impl.dart' + as _i55; +import '../../../features/driver_orders_details/domain/repos/order_details_repo.dart' + as _i313; +import '../../../features/driver_orders_details/domain/usecases/get_address_usecase.dart' + as _i453; +import '../../../features/driver_orders_details/domain/usecases/get_driver_data_usecase.dart' + as _i883; +import '../../../features/driver_orders_details/domain/usecases/get_order_details_usecase.dart' + as _i1045; +import '../../../features/driver_orders_details/domain/usecases/get_real_route_usecase.dart' + as _i707; +import '../../../features/driver_orders_details/domain/usecases/push_notification_usecase.dart' + as _i809; +import '../../../features/driver_orders_details/domain/usecases/send_device_notification_usecase.dart' + as _i44; +import '../../../features/driver_orders_details/domain/usecases/update_driver_location_usecase.dart' + as _i294; +import '../../../features/driver_orders_details/domain/usecases/update_order_state_usecase.dart' + as _i727; +import '../../../features/driver_orders_details/presentation/manager/order_details_cubit.dart' + as _i375; +import '../../../features/home/api/driverOrderDataS_imp.dart' as _i495; +import '../../../features/home/data/datascourse/driverOrderDatascource.dart' + as _i743; +import '../../../features/home/data/repo/driverOrderRepo_impl.dart' as _i1020; +import '../../../features/home/domain/repo/driverOrderRepo.dart' as _i499; +import '../../../features/home/domain/usecase/getdriverOrderUsecase.dart' + as _i858; +import '../../../features/home/domain/usecase/upload_driver_fire_data_use_case.dart' + as _i329; +import '../../../features/home/domain/usecase/upload_order_fire_data_use_case.dart' + as _i233; +import '../../../features/home/presentation/manger/driverorderCubit.dart' + as _i573; +import '../../../features/my_orders/api/datasource/my_orders_remote_data_source_imp.dart' + as _i583; +import '../../../features/my_orders/data/datasource/my_orders_remote_data_source.dart' + as _i466; +import '../../../features/my_orders/data/repo/my_orders_repo_imp.dart' as _i754; +import '../../../features/my_orders/domain/repo/my_orders_repo.dart' as _i919; +import '../../../features/my_orders/domain/usecases/get_order_use_case.dart' + as _i335; +import '../../../features/my_orders/presentation/manager/my_orders_cubit.dart' + as _i156; +import '../../../features/profile/api/profile_lacal_datasource_imp.dart' + as _i495; +import '../../../features/profile/api/profile_remote_datasource_imp.dart' + as _i899; +import '../../../features/profile/data/datasorce/profile_lacal_datasource.dart' + as _i697; +import '../../../features/profile/data/datasorce/profile_remote_datasource.dart' + as _i943; +import '../../../features/profile/data/repo/profile_repo_imp.dart' as _i1048; +import '../../../features/profile/domain/repo/profile_repo.dart' as _i863; +import '../../../features/profile/domain/usecases/edit_profile_usecase.dart' + as _i221; +import '../../../features/profile/domain/usecases/get_profile_usecase.dart' + as _i248; +import '../../../features/profile/domain/usecases/upload_profile_photo_usecase.dart' + as _i884; +import '../../../features/profile/presentation/managers/profile_cubit.dart' + as _i603; +import '../../core/api_manger/api_client.dart' as _i890; +import '../auth_storage/auth_storage.dart' as _i603; +import '../network/firebase_module.dart' as _i383; +import '../network/network_module.dart' as _i200; + +extension GetItInjectableX on _i174.GetIt { + // initializes the registration of main-scope dependencies inside of GetIt + _i174.GetIt init({ + String? environment, + _i526.EnvironmentFilter? environmentFilter, + }) { + final gh = _i526.GetItHelper(this, environment, environmentFilter); + final firebaseModule = _$FirebaseModule(); + final networkModule = _$NetworkModule(); + gh.factory<_i959.AppSectionCubit>(() => _i959.AppSectionCubit()); + gh.lazySingleton<_i603.AuthStorage>(() => _i603.AuthStorage()); + gh.lazySingleton<_i974.FirebaseFirestore>(() => firebaseModule.firestore); + gh.lazySingleton<_i783.CountryLocalDataSource>( + () => _i783.CountryLocalDataSourceImpl(), + ); + gh.lazySingleton<_i974.FirebaseFirestore>( + () => networkModule.firestore, + instanceName: 'firestore', + ); + gh.lazySingleton<_i361.Dio>( + () => networkModule.dio(gh<_i603.AuthStorage>()), + ); + gh.factory<_i329.UploadDriverFireDataUseCase>( + () => _i329.UploadDriverFireDataUseCase( + gh<_i974.FirebaseFirestore>(instanceName: 'firestore'), + ), + ); + gh.factory<_i233.UploadOrderFireDataUseCase>( + () => _i233.UploadOrderFireDataUseCase( + gh<_i974.FirebaseFirestore>(instanceName: 'firestore'), + ), + ); + gh.factory<_i114.OrderDetailsRemoteDatasource>( + () => _i860.OrderDetailsRemoteDatasourceImpl( + firestore: gh<_i974.FirebaseFirestore>(), + dio: gh<_i361.Dio>(), + ), + ); + gh.lazySingleton<_i697.ProfileLocalDataSource>( + () => _i495.ProfileLocalDataSourceImpl(gh<_i603.AuthStorage>()), + ); + gh.lazySingleton<_i890.ApiClient>( + () => networkModule.authApiClient(gh<_i361.Dio>()), + ); + gh.factory<_i466.MyOrdersRemoteDataSource>( + () => _i583.MyOrdersRemoteDataSourceImp(gh<_i890.ApiClient>()), + ); + gh.factory<_i313.OrderDetailsRepo>( + () => _i55.OrderDetailsRepoImpl( + gh<_i114.OrderDetailsRemoteDatasource>(), + gh<_i603.AuthStorage>(), + ), + ); + gh.factory<_i453.GetAddressUsecase>( + () => _i453.GetAddressUsecase(gh<_i313.OrderDetailsRepo>()), + ); + gh.factory<_i707.GetRealRouteUsecase>( + () => _i707.GetRealRouteUsecase(gh<_i313.OrderDetailsRepo>()), + ); + gh.factory<_i294.UpdateDriverLocationUsecase>( + () => _i294.UpdateDriverLocationUsecase(gh<_i313.OrderDetailsRepo>()), + ); + gh.factory<_i919.MyOrdersRepo>( + () => _i754.MyOrdersRepoImpl(gh<_i466.MyOrdersRemoteDataSource>()), + ); + gh.factory<_i335.GetOrderUseCase>( + () => _i335.GetOrderUseCase(gh<_i919.MyOrdersRepo>()), + ); + gh.factory<_i883.GetDriverDataUsecase>( + () => _i883.GetDriverDataUsecase(repo: gh<_i313.OrderDetailsRepo>()), + ); + gh.factory<_i1045.GetOrderDetailsUsecase>( + () => _i1045.GetOrderDetailsUsecase(repo: gh<_i313.OrderDetailsRepo>()), + ); + gh.factory<_i809.PushNotificationUsecase>( + () => _i809.PushNotificationUsecase(repo: gh<_i313.OrderDetailsRepo>()), + ); + gh.factory<_i44.SendDeviceNotificationUsecase>( + () => _i44.SendDeviceNotificationUsecase( + repo: gh<_i313.OrderDetailsRepo>(), + ), + ); + gh.factory<_i727.UpdateOrderStateUsecase>( + () => _i727.UpdateOrderStateUsecase(repo: gh<_i313.OrderDetailsRepo>()), + ); + gh.factory<_i743.DriverOrderDataSource>( + () => _i495.DriverOrderDataSourceImpl(gh<_i890.ApiClient>()), + ); + gh.factory<_i943.ProfileRemoteDatasource>( + () => _i899.ProfileRemoteDatasourceImp(gh<_i890.ApiClient>()), + ); + gh.factory<_i708.AuthRemoteDataSource>( + () => _i777.AuthRemoteDataSourceImpl(gh<_i890.ApiClient>()), + ); + gh.factory<_i712.AuthRepo>( + () => _i566.AuthRepoImpl(gh<_i708.AuthRemoteDataSource>()), + ); + gh.factory<_i375.OrderDetailsCubit>( + () => _i375.OrderDetailsCubit( + gh<_i1045.GetOrderDetailsUsecase>(), + gh<_i883.GetDriverDataUsecase>(), + gh<_i453.GetAddressUsecase>(), + gh<_i707.GetRealRouteUsecase>(), + gh<_i294.UpdateDriverLocationUsecase>(), + gh<_i727.UpdateOrderStateUsecase>(), + gh<_i809.PushNotificationUsecase>(), + gh<_i44.SendDeviceNotificationUsecase>(), + ), + ); + gh.factory<_i991.ChangePasswordUsecase>( + () => _i991.ChangePasswordUsecase(gh<_i712.AuthRepo>()), + ); + gh.factory<_i769.ForgetPasswordUsecase>( + () => _i769.ForgetPasswordUsecase(gh<_i712.AuthRepo>()), + ); + gh.factory<_i294.ResetPasswordUsecase>( + () => _i294.ResetPasswordUsecase(gh<_i712.AuthRepo>()), + ); + gh.factory<_i112.VerifyResetCodeUsecase>( + () => _i112.VerifyResetCodeUsecase(gh<_i712.AuthRepo>()), + ); + gh.factoryParam<_i466.VerifyResetCodeCubit, String, dynamic>( + (email, _) => _i466.VerifyResetCodeCubit( + gh<_i112.VerifyResetCodeUsecase>(), + gh<_i769.ForgetPasswordUsecase>(), + email, + ), + ); + gh.factory<_i156.MyOrdersCubit>( + () => _i156.MyOrdersCubit( + gh<_i335.GetOrderUseCase>(), + gh<_i603.AuthStorage>(), + ), + ); + gh.factoryParam<_i378.ResetPasswordCubit, String, dynamic>( + (email, _) => + _i378.ResetPasswordCubit(email, gh<_i294.ResetPasswordUsecase>()), + ); + gh.factory<_i499.DriverOrderRepo>( + () => _i1020.DriverOrderRepositoryImpl(gh<_i743.DriverOrderDataSource>()), + ); + gh.factory<_i863.ProfileRepo>( + () => _i1048.ProfileRepoImpl( + gh<_i943.ProfileRemoteDatasource>(), + gh<_i697.ProfileLocalDataSource>(), + ), + ); + gh.lazySingleton<_i412.ApplyUseCase>( + () => _i412.ApplyUseCase(gh<_i712.AuthRepo>()), + ); + gh.lazySingleton<_i1015.GetAllVehiclesUseCase>( + () => _i1015.GetAllVehiclesUseCase(gh<_i712.AuthRepo>()), + ); + gh.factory<_i940.GetCountriesUseCase>( + () => _i940.GetCountriesUseCase(gh<_i712.AuthRepo>()), + ); + gh.factory<_i75.LoginUseCase>( + () => _i75.LoginUseCase(gh<_i712.AuthRepo>()), + ); + gh.factory<_i27.LogoutUseCase>( + () => _i27.LogoutUseCase(gh<_i712.AuthRepo>()), + ); + gh.factory<_i14.ChangePasswordCubit>( + () => _i14.ChangePasswordCubit( + gh<_i991.ChangePasswordUsecase>(), + gh<_i603.AuthStorage>(), + ), + ); + gh.factory<_i614.ForgetPasswordCubit>( + () => _i614.ForgetPasswordCubit( + gh<_i769.ForgetPasswordUsecase>(), + gh<_i603.AuthStorage>(), + ), + ); + gh.factory<_i858.GetDriverOrdersUseCase>( + () => _i858.GetDriverOrdersUseCase(gh<_i499.DriverOrderRepo>()), + ); + gh.factory<_i377.ApplyCubit>( + () => _i377.ApplyCubit( + gh<_i940.GetCountriesUseCase>(), + gh<_i1015.GetAllVehiclesUseCase>(), + gh<_i412.ApplyUseCase>(), + ), + ); + gh.factory<_i221.EditProfileUseCase>( + () => _i221.EditProfileUseCase(gh<_i863.ProfileRepo>()), + ); + gh.factory<_i248.GetProfileUsecase>( + () => _i248.GetProfileUsecase(gh<_i863.ProfileRepo>()), + ); + gh.factory<_i884.UploadProfilePhotoUseCase>( + () => _i884.UploadProfilePhotoUseCase(gh<_i863.ProfileRepo>()), + ); + gh.factory<_i1023.LogoutCubit>( + () => + _i1023.LogoutCubit(gh<_i27.LogoutUseCase>(), gh<_i603.AuthStorage>()), + ); + gh.factory<_i810.LoginCubit>( + () => _i810.LoginCubit(gh<_i75.LoginUseCase>(), gh<_i603.AuthStorage>()), + ); + gh.factory<_i573.DriverOrderCubit>( + () => _i573.DriverOrderCubit( + gh<_i858.GetDriverOrdersUseCase>(), + gh<_i603.AuthStorage>(), + gh<_i329.UploadDriverFireDataUseCase>(), + gh<_i233.UploadOrderFireDataUseCase>(), + gh<_i499.DriverOrderRepo>(), + ), + ); + gh.factory<_i603.ProfileCubit>( + () => _i603.ProfileCubit( + gh<_i221.EditProfileUseCase>(), + gh<_i884.UploadProfilePhotoUseCase>(), + gh<_i248.GetProfileUsecase>(), + gh<_i603.AuthStorage>(), + ), + ); + return this; + } +} + +class _$FirebaseModule extends _i383.FirebaseModule {} + +class _$NetworkModule extends _i200.NetworkModule {} diff --git a/lib/app/config/di/di.dart b/lib/app/config/di/di.dart new file mode 100644 index 0000000..b2094df --- /dev/null +++ b/lib/app/config/di/di.dart @@ -0,0 +1,12 @@ +import 'package:get_it/get_it.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/config/di/di.config.dart'; + +final getIt = GetIt.instance; + +@InjectableInit( + initializerName: 'init', // default + preferRelativeImports: true, // default + asExtension: true, // default +) +void configureDependencies() => getIt.init(); diff --git a/lib/app/config/network/firebase_module.dart b/lib/app/config/network/firebase_module.dart new file mode 100644 index 0000000..e7ebf30 --- /dev/null +++ b/lib/app/config/network/firebase_module.dart @@ -0,0 +1,8 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:injectable/injectable.dart'; + +@module +abstract class FirebaseModule { + @lazySingleton + FirebaseFirestore get firestore => FirebaseFirestore.instance; +} diff --git a/lib/app/config/network/interceptor.dart b/lib/app/config/network/interceptor.dart new file mode 100644 index 0000000..2fd9c7c --- /dev/null +++ b/lib/app/config/network/interceptor.dart @@ -0,0 +1,23 @@ +import 'package:dio/dio.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; + +class AppInterceptor extends Interceptor { + final AuthStorage tokenStorage; + + AppInterceptor(this.tokenStorage); + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final token = await tokenStorage.getToken(); + + final isFirebase = options.uri.host.contains('googleapis.com'); + + if (!isFirebase && token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } +} diff --git a/lib/app/config/network/network_module.dart b/lib/app/config/network/network_module.dart new file mode 100644 index 0000000..c976283 --- /dev/null +++ b/lib/app/config/network/network_module.dart @@ -0,0 +1,45 @@ +import 'package:dio/dio.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/network/interceptor.dart'; +import 'package:injectable/injectable.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:pretty_dio_logger/pretty_dio_logger.dart'; + +import '../../core/api_manger/api_client.dart'; +import '../../core/values/app_endpoint_strings.dart'; + +@module +abstract class NetworkModule { + @lazySingleton + Dio dio(AuthStorage authStorage) { + final dio = Dio( + BaseOptions( + baseUrl: AppEndpointString.baseUrl, + headers: {'Content-Type': 'application/json'}, + ), + ); + + dio.interceptors.add(AppInterceptor(authStorage)); + + dio.interceptors.add( + PrettyDioLogger( + requestHeader: true, + requestBody: true, + responseBody: true, + responseHeader: false, + error: true, + compact: true, + maxWidth: 90, + ), + ); + + return dio; + } + + @lazySingleton + ApiClient authApiClient(Dio dio) => ApiClient(dio); + + @lazySingleton + @Named('firestore') + FirebaseFirestore get firestore => FirebaseFirestore.instance; +} diff --git a/lib/app/config/validation/app_validation.dart b/lib/app/config/validation/app_validation.dart new file mode 100644 index 0000000..22e35bc --- /dev/null +++ b/lib/app/config/validation/app_validation.dart @@ -0,0 +1,97 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +class Validators { + static bool notEmpty(String? text) => text != null && text.trim().isNotEmpty; + + static String? firstNameValidator(String? val) { + RegExp nameRegExp = RegExp(r'^[a-zA-Z\s]{3,50}$'); + if (val == null || val.isEmpty) { + return LocaleKeys.firstNameRequired.tr(); + } else if (!nameRegExp.hasMatch(val)) { + return LocaleKeys.nameInvalid.tr(); + } else { + return null; + } + } + + static String? lastNameValidator(String? val) { + RegExp nameRegExp = RegExp(r'^[a-zA-Z\s]{3,50}$'); + if (val == null || val.isEmpty) { + return LocaleKeys.lastNameRequired.tr(); + } else if (!nameRegExp.hasMatch(val)) { + return LocaleKeys.nameInvalid.tr(); + } else { + return null; + } + } + + static String? phoneValidator(String? val) { + RegExp phoneRegExp = RegExp(r'^\+201[0-2,5][0-9]{8}$'); + if (val == null || val.isEmpty) { + return LocaleKeys.phoneRequired.tr(); + } else if (!phoneRegExp.hasMatch(val)) { + return LocaleKeys.phoneInvalid.tr(); + } else { + return null; + } + } + + static String? passwordValidator(String? val) { + if (val == null || val.isEmpty) { + return LocaleKeys.passwordRequired.tr(); + } else if (val.length < 6) { + return LocaleKeys.passwordLengthInvalid.tr(); + } else if (!val.contains(RegExp(r'[A-Z]'))) { + return LocaleKeys.passwordUpperLetterInvalid.tr(); + } else if (!val.contains(RegExp(r'[a-z]'))) { + return LocaleKeys.passwordLowerLetterInvalid.tr(); + } else if (!val.contains(RegExp(r'[0-9]'))) { + return LocaleKeys.passwordNumbersInvalid.tr(); + } else if (!val.contains(RegExp(r'[!@#\$%^&*()<>?/|}{~:]'))) { + return LocaleKeys.passwordSpecialCharInvalid.tr(); + } + return null; + } + + static String? newPasswordValidator(String? newPass, String? currentPass) { + String? validParams = passwordValidator(newPass); + if (validParams != null) { + return validParams; + } + if (newPass == currentPass) { + return LocaleKeys.cannotBeSame.tr(); + } + return null; + } + + static String? confirmPasswordValidator(String? val, String? pass) { + if (val == null || val.isEmpty) { + return LocaleKeys.confirmPasswordRequired.tr(); + } else if (val != pass) { + return LocaleKeys.passwordsDoNotMatch.tr(); + } + return null; + } + + static String? emailValidator(String? val) { + RegExp emailRegExp = RegExp( + r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+", + ); + if (val == null || val.isEmpty) { + return LocaleKeys.emailRequired.tr(); + } else if (!emailRegExp.hasMatch(val)) { + return LocaleKeys.emailInvalid.tr(); + } else { + return null; + } + } + + static String? genderValidator(String? val) { + if (val == null || val.isEmpty) { + return LocaleKeys.genderRequired.tr(); + } else { + return null; + } + } +} diff --git a/lib/app/core/api_manger/api_client.dart b/lib/app/core/api_manger/api_client.dart new file mode 100644 index 0000000..48c1193 --- /dev/null +++ b/lib/app/core/api_manger/api_client.dart @@ -0,0 +1,91 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:tracking_app/app/core/values/app_endpoint_strings.dart'; +import 'package:tracking_app/features/auth/data/model/response/change_password_dto.dart'; +import 'package:tracking_app/features/auth/data/model/request/LoginRequest.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:retrofit/retrofit.dart'; +import 'package:tracking_app/features/auth/data/models/request/forget_password_request.dart'; +import 'package:tracking_app/features/auth/data/models/request/resetpassword_request.dart'; +import 'package:tracking_app/features/auth/data/models/request/verifyreset_request.dart'; +import 'package:tracking_app/features/auth/data/models/response/forgetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/resetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/verifyreset_response.dart'; +import 'package:tracking_app/features/my_orders/data/models/response/my_order_response.dart'; +import '../../../features/auth/data/models/response/apply_response_model.dart'; +import '../../../features/auth/data/models/response/vehicles_response_model.dart'; +import 'package:tracking_app/app/core/values/api_constants.dart'; +import 'package:tracking_app/features/profile/data/models/requests/edit_profile_request.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import '../../../features/auth/data/models/response/logout_response_dto/logout_response_dto.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +part 'api_client.g.dart'; + +@RestApi(baseUrl: AppEndpointString.baseUrl) +abstract class ApiClient { + factory ApiClient(Dio dio) = _ApiClient; + @GET(AppEndpointString.logout) + Future> logout( + @Header(ApiConstants.authorization) String token, + ); + + @POST(AppEndpointString.sendEmail) + Future> forgetPassword( + @Body() ForgetPasswordRequest request, + ); + @PUT(AppEndpointString.resetPassword) + Future> resetPassword( + @Body() ResetPasswordRequest request, + ); + @POST(AppEndpointString.verifyResetCode) + Future> verifyResetCode( + @Body() VerifyResetRequest request, + ); + @PATCH(AppEndpointString.changePassword) + Future> changePassword({ + @Header(ApiConstants.authorization) required String token, + @Body() required Map body, + }); + + @POST(AppEndpointString.login) + Future login(@Body() LoginRequest request); + + @GET(AppEndpointString.getVehicles) + Future> getAllVehicle(); + + @POST(AppEndpointString.apply) + @MultiPart() + Future> apply(@Body() FormData formData); + + @PUT(AppEndpointString.editProfile) + Future> editProfile({ + @Header(ApiConstants.authorization) required String token, + @Body() required EditProfileRequest request, + }); + + @MultiPart() + @PUT(AppEndpointString.uploadPhoto) + Future> uploadPhoto({ + @Header(ApiConstants.authorization) required String token, + @Part(name: ApiConstants.photo) required File photo, + }); + + @GET(AppEndpointString.getProfile) + Future> getProfile({ + @Header(ApiConstants.authorization) required String token, + }); + + @GET(AppEndpointString.mydriverOrders) + Future> getAllOrders({ + @Header("Authorization") required String token, + @Query("limit") int? limit = 1000, + @Query("page") int? page, + }); + + @GET(AppEndpointString.mydriverOrders) + Future> getPendingOrders( + @Header("Authorization") String token, { + @Query("limit") int? limit = 1000, + }); +} diff --git a/lib/app/core/api_manger/api_client.g.dart b/lib/app/core/api_manger/api_client.g.dart new file mode 100644 index 0000000..3bf4d50 --- /dev/null +++ b/lib/app/core/api_manger/api_client.g.dart @@ -0,0 +1,473 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_client.dart'; + +// dart format off + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations,unused_element_parameter,avoid_unused_constructor_parameters,unreachable_from_main + +class _ApiClient implements ApiClient { + _ApiClient(this._dio, {this.baseUrl, this.errorLogger}) { + baseUrl ??= 'https://flower.elevateegy.com/api/v1/'; + } + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future> logout(String token) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'drivers/logout', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late LogoutResponseDto _value; + try { + _value = LogoutResponseDto.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> forgetPassword( + ForgetPasswordRequest request, + ) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + _data.addAll(request.toJson()); + final _options = _setStreamType>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'drivers/forgotPassword', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late ForgetpasswordResponse _value; + try { + _value = ForgetpasswordResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> resetPassword( + ResetPasswordRequest request, + ) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + _data.addAll(request.toJson()); + final _options = _setStreamType>( + Options(method: 'PUT', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'drivers/resetPassword', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late ResetpasswordResponse _value; + try { + _value = ResetpasswordResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> verifyResetCode( + VerifyResetRequest request, + ) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + _data.addAll(request.toJson()); + final _options = _setStreamType>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'drivers/verifyResetCode', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late VerifyresetResponse _value; + try { + _value = VerifyresetResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> changePassword({ + required String token, + required Map body, + }) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + final _data = {}; + _data.addAll(body); + final _options = _setStreamType>( + Options(method: 'PATCH', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'drivers/change-password', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late ChangePasswordDto _value; + try { + _value = ChangePasswordDto.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future login(LoginRequest request) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + _data.addAll(request.toJson()); + final _options = _setStreamType( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'drivers/signin', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late LoginResponse _value; + try { + _value = LoginResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + return _value; + } + + @override + Future> getAllVehicle() async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'vehicles', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late VehiclesResponse _value; + try { + _value = VehiclesResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> apply(FormData formData) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = formData; + final _options = _setStreamType>( + Options( + method: 'POST', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + 'drivers/apply', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late ApplyResponseModel _value; + try { + _value = ApplyResponseModel.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> editProfile({ + required String token, + required EditProfileRequest request, + }) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + final _data = {}; + _data.addAll(request.toJson()); + final _options = _setStreamType>( + Options(method: 'PUT', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'drivers/editProfile', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late EditProfileResponse _value; + try { + _value = EditProfileResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> uploadPhoto({ + required String token, + required File photo, + }) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + final _data = FormData(); + _data.files.add( + MapEntry( + 'photo', + MultipartFile.fromFileSync( + photo.path, + filename: photo.path.split(Platform.pathSeparator).last, + ), + ), + ); + final _options = _setStreamType>( + Options( + method: 'PUT', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + 'drivers/upload-photo', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late EditProfileResponse _value; + try { + _value = EditProfileResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> getProfile({ + required String token, + }) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'drivers/profile-data', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late EditProfileResponse _value; + try { + _value = EditProfileResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> getAllOrders({ + required String token, + int? limit = 1000, + int? page, + }) async { + final _extra = {}; + final queryParameters = {r'limit': limit, r'page': page}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'orders/pending-orders', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late MyOrderResponse _value; + try { + _value = MyOrderResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + @override + Future> getPendingOrders( + String token, { + int? limit = 1000, + }) async { + final _extra = {}; + final queryParameters = {r'limit': limit}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'orders/pending-orders', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late OrderResponse _value; + try { + _value = OrderResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options, _result); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} + +// dart format on diff --git a/lib/app/core/app_constants.dart b/lib/app/core/app_constants.dart new file mode 100644 index 0000000..06be015 --- /dev/null +++ b/lib/app/core/app_constants.dart @@ -0,0 +1,41 @@ +class AppConstants { + static const String appName = 'Flowery '; + static const String welcomeMessage = 'Welcome to Flowery Shop'; + static const String home = 'Home'; + static const String category = 'Categories'; + static const String profile = 'Profile'; + static const String cart = 'Cart'; + static const String defaultErrorMessage = + "Something went wrong please try again later "; + static const String bestseller = 'Best Sellers'; + static const String sessionExpiredMessage = + 'Session expired. Please login again.'; + static const String notificationsKey = 'notifications_enabled'; + static const String noProfileFound = 'No Profile Found'; + static const String register = 'Register'; + static const String continueAsGuest = 'Continue as Guest'; + static const String login = 'Login'; + static const String pleaseLoginToAccessProfile = + 'You need to register or login to access your profile'; + static const String termsAndConditions = 'Terms and Conditions'; + static const String aboutUs = 'About Us'; + static const String language = 'Language'; + static const String notifications = 'Notifications'; + static const String savedaddresses = 'Saved Addresses'; + static const String myOrders = 'My Orders'; + static const String noName = 'No Name'; + static const String noEmail = 'No Email'; + static const String firstName = 'First Name'; + static const String lastName = 'Last Name'; + static const String email = 'Email'; + static const String phone = 'Phone Number'; + static const String gender = 'Gender'; + static const String password = 'Password'; + static const String update = 'Update'; + static const String editProfile = 'Edit Profile'; + static const String logout = 'Logout'; + static const String english = 'English'; + static const String arabic = 'Arabic'; + static const String logoutFailed = 'Logout failed'; + static const String floweryRider = 'Flowery Rider'; +} diff --git a/lib/app/core/firebase/cloud_messaging.dart b/lib/app/core/firebase/cloud_messaging.dart new file mode 100644 index 0000000..b126c49 --- /dev/null +++ b/lib/app/core/firebase/cloud_messaging.dart @@ -0,0 +1,75 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:tracking_app/firebase_options.dart'; + +abstract class CloudMessaging { + static late AndroidNotificationChannel channel; + static bool isFlutterLocalNotificationsInitialized = false; + static late FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin; + + static Future setupFlutterNotifications() async { + if (isFlutterLocalNotificationsInitialized) { + return; + } + channel = const AndroidNotificationChannel( + 'high_importance_channel', + 'High Importance Notifications', + description: 'This channel is used for important notifications.', + importance: Importance.high, + playSound: true, + showBadge: true, + ); + + flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(channel); + + await FirebaseMessaging.instance + .setForegroundNotificationPresentationOptions( + alert: true, + badge: true, + sound: true, + ); + isFlutterLocalNotificationsInitialized = true; + } + + static void showFlutterNotification(RemoteMessage message) { + RemoteNotification? notification = message.notification; + AndroidNotification? android = message.notification?.android; + if (notification != null && android != null) { + flutterLocalNotificationsPlugin.show( + id: notification.hashCode, + title: notification.title, + body: notification.body, + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + channel.id, + channel.name, + channelDescription: channel.description, + icon: 'launch_background', + ), + ), + ); + } + } + + static Future firebaseMessagingBackgroundHandler( + RemoteMessage message, + ) async { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + await setupFlutterNotifications(); + showFlutterNotification(message); + } + + static Future printDeviceToken() async { + var token = await FirebaseMessaging.instance.getToken(); + print('<<<<<<<<<<<< $token'); + } +} diff --git a/lib/app/core/network/api_result.dart b/lib/app/core/network/api_result.dart new file mode 100644 index 0000000..44f22b1 --- /dev/null +++ b/lib/app/core/network/api_result.dart @@ -0,0 +1,11 @@ +sealed class ApiResult {} + +class SuccessApiResult extends ApiResult { + final T data; + SuccessApiResult({required this.data}); +} + +class ErrorApiResult extends ApiResult { + final String error; + ErrorApiResult({required this.error}); +} diff --git a/lib/app/core/network/api_result_picker.dart b/lib/app/core/network/api_result_picker.dart new file mode 100644 index 0000000..aaab1b8 --- /dev/null +++ b/lib/app/core/network/api_result_picker.dart @@ -0,0 +1,15 @@ +import 'package:tracking_app/app/core/network/api_result.dart'; + +extension ApiResultPick on ApiResult { + ApiResult pick(R Function(T) selector) { + if (this is SuccessApiResult) { + final success = this as SuccessApiResult; + return SuccessApiResult(data: selector(success.data)); + } else if (this is ErrorApiResult) { + final error = this as ErrorApiResult; + return ErrorApiResult(error: error.error); + } else { + return ErrorApiResult(error: "Unknown error"); + } + } +} diff --git a/lib/app/core/network/safe_api_call.dart b/lib/app/core/network/safe_api_call.dart new file mode 100644 index 0000000..1a11abe --- /dev/null +++ b/lib/app/core/network/safe_api_call.dart @@ -0,0 +1,35 @@ +import 'package:dio/dio.dart'; + +import 'package:retrofit/retrofit.dart'; + +import 'api_result.dart'; + +Future> safeApiCall({ + required Future> Function() call, + bool isBaseResponse = false, +}) async { + try { + final response = await call(); + if (response.response.statusCode! >= 200 && + response.response.statusCode! < 300) { + return SuccessApiResult(data: response.data); + } else { + return ErrorApiResult( + error: "Failed with status code: ${response.response.statusCode}", + ); + } + } on DioException catch (dioError) { + final responseData = dioError.response?.data; + String errorDetail; + if (responseData is Map && responseData['error'] != null) { + errorDetail = responseData['error'].toString(); + } else if (dioError.message != null && dioError.message!.isNotEmpty) { + errorDetail = dioError.message!; + } else { + errorDetail = 'Unknown Dio error'; + } + return ErrorApiResult(error: errorDetail); + } catch (e) { + return ErrorApiResult(error: "Unexpected error: $e"); + } +} diff --git a/lib/app/core/router/app_router.dart b/lib/app/core/router/app_router.dart new file mode 100644 index 0000000..2f52fca --- /dev/null +++ b/lib/app/core/router/app_router.dart @@ -0,0 +1,125 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:tracking_app/features/Onboarding/presentation/pages/onboardingScreen.dart'; +import 'package:tracking_app/features/app_sections/presentation/pages/app_sections.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/location_type.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/pages/drivers_orders_details_page.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/pages/location_page.dart'; +import 'package:tracking_app/features/home/presentation/pages/driverOrderScreen.dart'; +import 'package:tracking_app/features/auth/presentation/login/pages/loginScreen.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/presentation/pages/edit_driver_profile_page.dart'; +import 'package:tracking_app/features/profile/presentation/pages/edit_vehicle_page.dart'; +import 'package:tracking_app/features/profile/presentation/pages/profile_page.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; +import 'package:tracking_app/features/my_orders/presentation/pages/order_details_page.dart'; +import 'package:tracking_app/features/auth/presentation/apply/view/apply_view.dart'; +import 'package:tracking_app/features/auth/presentation/forget_pass/manager/cubit/forget_pass_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/forget_pass/pages/forget_pass_page.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/reset_password_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/pages/change_password_page.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/pages/reset_password.dart'; +import 'package:tracking_app/features/auth/presentation/verify_reset/manger/cubit/verify_reset_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/verify_reset/pages/verify_reset_page.dart'; + +final GoRouter appRouter = GoRouter( + initialLocation: RouteNames.onboarding, + routes: [ + GoRoute( + path: RouteNames.changePassword, + builder: (context, state) => const ChangePasswordPage(), + ), + + GoRoute( + path: RouteNames.onboarding, + builder: (context, state) => const Onboardingscreen(), + ), + + GoRoute( + path: RouteNames.login, + builder: (context, state) => const LoginScreen(), + ), + + GoRoute( + path: RouteNames.profile, + builder: (context, state) => const ProfilePage(), + ), + + GoRoute( + path: RouteNames.appStart, + builder: (context, state) => AppSections(), + ), + + GoRoute( + path: RouteNames.applyScreen, + builder: (context, state) => const ApplyScreen(), + ), + + GoRoute( + path: RouteNames.verifyResetCode, + builder: (context, state) { + final email = state.extra as String; + + return BlocProvider( + create: (_) => getIt(param1: email), + child: VerifyResetCodePage(email: email), + ); + }, + ), + + GoRoute( + path: RouteNames.forgetPassword, + builder: (context, state) => BlocProvider( + create: (_) => getIt(), + child: const ForgetPasswordPage(), + ), + ), + + GoRoute( + path: RouteNames.resetPassword, + builder: (context, state) => BlocProvider( + create: (_) => getIt(param1: state.extra as String), + child: const ResetPasswordPage(), + ), + ), + + GoRoute( + path: RouteNames.editDriverProfile, + builder: (context, state) { + final driver = state.extra as DriverModel?; + return EditDriverProfilePage(driver: driver); + }, + ), + + GoRoute( + path: RouteNames.editVehicle, + builder: (context, state) { + final driver = state.extra as DriverModel; + return EditVehiclePage(driver: driver); + }, + ), + + GoRoute( + path: RouteNames.ordersDetailsPage, + builder: (context, state) => DriversOrdersDetailsPage(), + ), + + GoRoute( + path: RouteNames.orderDetails, + builder: (context, state) { + final order = state.extra as OrderEntity; + return OrderDetailsPage(order: order); + }, + ), + + GoRoute( + path: RouteNames.locationPage, + builder: (context, state) { + final locationType = state.extra as LocationType; + return LocationPage(locationType: locationType); + }, + ), + ], +); diff --git a/lib/app/core/router/route_names.dart b/lib/app/core/router/route_names.dart new file mode 100644 index 0000000..36005a4 --- /dev/null +++ b/lib/app/core/router/route_names.dart @@ -0,0 +1,20 @@ +abstract class RouteNames { + static const signup = '/signup'; + static const login = '/login'; + static const forgetPassword = '/forget-password'; + static const verifyResetCode = '/verify-reset-code'; + static const resetPassword = '/reset-password'; + static const home = '/home'; + static const profile = '/profile'; + static const appStart = '/appStart'; + static const changePassword = '/changePassword'; + static const applyScreen = '/applyScreen'; + static const onboarding = '/onboarding'; + static const editDriverProfile = "/editDriverProfile"; + static const editVehicle = "/editVehicle"; + static const getProfle = "/profile-data"; + static const ordersDetailsPage = "/ordersDetails"; + static const myOrders = "/myOrders"; + static const orderDetails = "/orderDetails"; + static const locationPage = "/locationPage"; +} diff --git a/lib/app/core/ui_helper/assets/images.dart b/lib/app/core/ui_helper/assets/images.dart new file mode 100644 index 0000000..2bb488a --- /dev/null +++ b/lib/app/core/ui_helper/assets/images.dart @@ -0,0 +1,19 @@ +// ignore_for_file: prefer_single_quotes +class Assets { + Assets._(); + + /// Assets for imagesCheck + /// assets/images/Check.svg + /// Assets for imagesFilter + /// assets/images/filter.png + /// Assets for imagesFlower + /// assets/images/Flower.svg + /// assets/images/delete.png + static const String imagesCheck = "assets/images/Check.svg"; + static const String imagesFilter = "assets/images/filter.png"; + static const String imagesFlower = "assets/images/Flower.svg"; + static const String delete = "assets/images/delete.png"; + static const String driverLocation = "assets/images/driver_location.png"; + static const String userLocation = "assets/images/user_location.png"; + static const String floweryLocation = "assets/images/flowery_location.png"; +} diff --git a/lib/app/core/ui_helper/color/colors.dart b/lib/app/core/ui_helper/color/colors.dart new file mode 100644 index 0000000..bcdc243 --- /dev/null +++ b/lib/app/core/ui_helper/color/colors.dart @@ -0,0 +1,21 @@ +import 'dart:ui'; + +abstract final class AppColors { + static const Color pink = Color(0xFFD21E6A); + static const Color secondaryColor = Color(0xFF140A2B); + static const Color blackColor = Color.fromARGB(255, 0, 0, 0); + static const Color blueColor = Color(0xFF0B0033); + static const Color lightPurple = Color(0xFF9C4DFF); + static const Color grey = Color(0xFF535353); + static const Color grey2 = Color(0xFF8A8595); + static const Color lightGrey = Color(0xFFEBEBEB); + static const Color green = Color(0xFF17B890); + static const Color lightGreen = Color(0x2617B890); + static const Color yellow = Color(0xFFC5BE19); + static const Color yellow2 = Color(0xffB89517); + static const Color red = Color(0xffDC3D37); + static const Color white = Color(0xFFFFFFFF); + static const Color purple = Color(0xFF441AB0); + static const Color white70 = Color(0xFFA6A6A6); + static const Color lightPink = Color(0xFFF9ECF0); +} diff --git a/lib/app/core/ui_helper/style/font_style.dart b/lib/app/core/ui_helper/style/font_style.dart new file mode 100644 index 0000000..e53cb78 --- /dev/null +++ b/lib/app/core/ui_helper/style/font_style.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; + +import '../color/colors.dart'; + +class AppStyles { + static final String _fontFamily = 'SansArabic'; + static final font32BlackSemiBold = TextStyle( + fontFamily: _fontFamily, + fontSize: 32, + color: AppColors.blackColor, + fontWeight: FontWeight.w500, + ); + static final black24SemiBold = TextStyle( + fontFamily: _fontFamily, + fontSize: 24, + color: AppColors.blackColor, + fontWeight: FontWeight.w600, + ); + static final font16Black = TextStyle( + fontFamily: _fontFamily, + fontSize: 16, + color: AppColors.blackColor, + ); + static final black16Medium = TextStyle( + fontFamily: _fontFamily, + fontSize: 16, + color: AppColors.blackColor, + fontWeight: FontWeight.w500, + ); + static final black14Medium = TextStyle( + fontFamily: _fontFamily, + fontSize: 14, + color: AppColors.blackColor, + fontWeight: FontWeight.w500, + ); + + static final font14Black = TextStyle( + fontFamily: _fontFamily, + fontSize: 14, + color: AppColors.blackColor, + fontWeight: FontWeight.normal, + ); + static final font14White = TextStyle( + fontFamily: _fontFamily, + fontSize: 14, + color: AppColors.white, + fontWeight: FontWeight.normal, + ); + + static final font30WhiteSemiBold = TextStyle( + fontFamily: _fontFamily, + fontSize: 30, + color: AppColors.white, + fontWeight: FontWeight.w500, + ); + static final subtitle = TextStyle( + fontFamily: _fontFamily, + fontSize: 12, + color: AppColors.grey, + fontWeight: FontWeight.normal, + ); + + static final black10Medium = TextStyle( + fontFamily: _fontFamily, + fontSize: 10, + color: AppColors.blackColor, + fontWeight: FontWeight.w500, + ); + + static final font12Black = TextStyle( + fontFamily: _fontFamily, + fontSize: 12, + color: AppColors.blackColor, + fontWeight: FontWeight.normal, + ); + static final font12BlackBold = TextStyle( + fontSize: 12, + color: AppColors.blackColor, + fontWeight: FontWeight.bold, + ); + static final font20BlackSemiBold = TextStyle( + fontFamily: _fontFamily, + fontSize: 20, + color: AppColors.blackColor, + fontWeight: FontWeight.w500, + ); + static final black18Medium = TextStyle( + fontFamily: _fontFamily, + fontSize: 18, + color: AppColors.blackColor, + fontWeight: FontWeight.w500, + ); + static final font12White = TextStyle( + fontFamily: _fontFamily, + fontSize: 12, + color: AppColors.blackColor, + fontWeight: FontWeight.normal, + ); + static final font24WhiteSemiBold = TextStyle( + fontFamily: _fontFamily, + fontSize: 24, + color: AppColors.white, + fontWeight: FontWeight.w500, + ); + static final grey2_16Regular = TextStyle( + fontFamily: _fontFamily, + fontSize: 16, + color: AppColors.grey2, + fontWeight: FontWeight.w400, + ); + static final grey14Regular = TextStyle( + fontFamily: _fontFamily, + fontSize: 14, + color: AppColors.grey, + fontWeight: FontWeight.w400, + ); + static final black14bold = TextStyle( + fontSize: 14, + color: AppColors.blackColor, + fontWeight: FontWeight.w900, + ); + static final grey16Medium = TextStyle( + fontFamily: _fontFamily, + fontSize: 16, + color: AppColors.grey, + fontWeight: FontWeight.w500, + ); + static final grey18Regular = TextStyle( + fontFamily: _fontFamily, + fontSize: 16, + color: AppColors.grey, + ); + static final red14Normal = TextStyle( + fontFamily: _fontFamily, + fontSize: 14, + color: AppColors.red, + fontWeight: FontWeight.w400, + ); + static final purple18bold = TextStyle( + fontFamily: _fontFamily, + fontSize: 18, + color: AppColors.purple, + fontWeight: FontWeight.w600, + ); + + static final white13medium = TextStyle( + fontFamily: _fontFamily, + fontSize: 13, + color: AppColors.white, + fontWeight: FontWeight.w500, + ); + static final green14regular = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.green, + ); + static const TextStyle medium20 = TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + fontSize: 20, + height: 1.0, + letterSpacing: 0, + color: Color(0xFF0C1015), + ); +} diff --git a/lib/app/core/ui_helper/theme/app_theme.dart b/lib/app/core/ui_helper/theme/app_theme.dart new file mode 100644 index 0000000..b644b7d --- /dev/null +++ b/lib/app/core/ui_helper/theme/app_theme.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class AppTheme { + static ThemeData lightTheme = ThemeData( + scaffoldBackgroundColor: AppColors.white, + + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.pink, + primary: AppColors.pink, + secondary: AppColors.secondaryColor, + tertiary: AppColors.blueColor, + ), + + appBarTheme: AppBarTheme( + backgroundColor: AppColors.white, + iconTheme: IconThemeData(color: AppColors.blackColor), + titleTextStyle: TextStyle( + color: AppColors.blackColor, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + + inputDecorationTheme: InputDecorationTheme( + floatingLabelBehavior: FloatingLabelBehavior.always, + labelStyle: TextStyle(color: AppColors.blackColor, fontSize: 20), + hintStyle: TextStyle(color: Colors.grey.shade600, fontSize: 13), + contentPadding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12), + filled: true, + fillColor: Colors.white, + errorStyle: const TextStyle(color: Colors.red, fontSize: 12, height: 1.3), + enabledBorder: _border(const Color(0xFF8C8C8C)), + focusedBorder: _border(AppColors.pink), + errorBorder: _border(Colors.red), + focusedErrorBorder: _border(Colors.red), + ), + + textTheme: TextTheme( + headlineMedium: TextStyle( + fontSize: 18, + color: AppColors.pink, + fontWeight: FontWeight.bold, + ), + headlineSmall: TextStyle(fontSize: 12, color: AppColors.blackColor), + labelMedium: TextStyle(fontSize: 18, color: AppColors.blackColor), + labelSmall: TextStyle(fontSize: 14, color: AppColors.grey), + bodyMedium: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: AppColors.grey, + ), + ), + + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.pink, + foregroundColor: Colors.white, + elevation: 0, + minimumSize: const Size(double.infinity, 52), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500), + ), + ), + + navigationBarTheme: NavigationBarThemeData( + backgroundColor: AppColors.white, + indicatorColor: AppColors.pink, + surfaceTintColor: AppColors.white, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + elevation: 0, + iconTheme: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.selected)) { + return IconThemeData(color: AppColors.white, size: 24); + } + return IconThemeData(color: AppColors.pink, size: 24); + }), + ), + ); + + static OutlineInputBorder _border(Color color) { + return OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: color, width: 1.2), + ); + } +} diff --git a/lib/app/core/utils/app_launcher.dart b/lib/app/core/utils/app_launcher.dart new file mode 100644 index 0000000..a096dff --- /dev/null +++ b/lib/app/core/utils/app_launcher.dart @@ -0,0 +1,20 @@ +import 'package:url_launcher/url_launcher.dart'; + +abstract class AppLauncher { + static void launchPhone(String phoneNumber) async { + final Uri url = Uri(scheme: 'tel', path: phoneNumber); + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + } + + static void launchWhatsApp(String phoneNumber) async { + String formattedPhone = phoneNumber.replaceAll(RegExp(r'[^0-9]'), ''); + + if (formattedPhone.startsWith('0')) { + formattedPhone = '20${formattedPhone.substring(1)}'; + } + final Uri url = Uri.parse("whatsapp://send?phone=$formattedPhone"); + await launchUrl(url, mode: LaunchMode.externalApplication); + } +} diff --git a/lib/app/core/utils/validators_helper.dart b/lib/app/core/utils/validators_helper.dart new file mode 100644 index 0000000..31c85cf --- /dev/null +++ b/lib/app/core/utils/validators_helper.dart @@ -0,0 +1,81 @@ +import '../values/user_error_mesagges.dart'; + +class Validators { + static String? validateEmail(String? value) { + if (value == null || value.isEmpty) return UserErrorMessages.emailRequired; + final emailRegex = RegExp( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + ); + if (!emailRegex.hasMatch(value)) return UserErrorMessages.invalidEmail; + return null; + } + + static String? validatePassword(String? value) { + if (value == null || value.isEmpty) { + return UserErrorMessages.passwordRequired; + } + if (value.length < 6) return UserErrorMessages.least6Characters; + if (!RegExp(r'[A-Z]').hasMatch(value)) { + return UserErrorMessages.passwordWithCapital; + } + if (!RegExp(r'[0-9]').hasMatch(value)) { + return UserErrorMessages.passwordWithNumber; + } + return null; + } + + static String? validateRePassword(String? value, String password) { + if (value == null || value.isEmpty) { + return UserErrorMessages.confirmPassword; + } + if (value != password) return UserErrorMessages.passwordDontMatch; + return null; + } + + static String? validatePhone(String? value) { + if (value == null || value.isEmpty) return UserErrorMessages.phoneRequired; + if (!RegExp(r'^01[0-9]{9}$').hasMatch(value)) { + return UserErrorMessages.invalidNumber; + } + return null; + } + + static String? validateName(String? value) { + if (value == null || value.isEmpty) { + return UserErrorMessages.required; + } + if (value.length < 3) { + return UserErrorMessages.least3Characters; + } + if (RegExp(r'[!@#<>?":_`~;[\]\\|=+)(*&^%-]').hasMatch(value)) { + return UserErrorMessages.invalidName; + } + return null; + } + + static String? validateRecipientName(String? value) { + if (value == null || value.isEmpty) { + return UserErrorMessages.requiredRecipientName; + } + if (value.length < 3) { + return UserErrorMessages.least3Characters; + } + if (RegExp(r'[!@#<>?":_`~;[\]\\|=+)(*&^%-]').hasMatch(value)) { + return UserErrorMessages.invalidRecipientName; + } + return null; + } + + static String? validateAddress(String? value) { + if (value == null || value.isEmpty) { + return UserErrorMessages.requiredAddress; + } + if (value.length < 3) { + return UserErrorMessages.least3Characters; + } + if (RegExp(r'[!@#<>?":_`~;[\]\\|=+)(*&^%-]').hasMatch(value)) { + return UserErrorMessages.invalidAddress; + } + return null; + } +} diff --git a/lib/app/core/values/api_constants.dart b/lib/app/core/values/api_constants.dart new file mode 100644 index 0000000..bfa50b9 --- /dev/null +++ b/lib/app/core/values/api_constants.dart @@ -0,0 +1,10 @@ +class ApiConstants { + static const String occasion = "occasion"; + static const String category = "category"; + static const String search = "search"; + static const String id = "id"; + static const String authorization = "Authorization"; + static const String photo = "photo"; + static const String fcmServerKey = + "BIAckRtVye1aHEqxHvno9fJIf7ebHJdB5ACPyNCGKIvfqUA5ozP3UWBQqNmEuAn17o-tFYytvgpMwwbPrjvrfFw"; +} diff --git a/lib/app/core/values/app_endpoint_strings.dart b/lib/app/core/values/app_endpoint_strings.dart new file mode 100644 index 0000000..eb418ac --- /dev/null +++ b/lib/app/core/values/app_endpoint_strings.dart @@ -0,0 +1,37 @@ +class AppEndpointString { + static const String baseUrl = 'https://flower.elevateegy.com/api/v1/'; + static const String loginEndpoint = 'auth/signin'; + static const String sendEmail = 'drivers/forgotPassword'; + static const String verifyResetCode = 'drivers/verifyResetCode'; + static const String resetPassword = 'drivers/resetPassword'; + static const String changePassword = "drivers/change-password"; + + static const String profileData = 'auth/profile-data'; + static const String updateRole = 'auth/update-role'; + static const String cashOrder = 'orders'; + + static const String addresses = 'addresses'; + static const String signup = '/auth/signup'; + static const String allCategories = 'categories'; + static const String getProduct = '/products'; + static const String home = '/home'; + static const String productDetails = 'products/{id}'; + static const String cartPage = 'cart'; + static const String tokenKey = 'token'; + static const String addAddress = 'addresses'; + static const String getaddresses = 'addresses'; + static const String getNotifications = "notifications/user"; + static const String deleteSpecificNotification = "notifications/{id}"; + static const String deleteAllNotifications = "notifications/clear-all"; + static const String getVehicles = "vehicles"; + static const String apply = "drivers/apply"; + + static const String editProfile = "drivers/editProfile"; + static const String uploadPhoto = "drivers/upload-photo"; + static const String getProfile = "drivers/profile-data"; + static const String login = "drivers/signin"; + static const String logout = 'drivers/logout'; + static const String driverOrders = 'orders/driver-orders'; + + static const String mydriverOrders = 'orders/pending-orders'; +} diff --git a/lib/app/core/values/paths.dart b/lib/app/core/values/paths.dart new file mode 100644 index 0000000..26ff989 --- /dev/null +++ b/lib/app/core/values/paths.dart @@ -0,0 +1,10 @@ +class AppPaths { + static const String aboutJsonFile = 'assets/files/about_section.json'; + static const String termsJsonFile = 'assets/files/terms.json'; + static const String aboutUs = 'about_app'; + static const String terms = 'terms_and_conditions'; + static const String onboardingImage = 'assets/images/Clip path group.png'; + static const String whatsappImage = 'assets/images/whatsapp.png'; + static const String flowerLogo = 'assets/images/flower_logo.png'; + static const String mediaUrl = 'https://flower.elevateegy.com/uploads/'; +} diff --git a/lib/app/core/values/user_error_mesagges.dart b/lib/app/core/values/user_error_mesagges.dart new file mode 100644 index 0000000..5f0176b --- /dev/null +++ b/lib/app/core/values/user_error_mesagges.dart @@ -0,0 +1,59 @@ +import 'package:easy_localization/easy_localization.dart'; +import '../../../generated/locale_keys.g.dart'; + +class UserErrorMessages { + // Server Errors ////////////////////////////////////////////////////////////////////// + + static String get connectionTimeout => LocaleKeys.connectionTimeout.tr(); + + static String get noInternet => LocaleKeys.noInternet.tr(); + + static String get unauthorized => LocaleKeys.unauthorized.tr(); + + static String get serverError => LocaleKeys.serverError.tr(); + + static String get unknownError => LocaleKeys.unknownError.tr(); + + // Validator Errors //////////////////////////////////////////////////////////////////// + + static String get invalidEmail => LocaleKeys.emailInvalid.tr(); + + static String get weakPassword => LocaleKeys.weakPassword.tr(); + + static String get emailRequired => LocaleKeys.emailRequired.tr(); + + static String get passwordRequired => LocaleKeys.passwordRequired.tr(); + + static String get passwordWithCapital => LocaleKeys.passwordWithCapital.tr(); + + static String get passwordWithNumber => LocaleKeys.passwordWithNumber.tr(); + + static String get passwordDontMatch => LocaleKeys.passwordDontMatch.tr(); + + static String get confirmPassword => LocaleKeys.confirmPassword.tr(); + + static String get phoneRequired => LocaleKeys.phoneRequired.tr(); + + static String get invalidNumber => LocaleKeys.invalidNumber.tr(); + + static String get required => LocaleKeys.required.tr(); + + static String get least3Characters => LocaleKeys.least3Characters.tr(); + + static String get least6Characters => LocaleKeys.least6Characters.tr(); + + static String get invalidName => LocaleKeys.invalidName.tr(); + + static String get invalidRecipientName => + LocaleKeys.invalidRecipientName.tr(); + + static String get invalidAddress => LocaleKeys.invalidAddress.tr(); + + static String get requiredRecipientName => + LocaleKeys.invalidRecipientName.tr(); + + static String get requiredAddress => LocaleKeys.invalidAddress.tr(); + static String get requiredCity => LocaleKeys.requiredCity.tr(); + + static String get requiredArea => LocaleKeys.requiredArea.tr(); +} diff --git a/lib/app/core/widgets/custom_action_text.dart b/lib/app/core/widgets/custom_action_text.dart new file mode 100644 index 0000000..e39cf52 --- /dev/null +++ b/lib/app/core/widgets/custom_action_text.dart @@ -0,0 +1,30 @@ +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:flutter/material.dart'; + +class CustomActionText extends StatelessWidget { + final String text; + final VoidCallback onTapAction; + final bool isEnabled; + + const CustomActionText({ + super.key, + required this.text, + required this.onTapAction, + this.isEnabled = true, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: isEnabled ? onTapAction : null, + child: Text( + text, + style: (Theme.of(context).textTheme.bodyMedium)?.copyWith( + color: isEnabled ? AppColors.pink : Colors.grey, + decoration: TextDecoration.underline, + decorationColor: isEnabled ? AppColors.pink : Colors.grey, + ), + ), + ); + } +} diff --git a/lib/app/core/widgets/custom_app_bar.dart b/lib/app/core/widgets/custom_app_bar.dart new file mode 100644 index 0000000..6dc46e0 --- /dev/null +++ b/lib/app/core/widgets/custom_app_bar.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:go_router/go_router.dart'; + +class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { + final String title; + final List? actions; + + const CustomAppBar({super.key, required this.title, this.actions}); + + @override + Widget build(BuildContext context) { + return AppBar( + title: Text(title.tr()), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: () => context.pop(), + ), + actions: actions, + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/lib/app/core/widgets/custom_button.dart b/lib/app/core/widgets/custom_button.dart new file mode 100644 index 0000000..8a98f56 --- /dev/null +++ b/lib/app/core/widgets/custom_button.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import '../ui_helper/theme/app_theme.dart'; + +class CustomButton extends StatelessWidget { + final bool isEnabled; + final bool isLoading; + final String text; + final VoidCallback onPressed; + final Color? color; + final bool isOutlined; + + const CustomButton({ + super.key, + required this.isEnabled, + required this.isLoading, + required this.text, + required this.onPressed, + this.color, + this.isOutlined = false, + }); + + @override + Widget build(BuildContext context) { + if (isOutlined) { + return OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: color ?? Colors.grey, + side: BorderSide(color: color ?? Colors.grey, width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ), + onPressed: isEnabled && !isLoading ? onPressed : null, + child: isLoading + ? CircularProgressIndicator( + color: color ?? AppTheme.lightTheme.primaryColor, + ) + : Text(text), + ); + } + + return ElevatedButton( + style: color != null + ? ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ) + : null, + onPressed: isEnabled && !isLoading ? onPressed : null, + child: isLoading + ? CircularProgressIndicator(color: AppTheme.lightTheme.primaryColor) + : Text(text), + ); + } +} diff --git a/lib/app/core/widgets/custom_text_form_field.dart b/lib/app/core/widgets/custom_text_form_field.dart new file mode 100644 index 0000000..4b8af3f --- /dev/null +++ b/lib/app/core/widgets/custom_text_form_field.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class CustomTextFormField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String hint; + final String? Function(String?)? validator; + final TextInputType keyboardType; + final TextInputAction textInputAction; + final void Function(String)? onChanged; + const CustomTextFormField({ + super.key, + required this.controller, + required this.label, + required this.hint, + required this.validator, + this.keyboardType = TextInputType.text, + this.textInputAction = TextInputAction.next, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + textInputAction: textInputAction, + keyboardType: keyboardType, + validator: validator, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: onChanged, + decoration: InputDecoration(labelText: label, hintText: hint), + ); + } +} diff --git a/lib/app/core/widgets/default_error_widget.dart b/lib/app/core/widgets/default_error_widget.dart new file mode 100644 index 0000000..88ada5a --- /dev/null +++ b/lib/app/core/widgets/default_error_widget.dart @@ -0,0 +1,37 @@ +import 'package:tracking_app/generated/locale_keys.g.dart'; +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class DefaultErrorWidget extends StatelessWidget { + final String? message; + final VoidCallback? onRetry; + + const DefaultErrorWidget({super.key, this.message, this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 60), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + message ?? LocaleKeys.something_went_wrong.tr(), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18), + ), + ), + if (onRetry != null) + ElevatedButton( + onPressed: onRetry, + child: Text( + LocaleKeys.resend.tr(), + ), // Assuming you have a 'retry' key + ), + ], + ), + ); + } +} diff --git a/lib/app/core/widgets/password_text_form_field.dart b/lib/app/core/widgets/password_text_form_field.dart new file mode 100644 index 0000000..503dabd --- /dev/null +++ b/lib/app/core/widgets/password_text_form_field.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class PasswordTextFormField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String hint; + final bool isVisible; + final VoidCallback onToggleVisibility; + final String? Function(String?)? validator; + final void Function(String)? onChanged; + final TextInputAction textInputAction; + + const PasswordTextFormField({ + super.key, + required this.controller, + required this.label, + required this.hint, + required this.isVisible, + required this.onToggleVisibility, + required this.validator, + this.onChanged, + this.textInputAction = TextInputAction.next, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + obscureText: !isVisible, + validator: validator, + autovalidateMode: AutovalidateMode.onUserInteraction, + textInputAction: textInputAction, + onChanged: onChanged, + decoration: InputDecoration( + labelText: label, + hintText: hint, + suffixIcon: IconButton( + icon: Icon(isVisible ? Icons.visibility : Icons.visibility_off), + onPressed: onToggleVisibility, + ), + ), + ); + } +} diff --git a/lib/app/core/widgets/shimmer_list.dart b/lib/app/core/widgets/shimmer_list.dart new file mode 100644 index 0000000..dd90e9f --- /dev/null +++ b/lib/app/core/widgets/shimmer_list.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +import '../ui_helper/color/colors.dart'; + +class ShimmerList extends StatelessWidget { + const ShimmerList({super.key}); + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: 5, + separatorBuilder: (_, _) => SizedBox(height: 16), + itemBuilder: (_, _) => Shimmer.fromColors( + baseColor: AppColors.white, + highlightColor: AppColors.lightGrey, + child: Container( + width: double.infinity, + height: 100, + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(15), + ), + ), + ), + ); + } +} diff --git a/lib/app/core/widgets/show_app_dialog.dart b/lib/app/core/widgets/show_app_dialog.dart new file mode 100644 index 0000000..18c37be --- /dev/null +++ b/lib/app/core/widgets/show_app_dialog.dart @@ -0,0 +1,30 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../../generated/locale_keys.g.dart'; + +Future showAppDialog( + BuildContext context, { + required String message, + bool isError = true, +}) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + "Error", + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.red), + ), + content: Text(message), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(LocaleKeys.ok.tr(), style: TextStyle(color: Colors.red)), + ), + ], + ), + ); +} diff --git a/lib/app/core/widgets/show_snak_bar.dart b/lib/app/core/widgets/show_snak_bar.dart new file mode 100644 index 0000000..81fbb40 --- /dev/null +++ b/lib/app/core/widgets/show_snak_bar.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +void showAppSnackbar( + BuildContext context, + String message, { + Color backgroundColor = AppColors.green, + String? label, + VoidCallback? onPressed, +}) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(message, style: Theme.of(context).textTheme.labelSmall), + backgroundColor: backgroundColor, + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + action: label != null && onPressed != null + ? SnackBarAction( + label: label, + textColor: Colors.white, + onPressed: onPressed, + ) + : null, + ), + ); +} diff --git a/lib/features/Onboarding/presentation/pages/onboardingScreen.dart b/lib/features/Onboarding/presentation/pages/onboardingScreen.dart new file mode 100644 index 0000000..4be007d --- /dev/null +++ b/lib/features/Onboarding/presentation/pages/onboardingScreen.dart @@ -0,0 +1,88 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/app/core/ui_helper/style/font_style.dart'; +import 'package:tracking_app/app/core/values/paths.dart'; +import 'package:tracking_app/app/core/widgets/custom_button.dart'; + +class Onboardingscreen extends StatelessWidget { + const Onboardingscreen({super.key}); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final height = size.height; + final width = size.width; + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: + height - + MediaQuery.of(context).padding.top - + MediaQuery.of(context).padding.bottom, + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: width * 0.06), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: height * 0.05), + Center( + child: Image( + image: const AssetImage(AppPaths.onboardingImage), + width: width * 0.85, + fit: BoxFit.contain, + ), + ), + SizedBox(height: height * 0.04), + Text( + 'onboardingTitle'.tr(), + style: AppStyles.medium20.copyWith(color: Colors.black), + ), + Text( + 'onboardingDescription'.tr(), + style: AppStyles.medium20.copyWith(color: Colors.black), + ), + SizedBox(height: height * 0.1), + SizedBox( + width: double.infinity, + child: CustomButton( + color: AppColors.pink, + isEnabled: true, + isLoading: false, + text: 'login'.tr(), + onPressed: () { + context.push(RouteNames.login); + }, + ), + ), + SizedBox(height: height * 0.015), + SizedBox( + width: double.infinity, + child: CustomButton( + color: Colors.grey.shade600, + isEnabled: true, + isLoading: false, + isOutlined: true, + text: 'applyNow'.tr(), + onPressed: () { + context.push(RouteNames.applyScreen); + }, + ), + ), + SizedBox(height: height * 0.25), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/app_sections/presentation/manager/app_section_cubit.dart b/lib/features/app_sections/presentation/manager/app_section_cubit.dart new file mode 100644 index 0000000..243cd38 --- /dev/null +++ b/lib/features/app_sections/presentation/manager/app_section_cubit.dart @@ -0,0 +1,12 @@ +import 'package:bloc/bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'app_section_states.dart'; + +@injectable +class AppSectionCubit extends Cubit { + AppSectionCubit() : super(AppSectionStates(selectedIndex: 0)); + + void updateIndex(int index) { + emit(AppSectionStates(selectedIndex: index)); + } +} diff --git a/lib/features/app_sections/presentation/manager/app_section_states.dart b/lib/features/app_sections/presentation/manager/app_section_states.dart new file mode 100644 index 0000000..d84e3c9 --- /dev/null +++ b/lib/features/app_sections/presentation/manager/app_section_states.dart @@ -0,0 +1,4 @@ +class AppSectionStates { + int selectedIndex; + AppSectionStates({required this.selectedIndex}); +} diff --git a/lib/features/app_sections/presentation/pages/app_sections.dart b/lib/features/app_sections/presentation/pages/app_sections.dart new file mode 100644 index 0000000..1d6d5d1 --- /dev/null +++ b/lib/features/app_sections/presentation/pages/app_sections.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/features/app_sections/presentation/manager/app_section_cubit.dart'; +import 'package:tracking_app/features/app_sections/presentation/widgets/app_section_view.dart'; + +class AppSections extends StatelessWidget { + const AppSections({super.key}); + + @override + Widget build(BuildContext context) { + final AppSectionCubit appSectionCubit = getIt(); + + return BlocProvider( + create: (_) => appSectionCubit, + child: AppSectionsView(), + ); + } +} diff --git a/lib/features/app_sections/presentation/pages/home_page_test.dart b/lib/features/app_sections/presentation/pages/home_page_test.dart new file mode 100644 index 0000000..52b8e91 --- /dev/null +++ b/lib/features/app_sections/presentation/pages/home_page_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class HomePageTest extends StatelessWidget { + const HomePageTest({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold(backgroundColor: AppColors.green); + } +} diff --git a/lib/features/app_sections/presentation/pages/orders_page_test.dart b/lib/features/app_sections/presentation/pages/orders_page_test.dart new file mode 100644 index 0000000..7c84623 --- /dev/null +++ b/lib/features/app_sections/presentation/pages/orders_page_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class OrdersPageTest extends StatelessWidget { + const OrdersPageTest({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold(backgroundColor: AppColors.pink); + } +} diff --git a/lib/features/app_sections/presentation/pages/profile_page_test.dart b/lib/features/app_sections/presentation/pages/profile_page_test.dart new file mode 100644 index 0000000..6e662bb --- /dev/null +++ b/lib/features/app_sections/presentation/pages/profile_page_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class ProfilePageTest extends StatelessWidget { + const ProfilePageTest({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold(backgroundColor: AppColors.grey); + } +} diff --git a/lib/features/app_sections/presentation/widgets/app_section_view.dart b/lib/features/app_sections/presentation/widgets/app_section_view.dart new file mode 100644 index 0000000..0e18e2e --- /dev/null +++ b/lib/features/app_sections/presentation/widgets/app_section_view.dart @@ -0,0 +1,76 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/features/app_sections/presentation/manager/app_section_cubit.dart'; +import 'package:tracking_app/features/app_sections/presentation/manager/app_section_states.dart'; +import 'package:tracking_app/features/app_sections/presentation/pages/home_page_test.dart'; +import 'package:tracking_app/features/app_sections/presentation/pages/orders_page_test.dart'; +import 'package:tracking_app/features/app_sections/presentation/pages/profile_page_test.dart'; +import 'package:tracking_app/features/my_orders/presentation/pages/my_orders_page.dart'; +import 'package:tracking_app/features/home/presentation/pages/driverOrderScreen.dart'; +import 'package:tracking_app/features/profile/presentation/pages/profile_page.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +class AppSectionsView extends StatefulWidget { + const AppSectionsView({super.key}); + + @override + State createState() => _AppSectionsViewState(); +} + +class _AppSectionsViewState extends State { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + Widget bodyWidget; + switch (state.selectedIndex) { + case 0: + bodyWidget = const DriverOrderScreen(); + break; + case 1: + bodyWidget = const MyOrdersPage(); + break; + case 2: + bodyWidget = const ProfilePage(); + break; + default: + bodyWidget = const HomePageTest(); + } + + return Scaffold( + body: bodyWidget, + bottomNavigationBar: BottomNavigationBar( + currentIndex: state.selectedIndex, + onTap: (index) { + setState(() { + state.selectedIndex = index; + }); + }, + elevation: 0, + selectedFontSize: 12, + unselectedFontSize: 12, + iconSize: 24, + selectedItemColor: AppColors.pink, + unselectedItemColor: AppColors.grey2, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.home), + label: LocaleKeys.home.tr(), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.fact_check_outlined), + label: LocaleKeys.orders.tr(), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.person_outlined), + label: LocaleKeys.profile.tr(), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/auth/api/datasource/auth_remote_datasource_impl.dart b/lib/features/auth/api/datasource/auth_remote_datasource_impl.dart new file mode 100644 index 0000000..04e66cd --- /dev/null +++ b/lib/features/auth/api/datasource/auth_remote_datasource_impl.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; +import 'package:injectable/injectable.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:dio/dio.dart'; +import 'package:dio/src/form_data.dart'; +import 'package:tracking_app/app/core/api_manger/api_client.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/app/core/network/safe_api_call.dart'; +import 'package:tracking_app/features/auth/data/datasource/auth_remote_datasource.dart'; +import 'package:tracking_app/features/auth/data/model/request/LoginRequest.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:tracking_app/features/auth/data/models/request/forget_password_request.dart'; +import 'package:tracking_app/features/auth/data/models/request/resetpassword_request.dart'; +import 'package:tracking_app/features/auth/data/models/request/verifyreset_request.dart'; +import 'package:tracking_app/features/auth/data/models/request/apply_request_model.dart'; +import 'package:tracking_app/features/auth/data/model/response/change_password_dto.dart'; +import 'package:tracking_app/features/auth/data/models/response/forgetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/logout_response_dto/logout_response_dto.dart'; +import 'package:tracking_app/features/auth/data/models/response/resetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/verifyreset_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/apply_response_model.dart'; +import 'package:tracking_app/features/auth/data/models/response/vehicles_response_model.dart'; +import 'package:tracking_app/features/auth/data/models/response/country_model.dart'; + +@Injectable(as: AuthRemoteDataSource) +class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { + final ApiClient apiClient; + + AuthRemoteDataSourceImpl(this.apiClient); + + @override + Future> forgetPassword( + ForgetPasswordRequest request, + ) { + return safeApiCall(call: () => apiClient.forgetPassword(request)); + } + + @override + Future> verifyResetCode( + VerifyResetRequest request, + ) { + return safeApiCall(call: () => apiClient.verifyResetCode(request)); + } + + @override + Future> resetPassword( + ResetPasswordRequest request, + ) { + return safeApiCall(call: () => apiClient.resetPassword(request)); + } + + @override + Future> login(LoginRequest loginRequest) async { + try { + final response = await apiClient.login(loginRequest); + return SuccessApiResult(data: response); + } on DioException catch (e) { + String errorMessage = 'unknownError'; + if (e.response?.statusCode == 401) { + errorMessage = 'wrongEmailOrPassword'; + } else if (e.response?.data != null) { + if (e.response!.data is Map) { + errorMessage = + e.response!.data['message'] ?? e.message ?? 'unknownError'; + } else { + errorMessage = e.message ?? 'unknownError'; + } + } else { + errorMessage = e.message ?? 'unknownError'; + } + return ErrorApiResult(error: errorMessage); + } catch (e) { + return ErrorApiResult(error: e.toString()); + } + } + + @override + Future> changePassword({ + required String token, + String? password, + String? newPassword, + }) { + return safeApiCall( + call: () async { + return apiClient.changePassword( + token: "Bearer $token", + body: {"password": password, "newPassword": newPassword}, + ); + }, + ); + } + + @override + Future> getAllVehicle() { + return safeApiCall(call: () => apiClient.getAllVehicle()); + } + + @override + Future> apply( + ApplyRequestModel applyRequestModel, + ) { + return safeApiCall( + call: () async { + final formData = FormData.fromMap({ + "country": applyRequestModel.country, + "firstName": applyRequestModel.firstName, + "lastName": applyRequestModel.lastName, + "vehicleType": applyRequestModel.vehicleType, + "vehicleNumber": applyRequestModel.vehicleNumber, + "email": applyRequestModel.email, + "phone": applyRequestModel.phone, + "NID": applyRequestModel.NID, + "password": applyRequestModel.password, + "rePassword": applyRequestModel.rePassword, + "gender": applyRequestModel.gender, + }); + + if (applyRequestModel.vehicleLicense != null) { + formData.files.add( + MapEntry( + "vehicleLicense", + await MultipartFile.fromFile( + applyRequestModel.vehicleLicense!.path, + filename: applyRequestModel.vehicleLicense!.path + .split('/') + .last, + ), + ), + ); + } + + if (applyRequestModel.NIDimg != null) { + formData.files.add( + MapEntry( + "NIDImg", + await MultipartFile.fromFile( + applyRequestModel.NIDimg!.path, + filename: applyRequestModel.NIDimg!.path.split('/').last, + ), + ), + ); + } + + return apiClient.apply(formData); + }, + ); + } + + @override + Future> getCountries() async { + final String response = await rootBundle.loadString( + 'assets/data/country.json', + ); + final List data = json.decode(response); + return data.map((json) => CountryModel.fromJson(json)).toList(); + } + + @override + Future> logout(String token) { + return safeApiCall(call: () => apiClient.logout(token)); + } +} diff --git a/lib/features/auth/data/datasource/auth_remote_datasource.dart b/lib/features/auth/data/datasource/auth_remote_datasource.dart new file mode 100644 index 0000000..ce683df --- /dev/null +++ b/lib/features/auth/data/datasource/auth_remote_datasource.dart @@ -0,0 +1,42 @@ +import 'package:tracking_app/features/auth/data/models/response/logout_response_dto/logout_response_dto.dart'; +import '../../../../app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/models/request/forget_password_request.dart'; +import 'package:tracking_app/features/auth/data/models/request/resetpassword_request.dart'; +import 'package:tracking_app/features/auth/data/models/request/verifyreset_request.dart'; +import 'package:tracking_app/features/auth/data/models/response/forgetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/resetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/verifyreset_response.dart'; +import '../models/response/country_model.dart'; +import '../models/response/vehicles_response_model.dart'; +import '../models/request/apply_request_model.dart'; +import '../models/response/apply_response_model.dart'; +import 'package:tracking_app/features/auth/data/model/request/LoginRequest.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:tracking_app/features/auth/data/model/response/change_password_dto.dart'; + +abstract class AuthRemoteDataSource { + Future> getAllVehicle(); + Future> apply( + ApplyRequestModel applyRequestModel, + ); + Future> getCountries(); + + Future?> login(LoginRequest loginRequest); + + Future> changePassword({ + required String token, + String? password, + String? newPassword, + }); + Future?> forgetPassword( + ForgetPasswordRequest request, + ); + Future?> verifyResetCode( + VerifyResetRequest request, + ); + Future?> resetPassword( + ResetPasswordRequest request, + ); + + Future> logout(String token); +} diff --git a/lib/features/auth/data/datasource/country_local_datasource.dart b/lib/features/auth/data/datasource/country_local_datasource.dart new file mode 100644 index 0000000..47438fd --- /dev/null +++ b/lib/features/auth/data/datasource/country_local_datasource.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:injectable/injectable.dart'; +import '../models/response/country_model.dart'; + +abstract class CountryLocalDataSource { + Future> getCountries(); +} + +@LazySingleton(as: CountryLocalDataSource) +class CountryLocalDataSourceImpl implements CountryLocalDataSource { + @override + Future> getCountries() async { + final String response = await rootBundle.loadString( + 'assets/data/country.json', + ); + final List data = json.decode(response); + return data.map((json) => CountryModel.fromJson(json)).toList(); + } +} diff --git a/lib/features/auth/data/mapper/change_password_dto_mapper.dart b/lib/features/auth/data/mapper/change_password_dto_mapper.dart new file mode 100644 index 0000000..357ad32 --- /dev/null +++ b/lib/features/auth/data/mapper/change_password_dto_mapper.dart @@ -0,0 +1,8 @@ +import 'package:tracking_app/features/auth/data/model/response/change_password_dto.dart'; +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; + +extension ChangePasswordDtoMapper on ChangePasswordDto { + ChangePasswordModel toChangePassModel() { + return ChangePasswordModel(message: message, token: token, error: error); + } +} diff --git a/lib/features/auth/data/mapper/vehicles_mapper.dart b/lib/features/auth/data/mapper/vehicles_mapper.dart new file mode 100644 index 0000000..66f1e63 --- /dev/null +++ b/lib/features/auth/data/mapper/vehicles_mapper.dart @@ -0,0 +1,7 @@ +import 'package:tracking_app/features/auth/data/models/response/vehicle_model.dart'; + +extension VehiclesResponseExtention on VehicleModel { + VehicleModel toVehicleType() { + return VehicleModel(type: type ?? "", id: id); + } +} diff --git a/lib/features/auth/data/model/request/LoginRequest.dart b/lib/features/auth/data/model/request/LoginRequest.dart new file mode 100644 index 0000000..dabe5df --- /dev/null +++ b/lib/features/auth/data/model/request/LoginRequest.dart @@ -0,0 +1,17 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'LoginRequest.g.dart'; + +@JsonSerializable() +class LoginRequest { + @JsonKey(name: "email") + String? email; + @JsonKey(name: "password") + String? password; + + LoginRequest({this.email, this.password}); + + factory LoginRequest.fromJson(Map json) => + _$LoginRequestFromJson(json); + + Map toJson() => _$LoginRequestToJson(this); +} diff --git a/lib/features/auth/data/model/response/LoginResponse.dart b/lib/features/auth/data/model/response/LoginResponse.dart new file mode 100644 index 0000000..f926203 --- /dev/null +++ b/lib/features/auth/data/model/response/LoginResponse.dart @@ -0,0 +1,17 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'LoginResponse.g.dart'; + +@JsonSerializable() +class LoginResponse { + @JsonKey(name: "message") + String? message; + @JsonKey(name: "token") + String? token; + + LoginResponse({this.message, this.token}); + + factory LoginResponse.fromJson(Map json) => + _$LoginResponseFromJson(json); + + Map toJson() => _$LoginResponseToJson(this); +} diff --git a/lib/features/auth/data/model/response/change_password_dto.dart b/lib/features/auth/data/model/response/change_password_dto.dart new file mode 100644 index 0000000..c3dba74 --- /dev/null +++ b/lib/features/auth/data/model/response/change_password_dto.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'change_password_dto.g.dart'; + +@JsonSerializable() +class ChangePasswordDto { + @JsonKey(name: 'message') + final String? message; + @JsonKey(name: 'error') + final String? error; + @JsonKey(name: 'token') + final String? token; + + ChangePasswordDto({this.message, this.error, this.token}); + + factory ChangePasswordDto.fromJson(Map json) => + _$ChangePasswordDtoFromJson(json); + Map toJson() => _$ChangePasswordDtoToJson(this); +} diff --git a/lib/features/auth/data/models/request/apply_request_model.dart b/lib/features/auth/data/models/request/apply_request_model.dart new file mode 100644 index 0000000..fd07e11 --- /dev/null +++ b/lib/features/auth/data/models/request/apply_request_model.dart @@ -0,0 +1,44 @@ +import 'dart:io'; +import 'package:json_annotation/json_annotation.dart'; + +part 'apply_request_model.g.dart'; + +@JsonSerializable() +class ApplyRequestModel { + final String? country; + final String? firstName; + final String? lastName; + final String? vehicleType; + final String? vehicleNumber; + final String? NID; + final String? email; + final String? password; + final String? rePassword; + final String? gender; + final String? phone; + @JsonKey(includeFromJson: false, includeToJson: false) + final File? vehicleLicense; + @JsonKey(includeFromJson: false, includeToJson: false) + final File? NIDimg; + + const ApplyRequestModel({ + this.country, + this.firstName, + this.lastName, + this.vehicleType, + this.vehicleNumber, + this.vehicleLicense, + this.NID, + this.NIDimg, + this.email, + this.password, + this.rePassword, + this.gender, + this.phone, + }); + + factory ApplyRequestModel.fromJson(Map json) => + _$ApplyRequestModelFromJson(json); + + Map toJson() => _$ApplyRequestModelToJson(this); +} diff --git a/lib/features/auth/data/models/request/forget_password_request.dart b/lib/features/auth/data/models/request/forget_password_request.dart new file mode 100644 index 0000000..4d62891 --- /dev/null +++ b/lib/features/auth/data/models/request/forget_password_request.dart @@ -0,0 +1,11 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'forget_password_request.g.dart'; + +@JsonSerializable() +class ForgetPasswordRequest { + final String email; + ForgetPasswordRequest({required this.email}); + factory ForgetPasswordRequest.fromJson(Map json) => + _$ForgetPasswordRequestFromJson(json); + Map toJson() => _$ForgetPasswordRequestToJson(this); +} diff --git a/lib/features/auth/data/models/request/resetpassword_request.dart b/lib/features/auth/data/models/request/resetpassword_request.dart new file mode 100644 index 0000000..acbe38c --- /dev/null +++ b/lib/features/auth/data/models/request/resetpassword_request.dart @@ -0,0 +1,12 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'resetpassword_request.g.dart'; + +@JsonSerializable() +class ResetPasswordRequest { + final String email; + final String newPassword; + ResetPasswordRequest({required this.email, required this.newPassword}); + factory ResetPasswordRequest.fromJson(Map json) => + _$ResetPasswordRequestFromJson(json); + Map toJson() => _$ResetPasswordRequestToJson(this); +} diff --git a/lib/features/auth/data/models/request/verifyreset_request.dart b/lib/features/auth/data/models/request/verifyreset_request.dart new file mode 100644 index 0000000..0111df8 --- /dev/null +++ b/lib/features/auth/data/models/request/verifyreset_request.dart @@ -0,0 +1,11 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'verifyreset_request.g.dart'; + +@JsonSerializable() +class VerifyResetRequest { + final String resetCode; + VerifyResetRequest({required this.resetCode}); + factory VerifyResetRequest.fromJson(Map json) => + _$VerifyResetRequestFromJson(json); + Map toJson() => _$VerifyResetRequestToJson(this); +} diff --git a/lib/features/auth/data/models/response/apply_response_model.dart b/lib/features/auth/data/models/response/apply_response_model.dart new file mode 100644 index 0000000..6f30e01 --- /dev/null +++ b/lib/features/auth/data/models/response/apply_response_model.dart @@ -0,0 +1,58 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'apply_response_model.g.dart'; + +@JsonSerializable() +class ApplyResponseModel { + final String? message; + final String? token; + + final String? country; + final String? firstName; + final String? lastName; + final String? vehicleType; + final String? vehicleNumber; + final String? vehicleLicense; + + @JsonKey(name: 'NID') + final String? nid; + + @JsonKey(name: 'NIDImg') + final String? nidImg; + + final String? email; + final String? gender; + final String? phone; + final String? photo; + final String? role; + + @JsonKey(name: '_id') + final String? id; + + final DateTime? createdAt; + + ApplyResponseModel({ + this.message, + this.token, + this.country, + this.firstName, + this.lastName, + this.vehicleType, + this.vehicleNumber, + this.vehicleLicense, + this.nid, + this.nidImg, + this.email, + this.gender, + this.phone, + this.photo, + this.role, + this.id, + this.createdAt, + }); + + factory ApplyResponseModel.fromJson(Map json) => + _$ApplyResponseModelFromJson(json); + + Map toJson() => _$ApplyResponseModelToJson(this); +} diff --git a/lib/features/auth/data/models/response/country_model.dart b/lib/features/auth/data/models/response/country_model.dart new file mode 100644 index 0000000..ec4b7ed --- /dev/null +++ b/lib/features/auth/data/models/response/country_model.dart @@ -0,0 +1,14 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../../domain/entities/country_entity.dart'; + +part 'country_model.g.dart'; + +@JsonSerializable() +class CountryModel extends CountryEntity { + const CountryModel({super.name, super.flag, super.phoneCode, super.isoCode}); + + factory CountryModel.fromJson(Map json) => + _$CountryModelFromJson(json); + + Map toJson() => _$CountryModelToJson(this); +} diff --git a/lib/features/auth/data/models/response/forgetpassword_response.dart b/lib/features/auth/data/models/response/forgetpassword_response.dart new file mode 100644 index 0000000..50e6628 --- /dev/null +++ b/lib/features/auth/data/models/response/forgetpassword_response.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'forgetpassword_response.g.dart'; + +@JsonSerializable() +class ForgetpasswordResponse { + @JsonKey(name: "message") + final String? message; + @JsonKey(name: "info") + final String? info; + + ForgetpasswordResponse({this.message, this.info}); + + ForgetpasswordResponse copyWith({String? message, String? info}) => + ForgetpasswordResponse( + message: message ?? this.message, + info: info ?? this.info, + ); + + factory ForgetpasswordResponse.fromJson(Map json) => + _$ForgetpasswordResponseFromJson(json); + + Map toJson() => _$ForgetpasswordResponseToJson(this); +} diff --git a/lib/features/auth/data/models/response/logout_response_dto/logout_response_dto.dart b/lib/features/auth/data/models/response/logout_response_dto/logout_response_dto.dart new file mode 100644 index 0000000..e6b87bc --- /dev/null +++ b/lib/features/auth/data/models/response/logout_response_dto/logout_response_dto.dart @@ -0,0 +1,17 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'logout_response_dto.g.dart'; + +@JsonSerializable() +class LogoutResponseDto { + @JsonKey(name: "message") + final String? message; + @JsonKey(name: "error") + final String? error; + + LogoutResponseDto({this.message, this.error}); + + factory LogoutResponseDto.fromJson(Map json) { + return _$LogoutResponseDtoFromJson(json); + } +} diff --git a/lib/features/auth/data/models/response/logout_response_dto/logout_response_dto.g.dart b/lib/features/auth/data/models/response/logout_response_dto/logout_response_dto.g.dart new file mode 100644 index 0000000..87683ff --- /dev/null +++ b/lib/features/auth/data/models/response/logout_response_dto/logout_response_dto.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'logout_response_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LogoutResponseDto _$LogoutResponseDtoFromJson(Map json) => + LogoutResponseDto( + message: json['message'] as String?, + error: json['error'] as String?, + ); + +Map _$LogoutResponseDtoToJson(LogoutResponseDto instance) => + {'message': instance.message, 'error': instance.error}; diff --git a/lib/features/auth/data/models/response/metadata_model.dart b/lib/features/auth/data/models/response/metadata_model.dart new file mode 100644 index 0000000..b9c80f7 --- /dev/null +++ b/lib/features/auth/data/models/response/metadata_model.dart @@ -0,0 +1,23 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'metadata_model.g.dart'; + +@JsonSerializable() +class Metadata { + final int currentPage; + final int totalPages; + final int limit; + final int totalItems; + + Metadata({ + required this.currentPage, + required this.totalPages, + required this.limit, + required this.totalItems, + }); + + factory Metadata.fromJson(Map json) => + _$MetadataFromJson(json); + + Map toJson() => _$MetadataToJson(this); +} diff --git a/lib/features/auth/data/models/response/resetpassword_response.dart b/lib/features/auth/data/models/response/resetpassword_response.dart new file mode 100644 index 0000000..fc5b7d2 --- /dev/null +++ b/lib/features/auth/data/models/response/resetpassword_response.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'resetpassword_response.g.dart'; + +@JsonSerializable() +class ResetpasswordResponse { + @JsonKey(name: "message") + final String? message; + @JsonKey(name: "token") + final String? token; + + ResetpasswordResponse({this.message, this.token}); + + ResetpasswordResponse copyWith({String? message, String? token}) => + ResetpasswordResponse( + message: message ?? this.message, + token: token ?? this.token, + ); + + factory ResetpasswordResponse.fromJson(Map json) => + _$ResetpasswordResponseFromJson(json); + + Map toJson() => _$ResetpasswordResponseToJson(this); +} diff --git a/lib/features/auth/data/models/response/vechicles_entity.dart b/lib/features/auth/data/models/response/vechicles_entity.dart new file mode 100644 index 0000000..a774ca8 --- /dev/null +++ b/lib/features/auth/data/models/response/vechicles_entity.dart @@ -0,0 +1,7 @@ +import 'package:tracking_app/features/auth/data/models/response/vehicle_model.dart'; + +class VehiclesEntity { + final List vehicles; + + VehiclesEntity({required this.vehicles}); +} diff --git a/lib/features/auth/data/models/response/vehicle_model.dart b/lib/features/auth/data/models/response/vehicle_model.dart new file mode 100644 index 0000000..fb1fe8e --- /dev/null +++ b/lib/features/auth/data/models/response/vehicle_model.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:tracking_app/features/auth/data/models/response/vechicles_entity.dart'; + +part 'vehicle_model.g.dart'; + +@JsonSerializable() +class VehicleModel { + @JsonKey(name: '_id') + final String? id; + final String? type; + final String? image; + final DateTime? createdAt; + final DateTime? updatedAt; + + @JsonKey(name: '__v') + final int? version; + + VehicleModel({ + this.id, + this.type, + this.image, + this.createdAt, + this.updatedAt, + this.version, + }); + + factory VehicleModel.fromJson(Map json) => + _$VehicleModelFromJson(json); + + Map toJson() => _$VehicleModelToJson(this); +} diff --git a/lib/features/auth/data/models/response/vehicles_response_model.dart b/lib/features/auth/data/models/response/vehicles_response_model.dart new file mode 100644 index 0000000..c4e26e6 --- /dev/null +++ b/lib/features/auth/data/models/response/vehicles_response_model.dart @@ -0,0 +1,25 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:tracking_app/features/auth/data/models/response/vechicles_entity.dart'; +import 'package:tracking_app/features/auth/data/models/response/vehicle_model.dart'; + +import 'metadata_model.dart'; + +part 'vehicles_response_model.g.dart'; + +@JsonSerializable() +class VehiclesResponse { + final String message; + final Metadata metadata; + final List vehicles; + + VehiclesResponse({ + required this.message, + required this.metadata, + required this.vehicles, + }); + + factory VehiclesResponse.fromJson(Map json) => + _$VehiclesResponseFromJson(json); + + Map toJson() => _$VehiclesResponseToJson(this); +} diff --git a/lib/features/auth/data/models/response/verifyreset_response.dart b/lib/features/auth/data/models/response/verifyreset_response.dart new file mode 100644 index 0000000..8a61817 --- /dev/null +++ b/lib/features/auth/data/models/response/verifyreset_response.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'verifyreset_response.g.dart'; + +@JsonSerializable() +class VerifyresetResponse { + @JsonKey(name: "status") + final String? status; + + VerifyresetResponse({this.status}); + + VerifyresetResponse copyWith({String? status}) => + VerifyresetResponse(status: status ?? this.status); + + factory VerifyresetResponse.fromJson(Map json) => + _$VerifyresetResponseFromJson(json); + + Map toJson() => _$VerifyresetResponseToJson(this); +} diff --git a/lib/features/auth/data/repos/auth_repo_impl.dart b/lib/features/auth/data/repos/auth_repo_impl.dart new file mode 100644 index 0000000..9d48d33 --- /dev/null +++ b/lib/features/auth/data/repos/auth_repo_impl.dart @@ -0,0 +1,191 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/datasource/auth_remote_datasource.dart'; +import 'package:tracking_app/features/auth/data/mapper/vehicles_mapper.dart'; +import 'package:tracking_app/features/auth/data/mapper/change_password_dto_mapper.dart'; +import 'package:tracking_app/features/auth/data/model/request/LoginRequest.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:tracking_app/features/auth/data/model/response/change_password_dto.dart'; + +import 'package:tracking_app/features/auth/data/models/request/apply_request_model.dart'; +import 'package:tracking_app/features/auth/data/models/request/forget_password_request.dart'; +import 'package:tracking_app/features/auth/data/models/request/resetpassword_request.dart'; +import 'package:tracking_app/features/auth/data/models/response/logout_response_dto/logout_response_dto.dart'; +import 'package:tracking_app/features/auth/data/models/response/vehicles_response_model.dart'; +import 'package:tracking_app/features/auth/domain/entities/country_entity.dart'; +import 'package:tracking_app/features/auth/data/models/request/verifyreset_request.dart'; +import 'package:tracking_app/features/auth/data/models/response/apply_response_model.dart'; +import 'package:tracking_app/features/auth/data/models/response/forgetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/resetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/vehicle_model.dart'; +import 'package:tracking_app/features/auth/data/models/response/verifyreset_response.dart'; + +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; +import 'package:tracking_app/features/auth/domain/models/forgetpassword_entitiy.dart'; +import 'package:tracking_app/features/auth/domain/models/resetpassword_entity.dart'; +import 'package:tracking_app/features/auth/domain/models/verifyreset_entity.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; + +@Injectable(as: AuthRepo) +class AuthRepoImpl implements AuthRepo { + final AuthRemoteDataSource authDatasource; + + AuthRepoImpl(this.authDatasource); + + @override + Future> forgetPassword(String email) async { + final result = await authDatasource.forgetPassword( + ForgetPasswordRequest(email: email), + ); + + if (result is SuccessApiResult) { + return SuccessApiResult( + data: ForgetPasswordEntitiy( + message: result.data.message, + info: result.data.info, + ), + ); + } + + if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } + + return ErrorApiResult(error: 'Unexpected error'); + } + + @override + Future> verifyResetCode(String code) async { + final result = await authDatasource.verifyResetCode( + VerifyResetRequest(resetCode: code), + ); + + if (result is SuccessApiResult) { + return SuccessApiResult( + data: VerifyResetCodeEntity(status: result.data.status), + ); + } + + if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } + + return ErrorApiResult(error: 'Unexpected error'); + } + + @override + Future> resetPassword( + ResetPasswordRequest request, + ) async { + final result = await authDatasource.resetPassword(request); + + if (result is SuccessApiResult) { + return SuccessApiResult( + data: ResetPasswordEntity( + token: result.data.token, + message: result.data.message, + ), + ); + } + + if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } + + return ErrorApiResult(error: 'Unexpected error'); + } + + @override + Future> login(String email, String password) async { + final loginRequest = LoginRequest(email: email, password: password); + final result = await authDatasource.login(loginRequest); + + if (result is SuccessApiResult) { + return SuccessApiResult(data: result.data); + } + + if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } + + return ErrorApiResult(error: 'Unknown error'); + } + + @override + Future> changePassword({ + required String token, + String? password, + String? newPassword, + }) async { + final response = await authDatasource.changePassword( + token: token, + password: password, + newPassword: newPassword, + ); + + if (response is SuccessApiResult) { + final dto = response.data; + return SuccessApiResult(data: dto.toChangePassModel()); + } + + if (response is ErrorApiResult) { + return ErrorApiResult(error: response.error); + } + + return ErrorApiResult(error: 'Unknown error'); + } + + @override + Future>> getAllVehicles() async { + final result = await authDatasource.getAllVehicle(); + + if (result is SuccessApiResult) { + return SuccessApiResult( + data: result.data.vehicles.map((v) => v.toVehicleType()).toList(), + ); + } + + if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } + + return ErrorApiResult(error: 'Unknown error'); + } + + @override + Future>> getCountries() async { + try { + final response = await authDatasource.getCountries(); + return SuccessApiResult(data: response); + } catch (error) { + return ErrorApiResult(error: error.toString()); + } + } + + @override + Future> apply(ApplyRequestModel request) async { + final result = await authDatasource.apply(request); + + if (result is SuccessApiResult) { + return SuccessApiResult(data: result.data); + } + + if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } + + return ErrorApiResult(error: 'Unknown error'); + } + + @override + Future> logout(String token) async { + final result = await authDatasource.logout(token); + if (result is SuccessApiResult) { + return SuccessApiResult(data: result.data); + } + if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } + return ErrorApiResult(error: 'Unexpected error'); + } +} diff --git a/lib/features/auth/domain/entities/country_entity.dart b/lib/features/auth/domain/entities/country_entity.dart new file mode 100644 index 0000000..9d74e6b --- /dev/null +++ b/lib/features/auth/domain/entities/country_entity.dart @@ -0,0 +1,13 @@ +import 'package:equatable/equatable.dart'; + +class CountryEntity extends Equatable { + final String? name; + final String? flag; + final String? phoneCode; + final String? isoCode; + + const CountryEntity({this.name, this.flag, this.phoneCode, this.isoCode}); + + @override + List get props => [name, flag, phoneCode, isoCode]; +} diff --git a/lib/features/auth/domain/models/change_password_model.dart b/lib/features/auth/domain/models/change_password_model.dart new file mode 100644 index 0000000..e0e41c0 --- /dev/null +++ b/lib/features/auth/domain/models/change_password_model.dart @@ -0,0 +1,7 @@ +class ChangePasswordModel { + final String? message; + final String? error; + final String? token; + + ChangePasswordModel({this.message, this.error, this.token}); +} diff --git a/lib/features/auth/domain/models/forgetpassword_entitiy.dart b/lib/features/auth/domain/models/forgetpassword_entitiy.dart new file mode 100644 index 0000000..73a57ae --- /dev/null +++ b/lib/features/auth/domain/models/forgetpassword_entitiy.dart @@ -0,0 +1,6 @@ +class ForgetPasswordEntitiy { + final String? message; + final String? info; + + ForgetPasswordEntitiy({required this.message, required this.info}); +} diff --git a/lib/features/auth/domain/models/resetpassword_entity.dart b/lib/features/auth/domain/models/resetpassword_entity.dart new file mode 100644 index 0000000..e9b1d64 --- /dev/null +++ b/lib/features/auth/domain/models/resetpassword_entity.dart @@ -0,0 +1,6 @@ +class ResetPasswordEntity { + final String? message; + final String? token; + + const ResetPasswordEntity({required this.message, this.token}); +} diff --git a/lib/features/auth/domain/models/verifyreset_entity.dart b/lib/features/auth/domain/models/verifyreset_entity.dart new file mode 100644 index 0000000..2a9dd14 --- /dev/null +++ b/lib/features/auth/domain/models/verifyreset_entity.dart @@ -0,0 +1,5 @@ +class VerifyResetCodeEntity { + final String? status; + + VerifyResetCodeEntity({required this.status}); +} diff --git a/lib/features/auth/domain/repos/auth_repo.dart b/lib/features/auth/domain/repos/auth_repo.dart new file mode 100644 index 0000000..fca23ea --- /dev/null +++ b/lib/features/auth/domain/repos/auth_repo.dart @@ -0,0 +1,35 @@ +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:tracking_app/features/auth/data/models/request/apply_request_model.dart'; +import 'package:tracking_app/features/auth/data/models/request/resetpassword_request.dart'; +import 'package:tracking_app/features/auth/data/models/response/apply_response_model.dart'; +import 'package:tracking_app/features/auth/data/models/response/logout_response_dto/logout_response_dto.dart'; +import 'package:tracking_app/features/auth/data/models/response/vehicle_model.dart'; +import 'package:tracking_app/features/auth/domain/entities/country_entity.dart'; +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; +import 'package:tracking_app/features/auth/domain/models/forgetpassword_entitiy.dart'; +import 'package:tracking_app/features/auth/domain/models/resetpassword_entity.dart'; +import 'package:tracking_app/features/auth/domain/models/verifyreset_entity.dart'; + +abstract class AuthRepo { + Future> forgetPassword(String email); + Future> verifyResetCode(String code); + Future> resetPassword( + ResetPasswordRequest request, + ); + + Future>> getAllVehicles(); + Future>> getCountries(); + Future> apply( + ApplyRequestModel applyRequestModel, + ); + Future> login(String email, String password); + + Future> changePassword({ + required String token, + String? password, + String? newPassword, + }); + + Future> logout(String token); +} diff --git a/lib/features/auth/domain/usecase/apply_usecase.dart b/lib/features/auth/domain/usecase/apply_usecase.dart new file mode 100644 index 0000000..60faa88 --- /dev/null +++ b/lib/features/auth/domain/usecase/apply_usecase.dart @@ -0,0 +1,18 @@ +import 'package:injectable/injectable.dart'; +import '../../../../app/core/network/api_result.dart'; +import '../../data/models/request/apply_request_model.dart'; +import '../../data/models/response/apply_response_model.dart'; +import '../repos/auth_repo.dart'; + +@lazySingleton +class ApplyUseCase { + final AuthRepo repo; + + ApplyUseCase(this.repo); + + Future> call( + ApplyRequestModel applyRequestModel, + ) { + return repo.apply(applyRequestModel); + } +} diff --git a/lib/features/auth/domain/usecase/change_password_usecase.dart b/lib/features/auth/domain/usecase/change_password_usecase.dart new file mode 100644 index 0000000..65a2642 --- /dev/null +++ b/lib/features/auth/domain/usecase/change_password_usecase.dart @@ -0,0 +1,21 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; + +@injectable +class ChangePasswordUsecase { + AuthRepo authRepo; + ChangePasswordUsecase(this.authRepo); + Future> call({ + required String token, + String? password, + String? newPassword, + }) { + return authRepo.changePassword( + token: token, + password: password, + newPassword: newPassword, + ); + } +} diff --git a/lib/features/auth/domain/usecase/forgetpassword_usecase.dart b/lib/features/auth/domain/usecase/forgetpassword_usecase.dart new file mode 100644 index 0000000..87117b0 --- /dev/null +++ b/lib/features/auth/domain/usecase/forgetpassword_usecase.dart @@ -0,0 +1,13 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/domain/models/forgetpassword_entitiy.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; + +@injectable +class ForgetPasswordUsecase { + AuthRepo authRepo; + ForgetPasswordUsecase(this.authRepo); + Future> call(String email) { + return authRepo.forgetPassword(email); + } +} diff --git a/lib/features/auth/domain/usecase/get_all_vehicles_usecase.dart b/lib/features/auth/domain/usecase/get_all_vehicles_usecase.dart new file mode 100644 index 0000000..9989b03 --- /dev/null +++ b/lib/features/auth/domain/usecase/get_all_vehicles_usecase.dart @@ -0,0 +1,16 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/features/auth/data/models/response/vehicle_model.dart'; + +import '../../../../app/core/network/api_result.dart'; +import '../repos/auth_repo.dart'; + +@lazySingleton +class GetAllVehiclesUseCase { + final AuthRepo repo; + + GetAllVehiclesUseCase(this.repo); + + Future>> call() { + return repo.getAllVehicles(); + } +} diff --git a/lib/features/auth/domain/usecase/get_countries_usecase.dart b/lib/features/auth/domain/usecase/get_countries_usecase.dart new file mode 100644 index 0000000..a0a45ad --- /dev/null +++ b/lib/features/auth/domain/usecase/get_countries_usecase.dart @@ -0,0 +1,15 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import '../entities/country_entity.dart'; +import '../repos/auth_repo.dart'; + +@injectable +class GetCountriesUseCase { + final AuthRepo repo; + + GetCountriesUseCase(this.repo); + + Future>> call() async { + return await repo.getCountries(); + } +} diff --git a/lib/features/auth/domain/usecase/login_usecase.dart b/lib/features/auth/domain/usecase/login_usecase.dart new file mode 100644 index 0000000..bc7a0b5 --- /dev/null +++ b/lib/features/auth/domain/usecase/login_usecase.dart @@ -0,0 +1,14 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; + +@injectable +class LoginUseCase { + final AuthRepo _authRepo; + LoginUseCase(this._authRepo); + + Future> call(String email, String password) async { + return await _authRepo.login(email, password); + } +} diff --git a/lib/features/auth/domain/usecase/logout_usecase.dart b/lib/features/auth/domain/usecase/logout_usecase.dart new file mode 100644 index 0000000..2e32b52 --- /dev/null +++ b/lib/features/auth/domain/usecase/logout_usecase.dart @@ -0,0 +1,14 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/features/auth/data/models/response/logout_response_dto/logout_response_dto.dart'; + +import '../../../../app/core/network/api_result.dart'; +import '../repos/auth_repo.dart'; + +@injectable +class LogoutUseCase { + final AuthRepo _authRepo; + LogoutUseCase(this._authRepo); + Future> call(String token) async { + return await _authRepo.logout(token); + } +} diff --git a/lib/features/auth/domain/usecase/resertpassword_usecase.dart b/lib/features/auth/domain/usecase/resertpassword_usecase.dart new file mode 100644 index 0000000..f2b0d46 --- /dev/null +++ b/lib/features/auth/domain/usecase/resertpassword_usecase.dart @@ -0,0 +1,14 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/models/request/resetpassword_request.dart'; +import 'package:tracking_app/features/auth/domain/models/resetpassword_entity.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; + +@injectable +class ResetPasswordUsecase { + AuthRepo authRepo; + ResetPasswordUsecase(this.authRepo); + Future> call(ResetPasswordRequest request) { + return authRepo.resetPassword(request); + } +} diff --git a/lib/features/auth/domain/usecase/verifyreaset_usecase.dart b/lib/features/auth/domain/usecase/verifyreaset_usecase.dart new file mode 100644 index 0000000..f7f8c2f --- /dev/null +++ b/lib/features/auth/domain/usecase/verifyreaset_usecase.dart @@ -0,0 +1,13 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/domain/models/verifyreset_entity.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; + +@injectable +class VerifyResetCodeUsecase { + AuthRepo authRepo; + VerifyResetCodeUsecase(this.authRepo); + Future> call(String code) { + return authRepo.verifyResetCode(code); + } +} diff --git a/lib/features/auth/presentation/apply/manager/apply_cubit.dart b/lib/features/auth/presentation/apply/manager/apply_cubit.dart new file mode 100644 index 0000000..3b2c392 --- /dev/null +++ b/lib/features/auth/presentation/apply/manager/apply_cubit.dart @@ -0,0 +1,85 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import '../../../domain/usecase/get_countries_usecase.dart'; +import '../../../../../app/core/network/api_result.dart'; +import 'apply_state.dart'; +import '../../../domain/entities/country_entity.dart'; +import '../../../domain/usecase/get_all_vehicles_usecase.dart'; +import 'apply_intent.dart'; +import 'package:tracking_app/features/auth/data/models/response/vehicle_model.dart'; +import 'package:tracking_app/features/auth/domain/usecase/apply_usecase.dart'; +import 'package:tracking_app/features/auth/data/models/request/apply_request_model.dart'; +import 'package:tracking_app/features/auth/data/models/response/apply_response_model.dart'; + +@injectable +class ApplyCubit extends Cubit { + final GetCountriesUseCase _getCountriesUseCase; + final GetAllVehiclesUseCase _getAllVehiclesUseCase; + final ApplyUseCase _applyUseCase; + + ApplyCubit( + this._getCountriesUseCase, + this._getAllVehiclesUseCase, + this._applyUseCase, + ) : super(const ApplyState()); + + Future onIntent(ApplyIntent intent) async { + if (intent is GetCountriesIntent) { + await _getCountries(); + } else if (intent is GetVehiclesIntent) { + await _getVehicles(); + } else if (intent is SubmitApplyIntent) { + await _submitApply(intent.applyRequestModel); + } + } + + Future _getCountries() async { + emit(state.copyWith(status: ApplyStatus.loading)); + final result = await _getCountriesUseCase(); + + if (result is SuccessApiResult>) { + emit(state.copyWith(status: ApplyStatus.success, countries: result.data)); + } else if (result is ErrorApiResult>) { + emit( + state.copyWith(status: ApplyStatus.failure, errorMessage: result.error), + ); + } + } + + Future _getVehicles() async { + emit(state.copyWith(vehiclesStatus: ApplyStatus.loading)); + final result = await _getAllVehiclesUseCase(); + + if (result is SuccessApiResult>) { + emit( + state.copyWith( + vehiclesStatus: ApplyStatus.success, + vehicles: result.data, + ), + ); + } else if (result is ErrorApiResult>) { + emit( + state.copyWith( + vehiclesStatus: ApplyStatus.failure, + vehiclesErrorMessage: result.error, + ), + ); + } + } + + Future _submitApply(ApplyRequestModel applyRequestModel) async { + emit(state.copyWith(applyStatus: ApplyStatus.loading)); + final result = await _applyUseCase(applyRequestModel); + + if (result is SuccessApiResult) { + emit(state.copyWith(applyStatus: ApplyStatus.success)); + } else if (result is ErrorApiResult) { + emit( + state.copyWith( + applyStatus: ApplyStatus.failure, + applyErrorMessage: result.error, + ), + ); + } + } +} diff --git a/lib/features/auth/presentation/apply/manager/apply_intent.dart b/lib/features/auth/presentation/apply/manager/apply_intent.dart new file mode 100644 index 0000000..45b61b1 --- /dev/null +++ b/lib/features/auth/presentation/apply/manager/apply_intent.dart @@ -0,0 +1,12 @@ +import 'package:tracking_app/features/auth/data/models/request/apply_request_model.dart'; + +abstract class ApplyIntent {} + +class GetCountriesIntent extends ApplyIntent {} + +class GetVehiclesIntent extends ApplyIntent {} + +class SubmitApplyIntent extends ApplyIntent { + final ApplyRequestModel applyRequestModel; + SubmitApplyIntent(this.applyRequestModel); +} diff --git a/lib/features/auth/presentation/apply/manager/apply_state.dart b/lib/features/auth/presentation/apply/manager/apply_state.dart new file mode 100644 index 0000000..0e06dbe --- /dev/null +++ b/lib/features/auth/presentation/apply/manager/apply_state.dart @@ -0,0 +1,63 @@ +import 'package:equatable/equatable.dart'; +import '../../../domain/entities/country_entity.dart'; +import 'package:tracking_app/features/auth/data/models/response/vehicle_model.dart'; + +enum ApplyStatus { initial, loading, success, failure } + +class ApplyState extends Equatable { + final ApplyStatus status; + final List countries; + final String? errorMessage; + + final ApplyStatus vehiclesStatus; + final List vehicles; + final String? vehiclesErrorMessage; + + final ApplyStatus applyStatus; + final String? applyErrorMessage; + + const ApplyState({ + this.status = ApplyStatus.initial, + this.countries = const [], + this.errorMessage, + this.vehiclesStatus = ApplyStatus.initial, + this.vehicles = const [], + this.vehiclesErrorMessage, + this.applyStatus = ApplyStatus.initial, + this.applyErrorMessage, + }); + + ApplyState copyWith({ + ApplyStatus? status, + List? countries, + String? errorMessage, + ApplyStatus? vehiclesStatus, + List? vehicles, + String? vehiclesErrorMessage, + ApplyStatus? applyStatus, + String? applyErrorMessage, + }) { + return ApplyState( + status: status ?? this.status, + countries: countries ?? this.countries, + errorMessage: errorMessage ?? this.errorMessage, + vehiclesStatus: vehiclesStatus ?? this.vehiclesStatus, + vehicles: vehicles ?? this.vehicles, + vehiclesErrorMessage: vehiclesErrorMessage ?? this.vehiclesErrorMessage, + applyStatus: applyStatus ?? this.applyStatus, + applyErrorMessage: applyErrorMessage ?? this.applyErrorMessage, + ); + } + + @override + List get props => [ + status, + countries, + errorMessage, + vehiclesStatus, + vehicles, + vehiclesErrorMessage, + applyStatus, + applyErrorMessage, + ]; +} diff --git a/lib/features/auth/presentation/apply/view/apply_success_view.dart b/lib/features/auth/presentation/apply/view/apply_success_view.dart new file mode 100644 index 0000000..10ce2d0 --- /dev/null +++ b/lib/features/auth/presentation/apply/view/apply_success_view.dart @@ -0,0 +1,106 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../../app/core/router/route_names.dart'; +import '../../../../../generated/locale_keys.g.dart'; + +class ApplySuccessScreen extends StatelessWidget { + const ApplySuccessScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Success Icon + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: Colors.green.shade50, + shape: BoxShape.circle, + ), + child: Center( + child: SvgPicture.asset( + "assets/images/Vector.svg", + width: 120, + height: 120, + ), + ), + ), + const SizedBox(height: 32), + + // Success Title + Text( + LocaleKeys.applicationSubmitted.tr(), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + // Success Message + Text( + LocaleKeys.congratulationsMessage.tr(), + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + + Text( + LocaleKeys.reviewMessage.tr(), + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + + // Return to Login Button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + context.go(RouteNames.login); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD01C68), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + elevation: 2, + ), + child: Text( + LocaleKeys.backToLogin.tr(), + style: const TextStyle( + fontSize: 18, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/apply/view/apply_view.dart b/lib/features/auth/presentation/apply/view/apply_view.dart new file mode 100644 index 0000000..401175e --- /dev/null +++ b/lib/features/auth/presentation/apply/view/apply_view.dart @@ -0,0 +1,476 @@ +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'apply_success_view.dart'; +import '../../../../../generated/locale_keys.g.dart'; +import '../../../../../app/config/di/di.dart'; +import '../manager/apply_cubit.dart'; +import '../manager/apply_state.dart'; +import '../manager/apply_intent.dart'; +import '../../../../../app/core/widgets/custom_text_form_field.dart'; +import '../../../../../app/core/utils/validators_helper.dart'; +import 'package:tracking_app/features/auth/domain/entities/country_entity.dart'; +import 'package:tracking_app/features/auth/data/models/request/apply_request_model.dart'; + +class ApplyScreen extends StatefulWidget { + const ApplyScreen({super.key}); + + @override + State createState() => _ApplyScreenState(); +} + +class _ApplyScreenState extends State { + final _formKey = GlobalKey(); + final ImagePicker _picker = ImagePicker(); + + // Controllers + final _firstNameController = TextEditingController(); + final _secondNameController = TextEditingController(); + final _vehicleNumberController = TextEditingController(); + final _emailController = TextEditingController(); + final _phoneController = TextEditingController(); + final _idNumberController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + String? _selectedCountry; + String? _selectedVehicleType; + String _selectedGender = 'female'; + + // ✅ Store picked files (NOT paths) + File? _vehicleLicenseFile; + File? _nidImgFile; + + @override + void dispose() { + _firstNameController.dispose(); + _secondNameController.dispose(); + _vehicleNumberController.dispose(); + _emailController.dispose(); + _phoneController.dispose(); + _idNumberController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _pickImage(Function(File) onPicked) async { + final XFile? image = await _picker.pickImage(source: ImageSource.gallery); + if (image != null) { + final file = File(image.path); + + final int sizeInBytes = await file.length(); + final double sizeInMb = sizeInBytes / (1024 * 1024); + + // ✅ allow up to 3MB (change if you want) + if (sizeInMb <= 3) { + onPicked(file); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("File size must be less than 3MB"), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: () => Navigator.pop(context), + ), + title: Text( + LocaleKeys.apply.tr(), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + centerTitle: false, + ), + body: BlocProvider( + create: (context) => getIt() + ..onIntent(GetCountriesIntent()) + ..onIntent(GetVehiclesIntent()), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + LocaleKeys.welcomeApply.tr(), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + LocaleKeys.joinTeamMessage.tr(), + style: const TextStyle(fontSize: 16, color: Colors.grey), + ), + const SizedBox(height: 24), + + // Country Dropdown + BlocBuilder( + builder: (context, state) { + if (state.status == ApplyStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } else if (state.status == ApplyStatus.failure) { + return Text( + state.errorMessage ?? + LocaleKeys.failedToLoadCountries.tr(), + style: const TextStyle(color: Colors.red), + ); + } + return DropdownButtonFormField( + isExpanded: true, + decoration: InputDecoration( + labelText: LocaleKeys.country.tr(), + border: const OutlineInputBorder(), + ), + value: _selectedCountry, + items: state.countries.map((country) { + return DropdownMenuItem( + value: country.isoCode, + child: Text( + "${country.flag} ${country.name}", + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: (v) => setState(() => _selectedCountry = v), + validator: (v) => + v == null ? LocaleKeys.requiredField.tr() : null, + ); + }, + ), + const SizedBox(height: 16), + + // First Name + CustomTextFormField( + controller: _firstNameController, + label: LocaleKeys.firstLegalName.tr(), + hint: LocaleKeys.enterFirstLegalName.tr(), + validator: Validators.validateName, + ), + const SizedBox(height: 16), + + // Second Name + CustomTextFormField( + controller: _secondNameController, + label: LocaleKeys.secondLegalName.tr(), + hint: LocaleKeys.enterSecondLegalName.tr(), + validator: Validators.validateName, + ), + const SizedBox(height: 16), + + // Vehicle Type Dropdown + BlocBuilder( + builder: (context, state) { + if (state.vehiclesStatus == ApplyStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } else if (state.vehiclesStatus == ApplyStatus.failure) { + return Text( + state.vehiclesErrorMessage ?? + LocaleKeys.failedToLoadVehicles.tr(), + style: const TextStyle(color: Colors.red), + ); + } + return DropdownButtonFormField( + isExpanded: true, + decoration: InputDecoration( + labelText: LocaleKeys.vehicleType.tr(), + border: const OutlineInputBorder(), + ), + value: _selectedVehicleType, + items: state.vehicles + .where((element) => element.id != null) + .map( + (e) => DropdownMenuItem( + value: e.id, + child: Text( + e.type ?? "Unknown", + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: (v) => + setState(() => _selectedVehicleType = v), + validator: (v) => + v == null ? LocaleKeys.requiredField.tr() : null, + ); + }, + ), + const SizedBox(height: 16), + + // Vehicle Number + CustomTextFormField( + controller: _vehicleNumberController, + label: LocaleKeys.vehicleNumber.tr(), + hint: LocaleKeys.enterVehicleNumber.tr(), + validator: (v) => + v?.isEmpty ?? true ? LocaleKeys.requiredField.tr() : null, + ), + const SizedBox(height: 16), + + // Vehicle License Upload (File) + _buildUploadField( + LocaleKeys.vehicleLicense.tr(), + LocaleKeys.uploadLicensePhoto.tr(), + onSaved: (f) => _vehicleLicenseFile = f, + validator: (f) => + f == null ? LocaleKeys.licensePhotoRequired.tr() : null, + ), + const SizedBox(height: 16), + + // Email + CustomTextFormField( + controller: _emailController, + label: LocaleKeys.email.tr(), + hint: LocaleKeys.enterEmail.tr(), + validator: Validators.validateEmail, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + + // Phone + CustomTextFormField( + controller: _phoneController, + label: LocaleKeys.phone.tr(), + hint: LocaleKeys.enterPhoneNumber.tr(), + validator: Validators.validatePhone, + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 16), + + // ID Number + CustomTextFormField( + controller: _idNumberController, + label: LocaleKeys.idNumber.tr(), + hint: LocaleKeys.enterNationalId.tr(), + validator: (v) => + v?.isEmpty ?? true ? LocaleKeys.requiredField.tr() : null, + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + + // ID Image Upload (File) + _buildUploadField( + LocaleKeys.idImage.tr(), + LocaleKeys.uploadIdImage.tr(), + onSaved: (f) => _nidImgFile = f, + validator: (f) => + f == null ? LocaleKeys.idImageRequired.tr() : null, + ), + const SizedBox(height: 16), + + // Password + Row( + children: [ + Expanded( + child: CustomTextFormField( + controller: _passwordController, + label: LocaleKeys.password.tr(), + hint: LocaleKeys.enterPassword.tr(), + validator: Validators.validatePassword, + ), + ), + const SizedBox(width: 16), + Expanded( + child: CustomTextFormField( + controller: _confirmPasswordController, + label: LocaleKeys.confirmPassword.tr(), + hint: LocaleKeys.confirmNewPassword.tr(), + validator: (val) => Validators.validateRePassword( + val, + _passwordController.text, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Gender + Row( + children: [ + Text( + LocaleKeys.gender.tr(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 16), + Expanded( + child: RadioListTile( + title: Text(LocaleKeys.femaleGender.tr()), + value: 'female', + groupValue: _selectedGender, + contentPadding: EdgeInsets.zero, + onChanged: (v) => setState(() => _selectedGender = v!), + ), + ), + Expanded( + child: RadioListTile( + title: Text(LocaleKeys.maleGender.tr()), + value: 'male', + groupValue: _selectedGender, + contentPadding: EdgeInsets.zero, + onChanged: (v) => setState(() => _selectedGender = v!), + ), + ), + ], + ), + const SizedBox(height: 32), + + // Continue Button + BlocConsumer( + listener: (context, state) { + if (state.applyStatus == ApplyStatus.success) { + // Navigate to success screen + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => const ApplySuccessScreen(), + ), + ); + } else if (state.applyStatus == ApplyStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + state.applyErrorMessage ?? + LocaleKeys.submissionFailed.tr(), + ), + backgroundColor: Colors.red, + ), + ); + } + }, + builder: (context, state) { + return ElevatedButton( + onPressed: state.applyStatus == ApplyStatus.loading + ? null + : () { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + + final countryEntity = state.countries + .cast() + .firstWhere( + (element) => + element.isoCode == _selectedCountry, + orElse: () => state.countries.first, + ); + final phoneCode = countryEntity.phoneCode ?? ""; + final rawPhone = _phoneController.text.trim(); + + final normalizedPhone = rawPhone.startsWith("0") + ? rawPhone.substring(1) + : rawPhone; + final request = ApplyRequestModel( + country: _selectedCountry, + firstName: _firstNameController.text, + lastName: _secondNameController.text, + vehicleType: _selectedVehicleType, + vehicleNumber: _vehicleNumberController.text, + email: _emailController.text, + phone: "+$phoneCode$normalizedPhone", + NID: _idNumberController.text, + password: _passwordController.text, + rePassword: _confirmPasswordController.text, + gender: _selectedGender, + + vehicleLicense: _vehicleLicenseFile, + NIDimg: _nidImgFile, + ); + + context.read().onIntent( + SubmitApplyIntent(request), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD01C68), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ), + child: state.applyStatus == ApplyStatus.loading + ? const CircularProgressIndicator(color: Colors.white) + : Text( + LocaleKeys.continueTxt.tr(), + style: const TextStyle( + fontSize: 18, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ); + }, + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + Widget _buildUploadField( + String label, + String hint, { + required Function(File?) onSaved, + required String? Function(File?) validator, + }) { + return FormField( + validator: validator, + onSaved: onSaved, + builder: (FormFieldState state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + suffixIcon: const Icon(Icons.file_upload_outlined), + errorText: state.errorText, + ), + child: GestureDetector( + onTap: () { + _pickImage((file) { + state.didChange(file); + }); + }, + child: Text( + state.value != null + ? state.value!.path.split('/').last + : hint, + style: TextStyle( + color: state.value != null + ? Colors.black + : Colors.grey[600], + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/auth/presentation/forget_pass/manager/cubit/forget_pass_cubit.dart b/lib/features/auth/presentation/forget_pass/manager/cubit/forget_pass_cubit.dart new file mode 100644 index 0000000..83e1b00 --- /dev/null +++ b/lib/features/auth/presentation/forget_pass/manager/cubit/forget_pass_cubit.dart @@ -0,0 +1,63 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/domain/models/forgetpassword_entitiy.dart'; +import 'package:tracking_app/features/auth/domain/usecase/forgetpassword_usecase.dart'; + +part 'forget_pass_state.dart'; +part 'forget_pass_intents.dart'; + +@injectable +class ForgetPasswordCubit extends Cubit { + final AuthStorage _authStorage; + final ForgetPasswordUsecase _ForgetPasswordUsecase; + + ForgetPasswordCubit(this._ForgetPasswordUsecase, this._authStorage) + : super(ForgetPasswordState.initial()); + + final formKey = GlobalKey(); + final emailController = TextEditingController(); + + void doIntent(ForgetPasswordIntents intent) { + switch (intent) { + case FormChangedIntent(): + _validateForm(); + break; + case SubmitForgetPasswordIntent(): + _submitForgetPassword(); + break; + } + } + + void _validateForm() { + final isEmailFilled = emailController.text.trim().isNotEmpty; + emit(state.copyWith(isFormValid: isEmailFilled)); + } + + Future _submitForgetPassword() async { + final isValid = formKey.currentState?.validate() ?? false; + if (!isValid) return; + + emit(state.copyWith(resource: Resource.loading())); + + final result = await _ForgetPasswordUsecase(emailController.text.trim()); + + if (result is SuccessApiResult) { + emit(state.copyWith(resource: Resource.success(result.data))); + } else if (result is ErrorApiResult) { + emit(state.copyWith(resource: Resource.error(result.error))); + } else { + emit(state.copyWith(resource: Resource.error('Unexpected error'))); + } + } + + @override + Future close() { + emailController.dispose(); + return super.close(); + } +} diff --git a/lib/features/auth/presentation/forget_pass/manager/cubit/forget_pass_intents.dart b/lib/features/auth/presentation/forget_pass/manager/cubit/forget_pass_intents.dart new file mode 100644 index 0000000..311360f --- /dev/null +++ b/lib/features/auth/presentation/forget_pass/manager/cubit/forget_pass_intents.dart @@ -0,0 +1,13 @@ +part of 'forget_pass_cubit.dart'; + +sealed class ForgetPasswordIntents { + const ForgetPasswordIntents(); +} + +class FormChangedIntent extends ForgetPasswordIntents { + const FormChangedIntent(); +} + +class SubmitForgetPasswordIntent extends ForgetPasswordIntents { + const SubmitForgetPasswordIntent(); +} diff --git a/lib/features/auth/presentation/forget_pass/manager/cubit/forget_pass_state.dart b/lib/features/auth/presentation/forget_pass/manager/cubit/forget_pass_state.dart new file mode 100644 index 0000000..55b0958 --- /dev/null +++ b/lib/features/auth/presentation/forget_pass/manager/cubit/forget_pass_state.dart @@ -0,0 +1,27 @@ +part of 'forget_pass_cubit.dart'; + +class ForgetPasswordState extends Equatable { + final Resource resource; + final bool isFormValid; + + const ForgetPasswordState({ + required this.resource, + required this.isFormValid, + }); + + factory ForgetPasswordState.initial() => + ForgetPasswordState(resource: Resource.initial(), isFormValid: false); + + ForgetPasswordState copyWith({ + Resource? resource, + bool? isFormValid, + }) { + return ForgetPasswordState( + resource: resource ?? this.resource, + isFormValid: isFormValid ?? this.isFormValid, + ); + } + + @override + List get props => [resource, isFormValid]; +} diff --git a/lib/features/auth/presentation/forget_pass/pages/forget_pass_page.dart b/lib/features/auth/presentation/forget_pass/pages/forget_pass_page.dart new file mode 100644 index 0000000..d31c798 --- /dev/null +++ b/lib/features/auth/presentation/forget_pass/pages/forget_pass_page.dart @@ -0,0 +1,24 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:tracking_app/features/auth/presentation/forget_pass/widgets/forget_pass_form.dart'; +import '../../../../../../../generated/locale_keys.g.dart'; + +class ForgetPasswordPage extends StatelessWidget { + const ForgetPasswordPage({super.key}); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: Text(LocaleKeys.password.tr()), + leading: IconButton( + icon: Icon(Icons.arrow_back_ios_new), + onPressed: () => context.go(RouteNames.onboarding), + ), + ), + body: ForgetPasswordForm(), + ); + } +} diff --git a/lib/features/auth/presentation/forget_pass/widgets/forget_pass_form.dart b/lib/features/auth/presentation/forget_pass/widgets/forget_pass_form.dart new file mode 100644 index 0000000..71e0016 --- /dev/null +++ b/lib/features/auth/presentation/forget_pass/widgets/forget_pass_form.dart @@ -0,0 +1,88 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/features/auth/presentation/forget_pass/manager/cubit/forget_pass_cubit.dart'; +import '../../../../../../../generated/locale_keys.g.dart'; +import '../../../../../../app/config/base_state/base_state.dart'; +import '../../../../../../app/core/router/route_names.dart'; +import '../../../../../../app/core/utils/validators_helper.dart'; +import '../../../../../../app/core/widgets/custom_button.dart'; +import '../../../../../../app/core/widgets/custom_text_form_field.dart'; +import '../../../../../../app/core/widgets/show_app_dialog.dart'; +import '../../../../../../app/core/widgets/show_snak_bar.dart'; + +class ForgetPasswordForm extends StatelessWidget { + const ForgetPasswordForm({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => + previous.resource.status != current.resource.status, + listener: (context, state) { + final email = context + .read() + .emailController + .text + .trim(); + if (state.resource.status == Status.success) { + showAppSnackbar( + context, + LocaleKeys.check_email_for_verification_code.tr(), + ); + context.push(RouteNames.verifyResetCode, extra: email); + } + + if (state.resource.status == Status.error) { + showAppDialog( + context, + message: state.resource.error ?? LocaleKeys.an_error_occurred.tr(), + isError: true, + ); + } + }, + builder: (context, state) { + final cubit = context.read(); + + return Form( + key: cubit.formKey, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + const SizedBox(height: 30), + Text( + LocaleKeys.forgotPassword.tr(), + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + Text( + LocaleKeys.associatedEmail.tr(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 30), + CustomTextFormField( + controller: cubit.emailController, + label: LocaleKeys.email.tr(), + hint: LocaleKeys.enterEmail.tr(), + validator: Validators.validateEmail, + onChanged: (_) => cubit.doIntent(const FormChangedIntent()), + ), + const SizedBox(height: 40), + CustomButton( + isEnabled: state.isFormValid, + isLoading: state.resource.status == Status.loading, + text: LocaleKeys.continueTxt.tr(), + onPressed: () => + cubit.doIntent(const SubmitForgetPasswordIntent()), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/auth/presentation/login/manager/login_cubit.dart b/lib/features/auth/presentation/login/manager/login_cubit.dart new file mode 100644 index 0000000..9c9aab1 --- /dev/null +++ b/lib/features/auth/presentation/login/manager/login_cubit.dart @@ -0,0 +1,55 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:tracking_app/features/auth/domain/usecase/login_usecase.dart'; +import 'package:tracking_app/features/auth/presentation/login/manager/login_intent.dart'; +import 'package:tracking_app/features/auth/presentation/login/manager/login_states.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; + +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; + +@injectable +class LoginCubit extends Cubit { + final LoginUseCase _loginUseCase; + final AuthStorage _authStorage; + + LoginCubit(this._loginUseCase, this._authStorage) : super(LoginStates()); + + Future doAction(LoginIntent intent) async { + switch (intent) { + case PerformLogin( + email: final email, + password: final password, + rememberMe: final rememberMe, + ): + await _performLogin(email, password, rememberMe); + case ToggleRememberMe(value: final value): + _toggleRememberMe(value); + } + } + + Future _performLogin( + String email, + String password, + bool rememberMe, + ) async { + emit(state.copyWith(loginResource: Resource.loading())); + final result = await _loginUseCase(email, password); + + switch (result) { + case SuccessApiResult(data: final data): + if (data.token != null) { + await _authStorage.saveToken(data.token!); + } + await _authStorage.setRememberMe(rememberMe); + emit(state.copyWith(loginResource: Resource.success(data))); + case ErrorApiResult(error: final error): + emit(state.copyWith(loginResource: Resource.error(error))); + } + } + + void _toggleRememberMe(bool value) { + emit(state.copyWith(rememberMe: value)); + } +} diff --git a/lib/features/auth/presentation/login/manager/login_intent.dart b/lib/features/auth/presentation/login/manager/login_intent.dart new file mode 100644 index 0000000..ba01edd --- /dev/null +++ b/lib/features/auth/presentation/login/manager/login_intent.dart @@ -0,0 +1,18 @@ +sealed class LoginIntent {} + +class PerformLogin extends LoginIntent { + final String email; + final String password; + final bool rememberMe; + + PerformLogin({ + required this.email, + required this.password, + required this.rememberMe, + }); +} + +class ToggleRememberMe extends LoginIntent { + final bool value; + ToggleRememberMe(this.value); +} diff --git a/lib/features/auth/presentation/login/manager/login_states.dart b/lib/features/auth/presentation/login/manager/login_states.dart new file mode 100644 index 0000000..46f53a5 --- /dev/null +++ b/lib/features/auth/presentation/login/manager/login_states.dart @@ -0,0 +1,21 @@ +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; + +class LoginStates { + final Resource loginResource; + final bool rememberMe; + + LoginStates({Resource? loginResource, this.rememberMe = false}) + : loginResource = loginResource ?? Resource.initial(); + + LoginStates copyWith({ + Resource? loginResource, + bool? rememberMe, + String? validationError, + }) { + return LoginStates( + loginResource: loginResource ?? this.loginResource, + rememberMe: rememberMe ?? this.rememberMe, + ); + } +} diff --git a/lib/features/auth/presentation/login/pages/loginScreen.dart b/lib/features/auth/presentation/login/pages/loginScreen.dart new file mode 100644 index 0000000..2d0a9f1 --- /dev/null +++ b/lib/features/auth/presentation/login/pages/loginScreen.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/features/auth/presentation/login/manager/login_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/login/widgets/loginScreenBody.dart'; + +class LoginScreen extends StatelessWidget { + const LoginScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => getIt(), + child: const Loginscreenbody(), + ); + } +} diff --git a/lib/features/auth/presentation/login/widgets/loginScreenBody.dart b/lib/features/auth/presentation/login/widgets/loginScreenBody.dart new file mode 100644 index 0000000..2e12323 --- /dev/null +++ b/lib/features/auth/presentation/login/widgets/loginScreenBody.dart @@ -0,0 +1,179 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/app/core/ui_helper/style/font_style.dart'; +import 'package:tracking_app/app/core/widgets/custom_button.dart'; +import 'package:tracking_app/app/core/widgets/custom_text_form_field.dart'; +import 'package:tracking_app/app/core/widgets/password_text_form_field.dart'; +import 'package:tracking_app/app/core/widgets/show_snak_bar.dart'; +import 'package:tracking_app/features/auth/presentation/login/manager/login_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/login/manager/login_intent.dart'; +import 'package:tracking_app/features/auth/presentation/login/manager/login_states.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +class Loginscreenbody extends StatefulWidget { + const Loginscreenbody({super.key}); + + @override + State createState() => _LoginscreenbodyState(); +} + +class _LoginscreenbodyState extends State { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final GlobalKey _formKey = GlobalKey(); + bool _isPasswordVisible = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state.loginResource.status == Status.error) { + showAppSnackbar( + context, + (state.loginResource.error ?? 'unknownError').tr(), + ); + } else if (state.loginResource.status == Status.success) { + showAppSnackbar(context, 'success'.tr()); + context.go(RouteNames.appStart); + } + }, + builder: (context, state) { + return Scaffold( + backgroundColor: AppColors.white, + appBar: AppBar( + backgroundColor: AppColors.white, + elevation: 0, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: AppColors.blackColor, + ), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + LocaleKeys.login.tr(), + style: AppStyles.black24SemiBold, + ), + centerTitle: false, + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 20.0, + ), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextFormField( + controller: _emailController, + label: LocaleKeys.email.tr(), + hint: LocaleKeys.enterEmail.tr(), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return LocaleKeys.emailRequired.tr(); + } + return null; + }, + ), + const SizedBox(height: 20), + PasswordTextFormField( + controller: _passwordController, + label: LocaleKeys.password.tr(), + hint: LocaleKeys.enterPassword.tr(), + isVisible: _isPasswordVisible, + onToggleVisibility: () { + setState(() { + _isPasswordVisible = !_isPasswordVisible; + }); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return LocaleKeys.passwordRequired.tr(); + } + if (value.length < 6) { + return LocaleKeys.least6Characters.tr(); + } + return null; + }, + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Checkbox( + value: state.rememberMe, + activeColor: AppColors.pink, + onChanged: (value) { + context.read().doAction( + ToggleRememberMe(value ?? false), + ); + }, + ), + Text( + LocaleKeys.rememberMe.tr(), + style: AppStyles.font14Black, + ), + ], + ), + TextButton( + onPressed: () { + context.go(RouteNames.forgetPassword); + }, + child: Text( + LocaleKeys.forgotPasswordTitle.tr(), + style: AppStyles.font14Black.copyWith( + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + const SizedBox(height: 30), + SizedBox( + width: double.infinity, + child: CustomButton( + isEnabled: true, + isLoading: state.loginResource.status == Status.loading, + text: LocaleKeys.login.tr(), + color: AppColors.pink, + onPressed: () { + if (_formKey.currentState!.validate()) { + context.read().doAction( + PerformLogin( + email: _emailController.text, + password: _passwordController.text, + rememberMe: state.rememberMe, + ), + ); + } + }, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/auth/presentation/logout/manager/logout_cubit.dart b/lib/features/auth/presentation/logout/manager/logout_cubit.dart new file mode 100644 index 0000000..8599e76 --- /dev/null +++ b/lib/features/auth/presentation/logout/manager/logout_cubit.dart @@ -0,0 +1,45 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/models/response/logout_response_dto/logout_response_dto.dart'; + +import 'package:tracking_app/features/auth/domain/usecase/logout_usecase.dart'; +import 'package:tracking_app/features/auth/presentation/logout/manager/logout_state.dart'; +import 'logout_intent.dart'; + +@injectable +class LogoutCubit extends Cubit { + final LogoutUseCase _logoutUseCase; + final AuthStorage _authStorage; + + LogoutCubit(this._logoutUseCase, this._authStorage) : super(LogoutStates()); + + void doIntent(LogoutIntent intent) { + switch (intent.runtimeType) { + case PerformLogout: + _performLogout(); + break; + } + } + + Future _performLogout() async { + emit(state.copyWith(logoutResource: Resource.loading())); + final token = await _authStorage.getToken(); + if (token == null || token.isEmpty) { + emit(state.copyWith(logoutResource: Resource.error("Token not found"))); + return; + } + final result = await _logoutUseCase.call('Bearer $token'); + switch (result) { + case SuccessApiResult(): + await _authStorage.clearAll(); + emit(state.copyWith(logoutResource: Resource.success(result.data))); + break; + case ErrorApiResult(): + emit(state.copyWith(logoutResource: Resource.error(result.error))); + break; + } + } +} diff --git a/lib/features/auth/presentation/logout/manager/logout_intent.dart b/lib/features/auth/presentation/logout/manager/logout_intent.dart new file mode 100644 index 0000000..fea8fbf --- /dev/null +++ b/lib/features/auth/presentation/logout/manager/logout_intent.dart @@ -0,0 +1,3 @@ +sealed class LogoutIntent {} + +class PerformLogout extends LogoutIntent {} diff --git a/lib/features/auth/presentation/logout/manager/logout_state.dart b/lib/features/auth/presentation/logout/manager/logout_state.dart new file mode 100644 index 0000000..e88cfd5 --- /dev/null +++ b/lib/features/auth/presentation/logout/manager/logout_state.dart @@ -0,0 +1,13 @@ +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/auth/data/models/response/logout_response_dto/logout_response_dto.dart'; + +class LogoutStates { + final Resource logoutResource; + + LogoutStates({Resource? logoutResource}) + : logoutResource = logoutResource ?? Resource.initial(); + + LogoutStates copyWith({Resource? logoutResource}) { + return LogoutStates(logoutResource: logoutResource ?? this.logoutResource); + } +} diff --git a/lib/features/auth/presentation/reset_password/manager/change_password_cubit.dart b/lib/features/auth/presentation/reset_password/manager/change_password_cubit.dart new file mode 100644 index 0000000..7a42e71 --- /dev/null +++ b/lib/features/auth/presentation/reset_password/manager/change_password_cubit.dart @@ -0,0 +1,86 @@ +import 'package:bloc/bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_intent.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_states.dart'; +import '../../../../../app/config/base_state/base_state.dart'; +import '../../../../../app/core/network/api_result.dart'; +import '../../../domain/usecase/change_password_usecase.dart'; + +@injectable +class ChangePasswordCubit extends Cubit { + final ChangePasswordUsecase _changePasswordUseCase; + final AuthStorage _authStorage; + + ChangePasswordCubit(this._changePasswordUseCase, this._authStorage) + : super(ChangePasswordStates()); + + final formKey = GlobalKey(); + String currentPass = ''; + String newPass = ''; + String confirmPass = ''; + + void doIntent(ChangePasswordIntent intent) { + switch (intent) { + case CurrentPasswordIntent(): + _currentPassword(intent.currentPass.toString()); + case NewPasswordIntent(): + _newPassword(intent.newPass.toString()); + case ConfirmPasswordIntent(): + _confirmPassword(intent.confirmPass.toString()); + case SubmitChangePasswordIntent(): + _submitChangePassword(); + case FormValidIntent(): + _formValid(); + } + } + + void _formValid() { + final isValid = formKey.currentState?.validate() ?? false; + emit(state.copyWith(isFormValid: isValid)); + } + + void _currentPassword(String value) { + currentPass = value; + emit(state.copyWith(currentPassword: true, data: null)); + } + + void _newPassword(String value) { + newPass = value; + emit(state.copyWith(newPassword: true, data: null)); + } + + void _confirmPassword(String value) { + confirmPass = value; + emit(state.copyWith(confirmPassword: true, data: null)); + } + + Future _submitChangePassword() async { + emit(state.copyWith(data: Resource.loading())); + final token = await _authStorage.getToken(); + + if (token == null || token.isEmpty) { + emit(state.copyWith(data: Resource.error("Token not found"))); + return; + } + + ApiResult response = await _changePasswordUseCase.call( + token: 'Bearer $token', + password: currentPass, + newPassword: newPass, + ); + + switch (response) { + case SuccessApiResult(): + if (response.data.token != null) { + await _authStorage.clearToken(); + } + emit(state.copyWith(data: Resource.success(response.data))); + + case ErrorApiResult(): + emit(state.copyWith(data: Resource.error(response.error))); + } + } +} diff --git a/lib/features/auth/presentation/reset_password/manager/change_password_intent.dart b/lib/features/auth/presentation/reset_password/manager/change_password_intent.dart new file mode 100644 index 0000000..a135b23 --- /dev/null +++ b/lib/features/auth/presentation/reset_password/manager/change_password_intent.dart @@ -0,0 +1,20 @@ +sealed class ChangePasswordIntent {} + +class CurrentPasswordIntent extends ChangePasswordIntent { + final String? currentPass; + CurrentPasswordIntent({this.currentPass}); +} + +class NewPasswordIntent extends ChangePasswordIntent { + final String? newPass; + NewPasswordIntent({this.newPass}); +} + +class ConfirmPasswordIntent extends ChangePasswordIntent { + final String? confirmPass; + ConfirmPasswordIntent({this.confirmPass}); +} + +class SubmitChangePasswordIntent extends ChangePasswordIntent {} + +class FormValidIntent extends ChangePasswordIntent {} diff --git a/lib/features/auth/presentation/reset_password/manager/change_password_states.dart b/lib/features/auth/presentation/reset_password/manager/change_password_states.dart new file mode 100644 index 0000000..f8bdfd2 --- /dev/null +++ b/lib/features/auth/presentation/reset_password/manager/change_password_states.dart @@ -0,0 +1,34 @@ +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; + +class ChangePasswordStates { + final Resource? data; + final bool? isFormValid; + final bool? currentPassword; + final bool? newPassword; + final bool? confirmPassword; + + const ChangePasswordStates({ + this.data, + this.isFormValid, + this.currentPassword, + this.newPassword, + this.confirmPassword, + }); + + ChangePasswordStates copyWith({ + Resource? data, + bool? isFormValid, + bool? currentPassword, + bool? newPassword, + bool? confirmPassword, + }) { + return ChangePasswordStates( + data: data ?? this.data, + isFormValid: isFormValid ?? this.isFormValid, + currentPassword: currentPassword ?? this.currentPassword, + newPassword: newPassword ?? this.newPassword, + confirmPassword: confirmPassword ?? this.confirmPassword, + ); + } +} diff --git a/lib/features/auth/presentation/reset_password/manager/reset_password_cubit.dart b/lib/features/auth/presentation/reset_password/manager/reset_password_cubit.dart new file mode 100644 index 0000000..a800511 --- /dev/null +++ b/lib/features/auth/presentation/reset_password/manager/reset_password_cubit.dart @@ -0,0 +1,81 @@ +import 'package:bloc/bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/features/auth/data/models/request/resetpassword_request.dart'; +import 'package:tracking_app/features/auth/domain/models/resetpassword_entity.dart'; +import 'package:tracking_app/features/auth/domain/usecase/resertpassword_usecase.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/reset_password_intents.dart'; +import '../../../../../app/config/base_state/base_state.dart'; +import '../../../../../app/core/network/api_result.dart'; +import '../../../../../app/core/utils/validators_helper.dart'; + +part 'reset_password_state.dart'; + +@injectable +class ResetPasswordCubit extends Cubit { + final ResetPasswordUsecase _resetPasswordUseCase; + final String email; + + ResetPasswordCubit(@factoryParam this.email, this._resetPasswordUseCase) + : super(ResetPasswordState.initial(email: email)); + + final formKey = GlobalKey(); + final emailController = TextEditingController(); + final newPasswordController = TextEditingController(); + + void doIntent(ChangePasswordIntent intent) { + switch (intent) { + case FormChangedIntent(): + _validateForm(); + break; + case TogglePasswordVisibilityIntent(): + _togglePasswordVisibility(); + break; + case SubmitChangePasswordIntent(): + _submitResetPassword(); + break; + } + } + + void _validateForm() { + final isValid = + newPasswordController.text.trim().isNotEmpty && + Validators.validatePassword(newPasswordController.text.trim()) == null; + + emit(state.copyWith(isFormValid: isValid)); + } + + void _togglePasswordVisibility() { + emit( + state.copyWith(togglePasswordVisibility: !state.togglePasswordVisibility), + ); + } + + Future _submitResetPassword() async { + if (!state.isFormValid) return; + + emit(state.copyWith(resource: Resource.loading())); + + final dto = ResetPasswordRequest( + email: email, // Use the stored email + newPassword: newPasswordController.text.trim(), + ); + + final result = await _resetPasswordUseCase(dto); + + if (result is SuccessApiResult) { + emit(state.copyWith(resource: Resource.success(result.data))); + } else if (result is ErrorApiResult) { + emit(state.copyWith(resource: Resource.error(result.error))); + } else { + emit(state.copyWith(resource: Resource.error('Unexpected error'))); + } + } + + @override + Future close() { + emailController.dispose(); + newPasswordController.dispose(); + return super.close(); + } +} diff --git a/lib/features/auth/presentation/reset_password/manager/reset_password_intents.dart b/lib/features/auth/presentation/reset_password/manager/reset_password_intents.dart new file mode 100644 index 0000000..d97932a --- /dev/null +++ b/lib/features/auth/presentation/reset_password/manager/reset_password_intents.dart @@ -0,0 +1,19 @@ +sealed class ChangePasswordIntent { + const ChangePasswordIntent(); + + static const formChanged = FormChangedIntent(); + static const togglePasswordVisibility = TogglePasswordVisibilityIntent(); + static const submit = SubmitChangePasswordIntent(); +} + +class FormChangedIntent extends ChangePasswordIntent { + const FormChangedIntent(); +} + +class TogglePasswordVisibilityIntent extends ChangePasswordIntent { + const TogglePasswordVisibilityIntent(); +} + +class SubmitChangePasswordIntent extends ChangePasswordIntent { + const SubmitChangePasswordIntent(); +} diff --git a/lib/features/auth/presentation/reset_password/manager/reset_password_state.dart b/lib/features/auth/presentation/reset_password/manager/reset_password_state.dart new file mode 100644 index 0000000..7fad286 --- /dev/null +++ b/lib/features/auth/presentation/reset_password/manager/reset_password_state.dart @@ -0,0 +1,37 @@ +part of 'reset_password_cubit.dart'; + +class ResetPasswordState { + final Resource resource; + final bool isFormValid; + final bool togglePasswordVisibility; + final String email; + + const ResetPasswordState({ + required this.resource, + required this.isFormValid, + required this.togglePasswordVisibility, + required this.email, + }); + + factory ResetPasswordState.initial({String email = ''}) => ResetPasswordState( + resource: Resource.initial(), + isFormValid: false, + togglePasswordVisibility: false, + email: email, + ); + + ResetPasswordState copyWith({ + Resource? resource, + bool? isFormValid, + bool? togglePasswordVisibility, + String? email, + }) { + return ResetPasswordState( + resource: resource ?? this.resource, + isFormValid: isFormValid ?? this.isFormValid, + togglePasswordVisibility: + togglePasswordVisibility ?? this.togglePasswordVisibility, + email: email ?? this.email, + ); + } +} diff --git a/lib/features/auth/presentation/reset_password/pages/change_password_page.dart b/lib/features/auth/presentation/reset_password/pages/change_password_page.dart new file mode 100644 index 0000000..050774a --- /dev/null +++ b/lib/features/auth/presentation/reset_password/pages/change_password_page.dart @@ -0,0 +1,65 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_states.dart'; +import '../../../../../../generated/locale_keys.g.dart'; +import '../../../../../app/config/base_state/base_state.dart'; +import '../../../../../app/core/router/route_names.dart'; +import '../../../../../app/core/widgets/show_app_dialog.dart'; +import '../../../../../app/core/widgets/show_snak_bar.dart'; +import '../manager/change_password_cubit.dart'; +import '../widgets/change_password_form.dart'; + +class ChangePasswordPage extends StatelessWidget { + const ChangePasswordPage({super.key}); + + @override + Widget build(BuildContext context) { + var bloc = getIt(); + return Scaffold( + appBar: AppBar( + title: Text( + LocaleKeys.resetPassword.tr(), + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: AppColors.blackColor, + fontSize: 20, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: () => context.pop(), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: BlocProvider( + create: (context) => bloc, + child: BlocConsumer( + listenWhen: (previous, current) => + previous.data?.status != current.data?.status, + listener: (context, state) { + if (state.data?.status == Status.success) { + showAppSnackbar(context, LocaleKeys.passwordUpdated.tr()); + context.push(RouteNames.login); + } + if (state.data?.status == Status.error) { + showAppDialog( + context, + message: + state.data?.error ?? LocaleKeys.an_error_occurred.tr(), + isError: true, + ); + } + }, + builder: (context, state) { + return ChangePasswordForm(); + }, + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/reset_password/pages/reset_password.dart b/lib/features/auth/presentation/reset_password/pages/reset_password.dart new file mode 100644 index 0000000..6a5970f --- /dev/null +++ b/lib/features/auth/presentation/reset_password/pages/reset_password.dart @@ -0,0 +1,51 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../../../generated/locale_keys.g.dart'; +import '../../../../../app/config/base_state/base_state.dart'; +import '../../../../../app/core/router/route_names.dart'; +import '../../../../../app/core/widgets/show_app_dialog.dart'; +import '../../../../../app/core/widgets/show_snak_bar.dart'; +import '../manager/reset_password_cubit.dart'; +import '../widgets/reset_password_form.dart'; + +class ResetPasswordPage extends StatelessWidget { + const ResetPasswordPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(LocaleKeys.password.tr()), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: () => context.pop(), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: BlocConsumer( + listenWhen: (p, c) => p.resource.status != c.resource.status, + listener: (context, state) { + if (state.resource.status == Status.success) { + showAppSnackbar(context, LocaleKeys.passwordUpdated.tr()); + context.push(RouteNames.login); + } + if (state.resource.status == Status.error) { + showAppDialog( + context, + message: + state.resource.error ?? LocaleKeys.an_error_occurred.tr(), + isError: true, + ); + } + }, + builder: (context, state) { + return const ResetPasswordForm(); + }, + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/reset_password/widgets/change_password_form.dart b/lib/features/auth/presentation/reset_password/widgets/change_password_form.dart new file mode 100644 index 0000000..201761e --- /dev/null +++ b/lib/features/auth/presentation/reset_password/widgets/change_password_form.dart @@ -0,0 +1,124 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/config/validation/app_validation.dart'; +import 'package:tracking_app/app/core/widgets/custom_button.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_intent.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_states.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/widgets/text_form_field_widget.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; +import '../manager/change_password_cubit.dart'; + +class ChangePasswordForm extends StatefulWidget { + const ChangePasswordForm({super.key}); + + @override + State createState() => _ChangePasswordFormState(); +} + +class _ChangePasswordFormState extends State { + bool _currentPassHidden = true; + bool _newPassHidden = true; + bool _confirmPassHidden = true; + + @override + Widget build(BuildContext context) { + final bloc = BlocProvider.of(context); + + return SingleChildScrollView( + child: Form( + key: bloc.formKey, + child: Column( + children: [ + const SizedBox(height: 20), + TextFormFieldWidget( + suffixIcon: IconButton( + onPressed: () { + setState(() { + _currentPassHidden = !_currentPassHidden; + }); + }, + icon: Icon( + _currentPassHidden ? Icons.visibility_off : Icons.visibility, + ), + ), + keyboardType: TextInputType.text, + obscureText: _currentPassHidden, + label: LocaleKeys.currentPassword.tr(), + hint: LocaleKeys.currentPassword.tr(), + validator: (val) => Validators.passwordValidator(val), + onChanged: (value) { + bloc.doIntent( + CurrentPasswordIntent(currentPass: value.toString()), + ); + bloc.doIntent(FormValidIntent()); + }, + ), + const SizedBox(height: 20), + TextFormFieldWidget( + suffixIcon: IconButton( + onPressed: () { + setState(() { + _newPassHidden = !_newPassHidden; + }); + }, + icon: Icon( + _newPassHidden ? Icons.visibility_off : Icons.visibility, + ), + ), + keyboardType: TextInputType.text, + obscureText: _newPassHidden, + label: LocaleKeys.newPassword.tr(), + hint: LocaleKeys.newPassword.tr(), + validator: (val) => + Validators.newPasswordValidator(val, bloc.currentPass), + onChanged: (value) { + bloc.doIntent(NewPasswordIntent(newPass: value.toString())); + bloc.doIntent(FormValidIntent()); + }, + ), + const SizedBox(height: 20), + TextFormFieldWidget( + suffixIcon: IconButton( + onPressed: () { + setState(() { + _confirmPassHidden = !_confirmPassHidden; + }); + }, + icon: Icon( + _confirmPassHidden ? Icons.visibility_off : Icons.visibility, + ), + ), + obscureText: _confirmPassHidden, + label: LocaleKeys.confirmPassword.tr(), + hint: LocaleKeys.confirmPassword.tr(), + validator: (val) => + Validators.confirmPasswordValidator(val, bloc.newPass), + onChanged: (value) { + bloc.doIntent( + ConfirmPasswordIntent(confirmPass: value.toString()), + ); + bloc.doIntent(FormValidIntent()); + }, + ), + + const SizedBox(height: 32), + BlocBuilder( + builder: (context, state) { + return CustomButton( + text: LocaleKeys.update.tr(), + isEnabled: state.isFormValid ?? false, + isLoading: state.data?.status == Status.loading, + onPressed: () { + bloc.doIntent(SubmitChangePasswordIntent()); + }, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/reset_password/widgets/reset_password_form.dart b/lib/features/auth/presentation/reset_password/widgets/reset_password_form.dart new file mode 100644 index 0000000..61e7fd6 --- /dev/null +++ b/lib/features/auth/presentation/reset_password/widgets/reset_password_form.dart @@ -0,0 +1,68 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/widgets/show_user_email.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; +import '../../../../../app/config/base_state/base_state.dart'; +import '../../../../../app/core/utils/validators_helper.dart'; +import '../../../../../app/core/widgets/custom_button.dart'; +import '../../../../../app/core/widgets/password_text_form_field.dart'; +import '../manager/reset_password_cubit.dart'; +import '../manager/reset_password_intents.dart'; + +class ResetPasswordForm extends StatelessWidget { + const ResetPasswordForm({super.key}); + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + final email = cubit.email; + + return Form( + key: cubit.formKey, + onChanged: () => cubit.doIntent(ChangePasswordIntent.formChanged), + child: Column( + children: [ + const SizedBox(height: 20), + + ShowUserEmail(context, email), + + const SizedBox(height: 24), + + BlocBuilder( + buildWhen: (p, c) => + p.togglePasswordVisibility != c.togglePasswordVisibility, + builder: (context, state) { + return PasswordTextFormField( + controller: cubit.newPasswordController, + label: LocaleKeys.newPassword.tr(), + isVisible: state.togglePasswordVisibility, + onToggleVisibility: () => cubit.doIntent( + ChangePasswordIntent.togglePasswordVisibility, + ), + validator: Validators.validatePassword, + hint: LocaleKeys.enterYourPassword, + ); + }, + ), + + const SizedBox(height: 32), + + BlocBuilder( + buildWhen: (p, c) => + p.isFormValid != c.isFormValid || + p.resource.status != c.resource.status, + builder: (context, state) { + return CustomButton( + text: LocaleKeys.confirm.tr(), + isEnabled: state.isFormValid, + isLoading: state.resource.status == Status.loading, + onPressed: () => cubit.doIntent(ChangePasswordIntent.submit), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/auth/presentation/reset_password/widgets/show_user_email.dart b/lib/features/auth/presentation/reset_password/widgets/show_user_email.dart new file mode 100644 index 0000000..832928b --- /dev/null +++ b/lib/features/auth/presentation/reset_password/widgets/show_user_email.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +Widget ShowUserEmail(BuildContext context, String email) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.email_outlined), + const SizedBox(width: 12), + Expanded(child: Text(email)), + ], + ), + ); +} diff --git a/lib/features/auth/presentation/reset_password/widgets/text_form_field_widget.dart b/lib/features/auth/presentation/reset_password/widgets/text_form_field_widget.dart new file mode 100644 index 0000000..d502b05 --- /dev/null +++ b/lib/features/auth/presentation/reset_password/widgets/text_form_field_widget.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class TextFormFieldWidget extends StatelessWidget { + const TextFormFieldWidget({ + super.key, + this.obscureText = false, + required this.label, + this.focusNode, + this.keyboardType, + required this.hint, + this.validator, + this.onChanged, + this.controller, + this.enabled = true, + this.suffixIcon, + }); + final bool obscureText; + final String label; + final String hint; + final FocusNode? focusNode; + final TextInputType? keyboardType; + final String? Function(String?)? validator; + final void Function(String?)? onChanged; + final TextEditingController? controller; + final bool enabled; + final Widget? suffixIcon; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return SizedBox( + width: double.infinity, + child: TextFormField( + controller: controller, + enabled: enabled, + onChanged: onChanged, + obscureText: obscureText, + autovalidateMode: AutovalidateMode.onUserInteraction, + focusNode: focusNode, + keyboardType: keyboardType, + cursorColor: AppColors.pink, + validator: validator, + onTapOutside: (event) { + FocusScope.of(context).unfocus(); + }, + decoration: InputDecoration( + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.grey2), + ), + suffixIcon: suffixIcon, + hint: Text( + hint, + style: textTheme.labelSmall!.copyWith(color: AppColors.grey2), + ), + labelText: label, + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/verify_reset/manger/cubit/verify_reset_cubit.dart b/lib/features/auth/presentation/verify_reset/manger/cubit/verify_reset_cubit.dart new file mode 100644 index 0000000..6f227b9 --- /dev/null +++ b/lib/features/auth/presentation/verify_reset/manger/cubit/verify_reset_cubit.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'package:bloc/bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/features/auth/domain/models/forgetpassword_entitiy.dart'; +import 'package:tracking_app/features/auth/domain/models/verifyreset_entity.dart'; +import 'package:tracking_app/features/auth/domain/usecase/forgetpassword_usecase.dart'; +import 'package:tracking_app/features/auth/domain/usecase/verifyreaset_usecase.dart'; +import '../../../../../../app/config/base_state/base_state.dart'; +import '../../../../../../app/core/network/api_result.dart'; + +part 'verify_reset_state.dart'; +part 'verify_reset_intent.dart'; + +@injectable +class VerifyResetCodeCubit extends Cubit { + final VerifyResetCodeUsecase _verifyUseCase; + final ForgetPasswordUsecase _resendUseCase; + final String email; + Timer? _cooldownTimer; + + VerifyResetCodeCubit( + this._verifyUseCase, + this._resendUseCase, + @factoryParam this.email, + ) : super(VerifyResetCodeState.initial()) { + _startCooldown(30); + } + + void doIntent(VerifyResetCodeIntents intent) { + switch (intent.runtimeType) { + case FormChangedIntent: + _validateForm((intent as FormChangedIntent).code); + break; + case SubmitVerifyCodeIntent: + _submitCode(); + break; + case ResendCodeIntent: + _resendCode(); + break; + } + } + + void _validateForm(String code) { + emit(state.copyWith(code: code, isFormValid: code.length == 6)); + } + + Future _submitCode() async { + if (!state.isFormValid) return; + + emit(state.copyWith(resource: Resource.loading())); + + final result = await _verifyUseCase(state.code); + + if (result is SuccessApiResult) { + emit(state.copyWith(resource: Resource.success(result.data))); + } else if (result is ErrorApiResult) { + emit(state.copyWith(resource: Resource.error(result.error))); + } else { + emit(state.copyWith(resource: Resource.error("Unexpected error"))); + } + } + + Future _resendCode() async { + if (!state.canResend) return; + _startCooldown(30); + emit(state.copyWith(resource: Resource.loading(), canResend: false)); + + final result = await _resendUseCase(email); + + if (result is SuccessApiResult) { + emit(state.copyWith(resource: Resource.success(result.data))); + } else if (result is ErrorApiResult) { + emit(state.copyWith(resource: Resource.error(result.error))); + } else { + emit(state.copyWith(resource: Resource.error("Unexpected error"))); + } + } + + void _startCooldown(int seconds) { + _cooldownTimer?.cancel(); + emit(state.copyWith(resendCountdown: seconds, canResend: false)); + + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + final remaining = state.resendCountdown - 1; + if (remaining <= 0) { + timer.cancel(); + emit(state.copyWith(resendCountdown: 0, canResend: true)); + } else { + emit(state.copyWith(resendCountdown: remaining)); + } + }); + } + + @override + Future close() { + _cooldownTimer?.cancel(); + return super.close(); + } +} diff --git a/lib/features/auth/presentation/verify_reset/manger/cubit/verify_reset_intent.dart b/lib/features/auth/presentation/verify_reset/manger/cubit/verify_reset_intent.dart new file mode 100644 index 0000000..4a64133 --- /dev/null +++ b/lib/features/auth/presentation/verify_reset/manger/cubit/verify_reset_intent.dart @@ -0,0 +1,18 @@ +part of 'verify_reset_cubit.dart'; + +sealed class VerifyResetCodeIntents { + const VerifyResetCodeIntents(); +} + +class FormChangedIntent extends VerifyResetCodeIntents { + final String code; + const FormChangedIntent(this.code); +} + +class SubmitVerifyCodeIntent extends VerifyResetCodeIntents { + const SubmitVerifyCodeIntent(); +} + +class ResendCodeIntent extends VerifyResetCodeIntents { + const ResendCodeIntent(); +} diff --git a/lib/features/auth/presentation/verify_reset/manger/cubit/verify_reset_state.dart b/lib/features/auth/presentation/verify_reset/manger/cubit/verify_reset_state.dart new file mode 100644 index 0000000..ebf54da --- /dev/null +++ b/lib/features/auth/presentation/verify_reset/manger/cubit/verify_reset_state.dart @@ -0,0 +1,41 @@ +part of 'verify_reset_cubit.dart'; + +class VerifyResetCodeState { + final Resource resource; + final bool isFormValid; + final String code; + final int resendCountdown; + final bool canResend; + + const VerifyResetCodeState({ + required this.resource, + required this.isFormValid, + required this.code, + required this.resendCountdown, + required this.canResend, + }); + + factory VerifyResetCodeState.initial() => VerifyResetCodeState( + resource: Resource.initial(), + isFormValid: false, + code: '', + resendCountdown: 0, + canResend: true, + ); + + VerifyResetCodeState copyWith({ + Resource? resource, + bool? isFormValid, + String? code, + int? resendCountdown, + bool? canResend, + }) { + return VerifyResetCodeState( + resource: resource ?? this.resource, + isFormValid: isFormValid ?? this.isFormValid, + code: code ?? this.code, + resendCountdown: resendCountdown ?? this.resendCountdown, + canResend: canResend ?? this.canResend, + ); + } +} diff --git a/lib/features/auth/presentation/verify_reset/pages/verify_reset_page.dart b/lib/features/auth/presentation/verify_reset/pages/verify_reset_page.dart new file mode 100644 index 0000000..73f7155 --- /dev/null +++ b/lib/features/auth/presentation/verify_reset/pages/verify_reset_page.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/features/auth/presentation/verify_reset/manger/cubit/verify_reset_cubit.dart'; +import '../../../../../../generated/locale_keys.g.dart'; +import '../../../../../app/config/base_state/base_state.dart'; +import '../../../../../app/core/router/route_names.dart'; +import '../../../../../app/core/widgets/show_app_dialog.dart'; +import '../../../../../app/core/widgets/show_snak_bar.dart'; +import '../widgets/verify_rest_code_form.dart'; + +class VerifyResetCodePage extends StatelessWidget { + final String email; + const VerifyResetCodePage({super.key, required this.email}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: Text(LocaleKeys.emailVerification.tr()), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.resource.status != current.resource.status, + listener: (context, state) { + if (state.resource.status == Status.success && + state.code.isNotEmpty) { + showAppSnackbar(context, LocaleKeys.yourEmailVerified.tr()); + context.push(RouteNames.resetPassword, extra: email); + } + if (state.resource.status == Status.error) { + showAppDialog( + context, + message: + state.resource.error ?? LocaleKeys.an_error_occurred.tr(), + isError: true, + ); + } + }, + builder: (context, state) { + return VerifyResetCodeForm(); + }, + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/verify_reset/widgets/count_down_timer_widget.dart b/lib/features/auth/presentation/verify_reset/widgets/count_down_timer_widget.dart new file mode 100644 index 0000000..00f2cba --- /dev/null +++ b/lib/features/auth/presentation/verify_reset/widgets/count_down_timer_widget.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class CountdownTimerWidget extends StatefulWidget { + final int initialSeconds; + final VoidCallback onTimerEnd; + final Color? activeColor; + final Color? inactiveColor; + + const CountdownTimerWidget({ + super.key, + required this.initialSeconds, + required this.onTimerEnd, + this.activeColor = Colors.pink, + this.inactiveColor = Colors.grey, + }); + + @override + State createState() => _CountdownTimerWidgetState(); +} + +class _CountdownTimerWidgetState extends State { + late int _remainingSeconds; + Timer? _timer; + + @override + void initState() { + super.initState(); + _remainingSeconds = widget.initialSeconds; + _startTimer(); + } + + void _startTimer() { + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + setState(() { + _remainingSeconds--; + }); + + if (_remainingSeconds <= 0) { + timer.cancel(); + widget.onTimerEnd(); + } + }); + } + + String _formatTime() { + final minutes = _remainingSeconds ~/ 60; + final seconds = _remainingSeconds % 60; + return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isActive = _remainingSeconds > 0; + + return Text( + isActive ? _formatTime() : '00:00', + style: (Theme.of(context).textTheme.bodyMedium)?.copyWith( + color: isActive ? widget.activeColor : widget.inactiveColor, + ), + ); + } +} diff --git a/lib/features/auth/presentation/verify_reset/widgets/resend_action_widget.dart b/lib/features/auth/presentation/verify_reset/widgets/resend_action_widget.dart new file mode 100644 index 0000000..5e89080 --- /dev/null +++ b/lib/features/auth/presentation/verify_reset/widgets/resend_action_widget.dart @@ -0,0 +1,82 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:tracking_app/features/auth/presentation/verify_reset/manger/cubit/verify_reset_cubit.dart'; + +import '../../../../../generated/locale_keys.g.dart'; + +Widget buildResendSectionWithCountdown( + BuildContext context, + VerifyResetCodeCubit cubit, + VerifyResetCodeState state, +) { + final canResend = state.canResend; + final cooldownSeconds = state.resendCountdown; + + return Column( + children: [ + Text( + LocaleKeys.didNotReceive.tr(), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + + if (canResend) + InkWell( + onTap: () => cubit.doIntent(ResendCodeIntent()), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.pink.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.pink, width: 1), + ), + child: Text( + LocaleKeys.resend.tr(), + style: TextStyle( + color: Colors.pink, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.timer_outlined, color: Colors.pink, size: 20), + const SizedBox(width: 8), + Text( + _formatTime(cooldownSeconds), + style: TextStyle( + color: Colors.pink, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + Text( + 'until you can resend', + style: TextStyle(color: Colors.grey.shade600, fontSize: 14), + ), + ], + ), + ), + ], + ); +} + +String _formatTime(int seconds) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; +} diff --git a/lib/features/auth/presentation/verify_reset/widgets/verify_rest_code_form.dart b/lib/features/auth/presentation/verify_reset/widgets/verify_rest_code_form.dart new file mode 100644 index 0000000..afc077b --- /dev/null +++ b/lib/features/auth/presentation/verify_reset/widgets/verify_rest_code_form.dart @@ -0,0 +1,93 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/features/auth/presentation/verify_reset/manger/cubit/verify_reset_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/verify_reset/widgets/resend_action_widget.dart'; +import '../../../../../../generated/locale_keys.g.dart'; +import '../../../../../app/config/base_state/base_state.dart'; +import 'package:flutter_otp_text_field/flutter_otp_text_field.dart'; + +class VerifyResetCodeForm extends StatelessWidget { + const VerifyResetCodeForm({super.key}); + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + + return BlocBuilder( + buildWhen: (previous, current) => + previous.canResend != current.canResend || + previous.resendCountdown != current.resendCountdown || + previous.resource.status != current.resource.status, + builder: (context, state) { + final isLoading = state.resource.status == Status.loading; + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 40), + Text( + LocaleKeys.emailVerification.tr(), + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 16), + Text( + LocaleKeys.instruction.tr(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 48), + + OtpTextField( + numberOfFields: 6, + borderColor: Theme.of(context).colorScheme.primary, + enabledBorderColor: Theme.of(context).colorScheme.outline, + focusedBorderColor: Theme.of(context).colorScheme.primary, + showFieldAsBox: true, + fieldWidth: 40, + fieldHeight: 64, + borderRadius: BorderRadius.circular(12), + textStyle: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w600), + onCodeChanged: (code) => + cubit.doIntent(FormChangedIntent(code)), + onSubmit: (code) { + cubit.doIntent(FormChangedIntent(code)); + cubit.doIntent(SubmitVerifyCodeIntent()); + }, + ), + + const SizedBox(height: 32), + + if (isLoading) + CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + + if (!isLoading) const SizedBox(height: 32), + buildResendSectionWithCountdown(context, cubit, state), + + const SizedBox(height: 20), + Text( + 'Code sent to: ${cubit.email}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/driver_orders_details/api/datasource/order_details_remote_datasource_impl.dart b/lib/features/driver_orders_details/api/datasource/order_details_remote_datasource_impl.dart new file mode 100644 index 0000000..bb04114 --- /dev/null +++ b/lib/features/driver_orders_details/api/datasource/order_details_remote_datasource_impl.dart @@ -0,0 +1,254 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_polyline_points/flutter_polyline_points.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/drivers_dto.dart'; +import 'package:flutter/services.dart'; +import 'package:googleapis_auth/auth_io.dart'; +import 'package:tracking_app/features/driver_orders_details/data/datasource/order_details_remote_datasource.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/orders_dto.dart'; + +@Injectable(as: OrderDetailsRemoteDatasource) +class OrderDetailsRemoteDatasourceImpl implements OrderDetailsRemoteDatasource { + final FirebaseFirestore _firestore; + final Dio _dio; + OrderDetailsRemoteDatasourceImpl({ + required FirebaseFirestore firestore, + required Dio dio, + }) : _dio = dio, + _firestore = firestore; + + @override + ApiResult> getOrderStream(String orderId) { + try { + final stream = _firestore + .collection('orders') + .doc(orderId) + .snapshots() + .where((snapshot) => snapshot.exists && snapshot.data() != null) + .map((snapshot) { + return OrderDto.fromJson( + snapshot.data() as Map, + snapshot.id, + ); + }); + return SuccessApiResult>(data: stream); + } catch (e) { + return ErrorApiResult>(error: e.toString()); + } + } + + @override + ApiResult> getDriverData(String driverId) { + try { + final stream = _firestore + .collection('drivers') + .doc(driverId) + .snapshots() + .where((snapshot) => snapshot.exists && snapshot.data() != null) + .map((snapshot) { + return DriverDataDto.fromJson( + snapshot.data() as Map, + ); + }); + return SuccessApiResult>(data: stream); + } catch (e) { + return ErrorApiResult>(error: e.toString()); + } + } + + @override + Future> getLatLngFromAddress(String address) async { + try { + final response = await _dio.get( + "https://nominatim.openstreetmap.org/search", + queryParameters: { + "q": "$address, Egypt", + "format": "json", + "limit": 1, + "addressdetails": 1, + }, + options: Options(headers: {"User-Agent": "tracking_app"}), + ); + + final data = response.data; + + print("<<<<<<<< Geocode response: $data"); + + if (response.statusCode == 200 && data != null && data.isNotEmpty) { + double lat = double.parse(data[0]['lat']); + double lon = double.parse(data[0]['lon']); + + return SuccessApiResult(data: LatLng(lat, lon)); + } + return SuccessApiResult(data: null); + } catch (e) { + return ErrorApiResult(error: e.toString()); + } + } + + @override + Future>> getRealRoute( + LatLng myLocation, + LatLng destination, + ) async { + try { + final response = await _dio.get( + "https://router.project-osrm.org/route/v1/driving/" + "${myLocation.longitude},${myLocation.latitude};" + "${destination.longitude},${destination.latitude}", + queryParameters: {"overview": "full", "geometries": "polyline"}, + ); + + final data = response.data; + + if (response.statusCode == 200 && data['code'] == 'Ok') { + String encodedPolyline = data['routes'][0]['geometry']; + + List result = PolylinePoints.decodePolyline( + encodedPolyline, + ); + + List polylineCoordinates = result + .map((point) => LatLng(point.latitude, point.longitude)) + .toList(); + + return SuccessApiResult>(data: polylineCoordinates); + } + + return ErrorApiResult>(error: 'No route found'); + } catch (e) { + return ErrorApiResult>(error: e.toString()); + } + } + + @override + Future updateDriverLocation( + String driverId, + double lat, + double lng, + ) async { + await FirebaseFirestore.instance.collection('drivers').doc(driverId).update( + {"currentLocation.lat": lat, "currentLocation.lng": lng}, + ); + } + + Future> pushNotification({ + required String title, + required String des, + }) async { + try { + await _firestore.collection('notification').add({ + 'title': title, + 'des': des, + }); + return SuccessApiResult(data: null); + } catch (e) { + return ErrorApiResult(error: e.toString()); + } + } + + Future> sendDeviceNotification({ + required String userId, + required String title, + required String body, + }) async { + try { + // 1. Get the user document from the u8sj29sk2k collection using id_user + final querySnapshot = await _firestore + .collection('u8sj29sk2k') + .where('id_user', isEqualTo: userId) + .get(); + + if (querySnapshot.docs.isEmpty) { + return ErrorApiResult(error: 'User not found'); + } + + final userDoc = querySnapshot.docs.first; + final deviceToken = userDoc.data()['deviceToken'] as String?; + + if (deviceToken == null || deviceToken.isEmpty) { + return ErrorApiResult(error: 'Device token not found'); + } + + // 2. Send FCM push notification via HTTP v1 API + // Using service account credentials to generate an OAuth2 token + final String jsonString = await rootBundle.loadString( + 'assets/data/elevate-flower-app-a66e96c7e8d7.json', + ); + final credentials = ServiceAccountCredentials.fromJson(jsonString); + final client = await clientViaServiceAccount(credentials, [ + 'https://www.googleapis.com/auth/firebase.messaging', + ]); + final String oauthToken = client.credentials.accessToken.data; + client.close(); + + final response = await _dio.post( + 'https://fcm.googleapis.com/v1/projects/elevate-flower-app/messages:send', + options: Options( + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $oauthToken', + }, + ), + data: { + 'message': { + 'token': deviceToken, + 'notification': {'title': title, 'body': body}, + 'android': { + 'notification': { + 'sound': 'default', + 'click_action': 'FLUTTER_NOTIFICATION_CLICK', + }, + }, + 'apns': { + 'payload': { + 'aps': { + 'sound': 'default', + 'category': 'FLUTTER_NOTIFICATION_CLICK', + }, + }, + }, + }, + }, + ); + + if (response.statusCode == 200) { + print('Notification sent successfully to user mvc'); + return SuccessApiResult(data: null); + } else { + return ErrorApiResult( + error: 'FCM error: \${response.statusCode}', + ); + } + } catch (e) { + return ErrorApiResult(error: e.toString()); + } + } + + Future> updateOrderState({ + required String orderId, + required String state, + }) async { + try { + final querySnapshot = await _firestore + .collection('orders') + .where('orderId', isEqualTo: orderId) + .get(); + if (querySnapshot.docs.isNotEmpty) { + await querySnapshot.docs.first.reference.update({ + 'oder_dt.status': state, + }); + } else { + await _firestore.collection('orders').doc(orderId).update({ + 'oder_dt.status': state, + }); + } + return SuccessApiResult(data: null); + } catch (e) { + return ErrorApiResult(error: e.toString()); + } + } +} diff --git a/lib/features/driver_orders_details/data/datasource/order_details_remote_datasource.dart b/lib/features/driver_orders_details/data/datasource/order_details_remote_datasource.dart new file mode 100644 index 0000000..0fe9f94 --- /dev/null +++ b/lib/features/driver_orders_details/data/datasource/order_details_remote_datasource.dart @@ -0,0 +1,28 @@ +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/drivers_dto.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/orders_dto.dart'; + +abstract class OrderDetailsRemoteDatasource { + ApiResult> getOrderStream(String orderId); + ApiResult> getDriverData(String driverId); + Future> getLatLngFromAddress(String address); + Future updateDriverLocation(String driverId, double lat, double lng); + Future>> getRealRoute( + LatLng myLocation, + LatLng destination, + ); + Future> updateOrderState({ + required String orderId, + required String state, + }); + Future> pushNotification({ + required String title, + required String des, + }); + Future> sendDeviceNotification({ + required String userId, + required String title, + required String body, + }); +} diff --git a/lib/features/driver_orders_details/data/mapper/drivers_dto_mapper.dart b/lib/features/driver_orders_details/data/mapper/drivers_dto_mapper.dart new file mode 100644 index 0000000..5d63e20 --- /dev/null +++ b/lib/features/driver_orders_details/data/mapper/drivers_dto_mapper.dart @@ -0,0 +1,20 @@ +import 'package:tracking_app/features/driver_orders_details/data/models/drivers_dto.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; + +extension DriversDtoMapper on DriverDataDto { + DriverDataModel toDriversModel() { + return DriverDataModel( + name: name, + phone: phone, + id: id, + deviceToken: deviceToken, + currentLocation: currentLocation.toDriverLocationModel(), + ); + } +} + +extension DriverLocationDtoMapper on DriverLocationDto { + DriverLocationModel toDriverLocationModel() { + return DriverLocationModel(lat: lat, lng: lng); + } +} diff --git a/lib/features/driver_orders_details/data/mapper/order_dto_mapper.dart b/lib/features/driver_orders_details/data/mapper/order_dto_mapper.dart new file mode 100644 index 0000000..ab50afe --- /dev/null +++ b/lib/features/driver_orders_details/data/mapper/order_dto_mapper.dart @@ -0,0 +1,51 @@ +import 'package:tracking_app/features/driver_orders_details/data/models/orders_dto.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; + +extension OrderDtoMapper on OrderDto { + OrderModel toOrderModel() { + return OrderModel( + driverId: driverId, + orderId: orderId, + userAddress: userAddress.toUserAddressModel(), + userId: userId, + orderDetails: orderDetails.toOrderDetailsModel(), + ); + } +} + +extension OrderDetailsDtoMapper on OrderDetailsDto { + OrderDetailsModel toOrderDetailsModel() { + return OrderDetailsModel( + items: items.map((i) => i.toOrderItemModel()).toList(), + status: status, + totalPrice: totalPrice, + pickupAddress: pickupAddress.toPickedAddressModel(), + orderId: orderId, + userAddress: userAddress, + ); + } +} + +extension OrderItemDtoMapper on OrderItemDto { + OrderItemModel toOrderItemModel() { + return OrderItemModel( + productId: productId, + title: title, + image: image, + quantity: quantity, + price: price, + ); + } +} + +extension PickedAddressDtoMapper on PickedAddressDto { + PickedAddressModel toPickedAddressModel() { + return PickedAddressModel(name: name, address: address); + } +} + +extension UserAddressDtoMapper on UserAddressDto { + UserAddressModel toUserAddressModel() { + return UserAddressModel(name: name, address: address, userId: userId); + } +} diff --git a/lib/features/driver_orders_details/data/models/drivers_dto.dart b/lib/features/driver_orders_details/data/models/drivers_dto.dart new file mode 100644 index 0000000..bdde436 --- /dev/null +++ b/lib/features/driver_orders_details/data/models/drivers_dto.dart @@ -0,0 +1,55 @@ +class DriverDataDto { + final String id; + final String name; + final String phone; + final String deviceToken; + final DriverLocationDto currentLocation; + + DriverDataDto({ + required this.id, + required this.name, + required this.phone, + required this.deviceToken, + required this.currentLocation, + }); + + factory DriverDataDto.fromJson(Map json) { + return DriverDataDto( + id: json['id'] ?? '', + name: json['name'] ?? '', + phone: json['phone'] ?? '', + deviceToken: json['deviceToken'] ?? '', + currentLocation: DriverLocationDto.fromJson( + json['currentLocation'] ?? {}, + ), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'phone': phone, + 'deviceToken': deviceToken, + 'currentLocation': currentLocation.toJson(), + }; + } +} + +class DriverLocationDto { + final double lat; + final double lng; + + DriverLocationDto({required this.lat, required this.lng}); + + factory DriverLocationDto.fromJson(Map json) { + return DriverLocationDto( + lat: (json['lat'] ?? 0).toDouble(), + lng: (json['lng'] ?? 0).toDouble(), + ); + } + + Map toJson() { + return {'lat': lat, 'lng': lng}; + } +} diff --git a/lib/features/driver_orders_details/data/models/orders_dto.dart b/lib/features/driver_orders_details/data/models/orders_dto.dart new file mode 100644 index 0000000..0b14faf --- /dev/null +++ b/lib/features/driver_orders_details/data/models/orders_dto.dart @@ -0,0 +1,154 @@ +class OrderDto { + final String orderId; + final String driverId; + final String userId; + final OrderDetailsDto orderDetails; + final UserAddressDto userAddress; + + OrderDto({ + required this.orderId, + required this.driverId, + required this.userId, + required this.orderDetails, + required this.userAddress, + }); + + factory OrderDto.fromJson(Map json, String id) { + return OrderDto( + orderId: id, + driverId: json['driver_id'] ?? '', + userId: json['user_id'] ?? '', + orderDetails: OrderDetailsDto.fromJson(json['oder_dt'] ?? {}), + userAddress: UserAddressDto.fromJson(json['userAddress'] ?? {}), + ); + } + + Map toJson() { + return { + 'driver_id': driverId, + 'user_id': userId, + 'oder_dt': (orderDetails).toJson(), + 'userAddress': (userAddress).toJson(), + }; + } +} + +class OrderDetailsDto { + final List items; + final String status; + final double totalPrice; + final PickedAddressDto pickupAddress; + final String orderId; + final String userAddress; + + OrderDetailsDto({ + required this.items, + required this.status, + required this.totalPrice, + required this.pickupAddress, + required this.orderId, + required this.userAddress, + }); + + factory OrderDetailsDto.fromJson(Map json) { + return OrderDetailsDto( + status: json['status'] ?? '', + totalPrice: (json['totalPrice'] ?? 0).toDouble(), + pickupAddress: PickedAddressDto.fromJson(json['pickupAddress'] ?? {}), + items: (json['items'] as List? ?? []) + .map((i) => OrderItemDto.fromJson(i)) + .toList(), + orderId: json['orderId'] ?? '', + userAddress: json['userAddress'] ?? '', + ); + } + + Map toJson() { + return { + 'status': status, + 'totalPrice': totalPrice, + 'pickupAddress': (pickupAddress).toJson(), + 'items': items.map((i) => (i).toJson()).toList(), + 'orderId': orderId, + 'userAddress': userAddress, + }; + } +} + +class OrderItemDto { + final String productId; + final String title; + final String image; + final int quantity; + final double price; + + OrderItemDto({ + required this.productId, + required this.title, + required this.image, + required this.quantity, + required this.price, + }); + + factory OrderItemDto.fromJson(Map json) { + return OrderItemDto( + productId: json['productId'] ?? '', + title: json['title'] ?? '', + image: json['image'] ?? '', + quantity: json['quantity'] ?? 0, + price: (json['price'] ?? 0).toDouble(), + ); + } + + Map toJson() { + return { + 'productId': productId, + 'title': title, + 'image': image, + 'quantity': quantity, + 'price': price, + }; + } +} + +class PickedAddressDto { + final String name; + final String address; + + PickedAddressDto({required this.name, required this.address}); + + factory PickedAddressDto.fromJson(Map json) { + return PickedAddressDto( + name: json['name'] ?? '', + address: json['address'] ?? '', + ); + } + + Map toJson() { + return {'name': name, 'address': address}; + } +} + +class UserAddressDto { + final String name; + final String address; + final String userId; + + UserAddressDto({ + required this.name, + required this.address, + required this.userId, + }); + + factory UserAddressDto.fromJson(Map json) { + return UserAddressDto( + name: json['name'] ?? '', + address: json['adress'] ?? '', + userId: json['user_id'] ?? '', + ); + } + + Map toJson() { + return {'name': name, 'adress': address, 'user_id': userId}; + } +} diff --git a/lib/features/driver_orders_details/data/repos/order_details_repo_impl.dart b/lib/features/driver_orders_details/data/repos/order_details_repo_impl.dart new file mode 100644 index 0000000..437dfbb --- /dev/null +++ b/lib/features/driver_orders_details/data/repos/order_details_repo_impl.dart @@ -0,0 +1,106 @@ +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/data/datasource/order_details_remote_datasource.dart'; +import 'package:tracking_app/features/driver_orders_details/data/mapper/drivers_dto_mapper.dart'; +import 'package:tracking_app/features/driver_orders_details/data/mapper/order_dto_mapper.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/drivers_dto.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/orders_dto.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/notcicationModel.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/notficationDevice.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orderStates.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; + +@Injectable(as: OrderDetailsRepo) +class OrderDetailsRepoImpl implements OrderDetailsRepo { + final OrderDetailsRemoteDatasource _remoteDataSource; + final AuthStorage _authStorage; + OrderDetailsRepoImpl(this._remoteDataSource, this._authStorage); + + @override + Future>> getOrderDetails() async { + final orderId = await _authStorage.getOrderId(); + if (orderId == null) { + return ErrorApiResult>(error: "No order ID found"); + } + final result = _remoteDataSource.getOrderStream(orderId); + + switch (result) { + case SuccessApiResult>(): + return SuccessApiResult>( + data: result.data.map((dto) => dto.toOrderModel()), + ); + case ErrorApiResult>(): + return ErrorApiResult>(error: result.error); + } + } + + @override + ApiResult> getDriverData(String driverId) { + final result = _remoteDataSource.getDriverData(driverId); + + switch (result) { + case SuccessApiResult>(): + return SuccessApiResult>( + data: result.data.map((dto) => dto.toDriversModel()), + ); + case ErrorApiResult>(): + return ErrorApiResult>(error: result.error); + } + } + + @override + Future> getLatLngFromAddress(String address) { + return _remoteDataSource.getLatLngFromAddress(address); + } + + @override + Future>> getRealRoute( + LatLng myLocation, + LatLng destination, + ) { + return _remoteDataSource.getRealRoute(myLocation, destination); + } + + Future> updateOrderState( + UpdateOrderStateParams params, + ) async { + return _remoteDataSource.updateOrderState( + orderId: params.orderId, + state: params.state, + ); + } + + @override + Future> pushNotification( + PushNotificationParams params, + ) async { + return _remoteDataSource.pushNotification( + title: params.title, + des: params.des, + ); + } + + @override + Future> sendDeviceNotification( + SendDeviceNotificationParams params, + ) async { + return _remoteDataSource.sendDeviceNotification( + userId: params.userId, + title: params.title, + body: params.body, + ); + } + + @override + Future updateDriverLocation( + String driverId, + double lat, + double lng, + ) async { + return _remoteDataSource.updateDriverLocation(driverId, lat, lng); + } +} diff --git a/lib/features/driver_orders_details/domain/models/drivers_model.dart b/lib/features/driver_orders_details/domain/models/drivers_model.dart new file mode 100644 index 0000000..e8657cd --- /dev/null +++ b/lib/features/driver_orders_details/domain/models/drivers_model.dart @@ -0,0 +1,22 @@ +class DriverDataModel { + final String id; + final String name; + final String phone; + final String deviceToken; + final DriverLocationModel currentLocation; + + DriverDataModel({ + required this.id, + required this.name, + required this.phone, + required this.deviceToken, + required this.currentLocation, + }); +} + +class DriverLocationModel { + final double lat; + final double lng; + + DriverLocationModel({required this.lat, required this.lng}); +} diff --git a/lib/features/driver_orders_details/domain/models/location_type.dart b/lib/features/driver_orders_details/domain/models/location_type.dart new file mode 100644 index 0000000..4572c33 --- /dev/null +++ b/lib/features/driver_orders_details/domain/models/location_type.dart @@ -0,0 +1 @@ +enum LocationType { pickup, user } diff --git a/lib/features/driver_orders_details/domain/models/notcicationModel.dart b/lib/features/driver_orders_details/domain/models/notcicationModel.dart new file mode 100644 index 0000000..3c9efc6 --- /dev/null +++ b/lib/features/driver_orders_details/domain/models/notcicationModel.dart @@ -0,0 +1,6 @@ +class PushNotificationParams { + final String title; + final String des; + + PushNotificationParams({required this.title, required this.des}); +} diff --git a/lib/features/driver_orders_details/domain/models/notficationDevice.dart b/lib/features/driver_orders_details/domain/models/notficationDevice.dart new file mode 100644 index 0000000..c2e564d --- /dev/null +++ b/lib/features/driver_orders_details/domain/models/notficationDevice.dart @@ -0,0 +1,11 @@ +class SendDeviceNotificationParams { + final String userId; + final String title; + final String body; + + SendDeviceNotificationParams({ + required this.userId, + required this.title, + required this.body, + }); +} diff --git a/lib/features/driver_orders_details/domain/models/orderStates.dart b/lib/features/driver_orders_details/domain/models/orderStates.dart new file mode 100644 index 0000000..d057fa6 --- /dev/null +++ b/lib/features/driver_orders_details/domain/models/orderStates.dart @@ -0,0 +1,6 @@ +class UpdateOrderStateParams { + final String orderId; + final String state; + + UpdateOrderStateParams({required this.orderId, required this.state}); +} diff --git a/lib/features/driver_orders_details/domain/models/orders_model.dart b/lib/features/driver_orders_details/domain/models/orders_model.dart new file mode 100644 index 0000000..9e96435 --- /dev/null +++ b/lib/features/driver_orders_details/domain/models/orders_model.dart @@ -0,0 +1,68 @@ +class OrderModel { + final String orderId; + final String driverId; + final String userId; + final OrderDetailsModel orderDetails; + final UserAddressModel userAddress; + + OrderModel({ + required this.orderId, + required this.driverId, + required this.userId, + required this.orderDetails, + required this.userAddress, + }); +} + +class OrderDetailsModel { + final List items; + final String status; + final double totalPrice; + final PickedAddressModel pickupAddress; + final String orderId; + final String userAddress; + + OrderDetailsModel({ + required this.items, + required this.status, + required this.totalPrice, + required this.pickupAddress, + required this.orderId, + required this.userAddress, + }); +} + +class OrderItemModel { + final String productId; + final String title; + final String image; + final int quantity; + final double price; + + OrderItemModel({ + required this.productId, + required this.title, + required this.image, + required this.quantity, + required this.price, + }); +} + +class PickedAddressModel { + final String name; + final String address; + + PickedAddressModel({required this.name, required this.address}); +} + +class UserAddressModel { + final String userId; + final String name; + final String address; + + UserAddressModel({ + required this.name, + required this.address, + required this.userId, + }); +} diff --git a/lib/features/driver_orders_details/domain/repos/order_details_repo.dart b/lib/features/driver_orders_details/domain/repos/order_details_repo.dart new file mode 100644 index 0000000..b53698b --- /dev/null +++ b/lib/features/driver_orders_details/domain/repos/order_details_repo.dart @@ -0,0 +1,23 @@ +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/notcicationModel.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/notficationDevice.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orderStates.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; + +abstract class OrderDetailsRepo { + Future>> getOrderDetails(); + ApiResult> getDriverData(String driverId); + Future> getLatLngFromAddress(String address); + Future updateDriverLocation(String driverId, double lat, double lng); + Future>> getRealRoute( + LatLng myLocation, + LatLng destination, + ); + Future> updateOrderState(UpdateOrderStateParams params); + Future> pushNotification(PushNotificationParams params); + Future> sendDeviceNotification( + SendDeviceNotificationParams params, + ); +} diff --git a/lib/features/driver_orders_details/domain/usecases/get_address_usecase.dart b/lib/features/driver_orders_details/domain/usecases/get_address_usecase.dart new file mode 100644 index 0000000..85f8158 --- /dev/null +++ b/lib/features/driver_orders_details/domain/usecases/get_address_usecase.dart @@ -0,0 +1,15 @@ +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; + +@injectable +class GetAddressUsecase { + final OrderDetailsRepo _repo; + + GetAddressUsecase(this._repo); + + Future> getAddress(String address) { + return _repo.getLatLngFromAddress(address); + } +} diff --git a/lib/features/driver_orders_details/domain/usecases/get_driver_data_usecase.dart b/lib/features/driver_orders_details/domain/usecases/get_driver_data_usecase.dart new file mode 100644 index 0000000..0680a58 --- /dev/null +++ b/lib/features/driver_orders_details/domain/usecases/get_driver_data_usecase.dart @@ -0,0 +1,13 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; + +@injectable +class GetDriverDataUsecase { + OrderDetailsRepo _repo; + GetDriverDataUsecase({required OrderDetailsRepo repo}) : _repo = repo; + + ApiResult> call(String driverId) => + _repo.getDriverData(driverId); +} diff --git a/lib/features/driver_orders_details/domain/usecases/get_order_details_usecase.dart b/lib/features/driver_orders_details/domain/usecases/get_order_details_usecase.dart new file mode 100644 index 0000000..37fe21e --- /dev/null +++ b/lib/features/driver_orders_details/domain/usecases/get_order_details_usecase.dart @@ -0,0 +1,12 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; + +@injectable +class GetOrderDetailsUsecase { + OrderDetailsRepo _repo; + GetOrderDetailsUsecase({required OrderDetailsRepo repo}) : _repo = repo; + + Future>> call() => _repo.getOrderDetails(); +} diff --git a/lib/features/driver_orders_details/domain/usecases/get_real_route_usecase.dart b/lib/features/driver_orders_details/domain/usecases/get_real_route_usecase.dart new file mode 100644 index 0000000..46ee447 --- /dev/null +++ b/lib/features/driver_orders_details/domain/usecases/get_real_route_usecase.dart @@ -0,0 +1,18 @@ +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; + +@injectable +class GetRealRouteUsecase { + final OrderDetailsRepo _repo; + + GetRealRouteUsecase(this._repo); + + Future>> getRealRoute( + LatLng driverLocation, + LatLng destination, + ) { + return _repo.getRealRoute(driverLocation, destination); + } +} diff --git a/lib/features/driver_orders_details/domain/usecases/push_notification_usecase.dart b/lib/features/driver_orders_details/domain/usecases/push_notification_usecase.dart new file mode 100644 index 0000000..176ab97 --- /dev/null +++ b/lib/features/driver_orders_details/domain/usecases/push_notification_usecase.dart @@ -0,0 +1,14 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/notcicationModel.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; + +@injectable +class PushNotificationUsecase { + final OrderDetailsRepo _repo; + + PushNotificationUsecase({required OrderDetailsRepo repo}) : _repo = repo; + + Future> call(PushNotificationParams params) => + _repo.pushNotification(params); +} diff --git a/lib/features/driver_orders_details/domain/usecases/send_device_notification_usecase.dart b/lib/features/driver_orders_details/domain/usecases/send_device_notification_usecase.dart new file mode 100644 index 0000000..cad8033 --- /dev/null +++ b/lib/features/driver_orders_details/domain/usecases/send_device_notification_usecase.dart @@ -0,0 +1,15 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/notficationDevice.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; + +@injectable +class SendDeviceNotificationUsecase { + final OrderDetailsRepo _repo; + + SendDeviceNotificationUsecase({required OrderDetailsRepo repo}) + : _repo = repo; + + Future> call(SendDeviceNotificationParams params) => + _repo.sendDeviceNotification(params); +} diff --git a/lib/features/driver_orders_details/domain/usecases/update_driver_location_usecase.dart b/lib/features/driver_orders_details/domain/usecases/update_driver_location_usecase.dart new file mode 100644 index 0000000..70d735d --- /dev/null +++ b/lib/features/driver_orders_details/domain/usecases/update_driver_location_usecase.dart @@ -0,0 +1,13 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; + +@injectable +class UpdateDriverLocationUsecase { + final OrderDetailsRepo _repo; + + UpdateDriverLocationUsecase(this._repo); + + Future updateDriverLocation(String driverId, double lat, double lng) { + return _repo.updateDriverLocation(driverId, lat, lng); + } +} diff --git a/lib/features/driver_orders_details/domain/usecases/update_order_state_usecase.dart b/lib/features/driver_orders_details/domain/usecases/update_order_state_usecase.dart new file mode 100644 index 0000000..4075dbe --- /dev/null +++ b/lib/features/driver_orders_details/domain/usecases/update_order_state_usecase.dart @@ -0,0 +1,14 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orderStates.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; + +@injectable +class UpdateOrderStateUsecase { + final OrderDetailsRepo _repo; + + UpdateOrderStateUsecase({required OrderDetailsRepo repo}) : _repo = repo; + + Future> call(UpdateOrderStateParams params) => + _repo.updateOrderState(params); +} diff --git a/lib/features/driver_orders_details/presentation/manager/order_details_cubit.dart b/lib/features/driver_orders_details/presentation/manager/order_details_cubit.dart new file mode 100644 index 0000000..032825a --- /dev/null +++ b/lib/features/driver_orders_details/presentation/manager/order_details_cubit.dart @@ -0,0 +1,222 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/usecases/get_driver_data_usecase.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/usecases/get_address_usecase.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/notcicationModel.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/notficationDevice.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orderStates.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/usecases/get_real_route_usecase.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/usecases/push_notification_usecase.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/usecases/send_device_notification_usecase.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/usecases/update_driver_location_usecase.dart'; +import '../../domain/usecases/get_order_details_usecase.dart'; +import '../../domain/usecases/update_order_state_usecase.dart'; +import 'order_details_intents.dart'; +import 'order_details_states.dart'; + +@injectable +class OrderDetailsCubit extends Cubit { + final GetOrderDetailsUsecase _getOrderDetailsUsecase; + final GetDriverDataUsecase _getDriverDataUsecase; + final UpdateOrderStateUsecase _updateOrderStateUsecase; + final PushNotificationUsecase _pushNotificationUsecase; + final SendDeviceNotificationUsecase _sendDeviceNotificationUsecase; + final GetAddressUsecase _getAddressUsecase; + final GetRealRouteUsecase _getRealRouteUsecase; + final UpdateDriverLocationUsecase _updateDriverLocationUsecase; + StreamSubscription? _orderSubscription; + StreamSubscription? _driverSubscription; + Timer? _driverMoveTimer; + int _currentIndex = 0; + List _fullRoute = []; + + OrderDetailsCubit( + this._getOrderDetailsUsecase, + this._getDriverDataUsecase, + this._getAddressUsecase, + this._getRealRouteUsecase, + this._updateDriverLocationUsecase, + this._updateOrderStateUsecase, + this._pushNotificationUsecase, + this._sendDeviceNotificationUsecase, + ) : super(OrderDetailsStates()); + + final _authStorage = getIt(); + + void onIntent(OrderDetailsIntent intent) { + switch (intent) { + case GetOrderDetails(): + getOrderDetails(); + case UpdateOrderState(currentStatus: final status): + _updateOrderState(status); + } + } + + void getOrderDetails() async { + emit(state.copyWith(data: Resource.loading())); + _orderSubscription?.cancel(); + + final result = await _getOrderDetailsUsecase.call(); + + if (result is SuccessApiResult>) { + _orderSubscription = result.data.listen( + (order) { + emit(state.copyWith(data: Resource.success(order))); + if (order.driverId.isNotEmpty) { + getDriverData(order.driverId); + } + }, + onError: (error) { + emit(state.copyWith(data: Resource.error(error.toString()))); + }, + ); + } else if (result is ErrorApiResult>) { + emit(state.copyWith(data: Resource.error(result.error))); + } + } + + void getDriverData(String driverId) async { + emit(state.copyWith(driverData: Resource.loading())); + _driverSubscription?.cancel(); + final result = _getDriverDataUsecase.call(driverId); + if (result is SuccessApiResult>) { + _driverSubscription = result.data.listen((driver) async { + emit(state.copyWith(driverData: Resource.success(driver))); + }); + } else if (result is ErrorApiResult>) { + emit(state.copyWith(driverData: Resource.error(result.error))); + } + } + + Future getRoute(LatLng driverLocation) async { + if (state.destination == null) return; + + final result = await _getRealRouteUsecase.getRealRoute( + driverLocation, + state.destination!, + ); + + if (result is SuccessApiResult>) { + _fullRoute = result.data; + _currentIndex = 0; + emit(state.copyWith(polylines: _fullRoute)); + } + } + + Future setDestinationFromAddress( + String address, + LatLng driverLocation, + ) async { + if (state.destination != null) return; + + final result = await _getAddressUsecase.getAddress(address); + + if (result is SuccessApiResult && result.data != null) { + emit(state.copyWith(destination: result.data)); + await getRoute(driverLocation); + startDriverSimulation(); + } + } + + LatLng moveTowards(LatLng current, LatLng destination, double step) { + double latDiff = destination.latitude - current.latitude; + double lngDiff = destination.longitude - current.longitude; + + double newLat = current.latitude + (latDiff * step); + double newLng = current.longitude + (lngDiff * step); + + return LatLng(newLat, newLng); + } + + void startDriverSimulation() { + _driverMoveTimer?.cancel(); + _driverMoveTimer = Timer.periodic(const Duration(seconds: 10), ( + timer, + ) async { + final driver = state.driverData?.data; + + if (driver == null || _fullRoute.isEmpty) return; + + if (_currentIndex >= _fullRoute.length) { + timer.cancel(); + return; + } + + final nextPoint = _fullRoute[_currentIndex]; + _currentIndex++; + + await _updateDriverLocationUsecase.updateDriverLocation( + driver.id, + nextPoint.latitude, + nextPoint.longitude, + ); + final remainingRoute = _fullRoute.sublist(_currentIndex); + + emit(state.copyWith(polylines: remainingRoute)); + }); + } + + String? _nextStateFor(String currentStatus) { + switch (currentStatus.toLowerCase()) { + case 'pending': + case 'accepted': + return 'Picked'; + case 'picked': + return 'Out for delivery'; + case 'out for delivery': + return 'Arrived'; + case 'arrived': + return 'Delivered'; + default: + return null; + } + } + + Future _updateOrderState(String currentStatus) async { + final orderId = await _authStorage.getOrderId(); + if (orderId == null || orderId.isEmpty) return; + + final nextState = _nextStateFor(currentStatus); + if (nextState == null) return; + + final result = await _updateOrderStateUsecase( + UpdateOrderStateParams(orderId: orderId, state: nextState), + ); + + if (result is SuccessApiResult) { + final title = 'Order Update'; + final body = 'Your order is now $nextState'; + + await _pushNotificationUsecase( + PushNotificationParams(title: title, des: body), + ); + + // Send actual FCM push to device token + if (state.data?.data?.userId != null) { + await _sendDeviceNotificationUsecase( + SendDeviceNotificationParams( + userId: state.data!.data!.userId, + title: title, + body: body, + ), + ); + } + } + } + + @override + Future close() { + _orderSubscription?.cancel(); + _driverSubscription?.cancel(); + _driverMoveTimer?.cancel(); + return super.close(); + } +} diff --git a/lib/features/driver_orders_details/presentation/manager/order_details_intents.dart b/lib/features/driver_orders_details/presentation/manager/order_details_intents.dart new file mode 100644 index 0000000..669ba6c --- /dev/null +++ b/lib/features/driver_orders_details/presentation/manager/order_details_intents.dart @@ -0,0 +1,8 @@ +sealed class OrderDetailsIntent {} + +class GetOrderDetails extends OrderDetailsIntent {} + +class UpdateOrderState extends OrderDetailsIntent { + final String currentStatus; + UpdateOrderState(this.currentStatus); +} diff --git a/lib/features/driver_orders_details/presentation/manager/order_details_states.dart b/lib/features/driver_orders_details/presentation/manager/order_details_states.dart new file mode 100644 index 0000000..8adc425 --- /dev/null +++ b/lib/features/driver_orders_details/presentation/manager/order_details_states.dart @@ -0,0 +1,31 @@ +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; + +class OrderDetailsStates { + final Resource? data; + final Resource? driverData; + final LatLng? destination; + final List? polylines; + const OrderDetailsStates({ + this.data, + this.driverData, + this.destination, + this.polylines, + }); + + OrderDetailsStates copyWith({ + Resource? data, + Resource? driverData, + LatLng? destination, + List? polylines, + }) { + return OrderDetailsStates( + data: data ?? this.data, + driverData: driverData ?? this.driverData, + destination: destination ?? this.destination, + polylines: polylines ?? this.polylines, + ); + } +} diff --git a/lib/features/driver_orders_details/presentation/pages/drivers_orders_details_page.dart b/lib/features/driver_orders_details/presentation/pages/drivers_orders_details_page.dart new file mode 100644 index 0000000..b29b827 --- /dev/null +++ b/lib/features/driver_orders_details/presentation/pages/drivers_orders_details_page.dart @@ -0,0 +1,211 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/app/core/values/paths.dart'; +import 'package:tracking_app/app/core/widgets/custom_button.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/location_type.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/manager/order_details_cubit.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/manager/order_details_intents.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/manager/order_details_states.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/widgets/address_card.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/widgets/bottom_row_section.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/widgets/order_item.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/widgets/order_status.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/widgets/section_title.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +class DriversOrdersDetailsPage extends StatelessWidget { + const DriversOrdersDetailsPage({super.key}); + + @override + Widget build(BuildContext context) { + final order = getIt().state.data?.data; + final status = OrderStatus.fromString(order?.orderDetails.status); + + return PopScope( + canPop: status == OrderStatus.delivered, + onPopInvoked: (didPop) { + if (!didPop && status != OrderStatus.delivered) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(LocaleKeys.finishYourOrder.tr())), + ); + } + }, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: AppColors.blackColor), + onPressed: () { + if (status == OrderStatus.delivered) { + context.pop(); + } + }, + ), + title: Text( + LocaleKeys.orderDetails.tr(), + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 20, + color: AppColors.blackColor, + ), + ), + ), + body: BlocProvider( + create: (context) => + getIt()..onIntent(GetOrderDetails()), + child: BlocBuilder( + builder: (context, state) { + if (state.data?.status == Status.loading) { + return const Center(child: CircularProgressIndicator()); + } else if (state.data?.status == Status.error) { + return Center(child: Text(state.data!.error.toString())); + } else if (state.data?.status == Status.success) { + final order = state.data!.data; + final status = OrderStatus.fromString( + order?.orderDetails.status, + ); + + int currentStep = status.step; + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: List.generate(5, (index) { + return Expanded( + child: Container( + height: 4, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: index < currentStep + ? AppColors.green + : AppColors.lightGrey, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + }), + ), + const SizedBox(height: 20), + + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.lightPink, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${LocaleKeys.status.tr()}${order?.orderDetails.status}', + style: TextStyle( + color: AppColors.green, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + '${LocaleKeys.orderId.tr()}${order?.orderId}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + 'Wed, 03 Sep 2024, 11:00 AM', + style: TextStyle( + color: AppColors.grey, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + SectionTitle(title: LocaleKeys.pickupAddress.tr()), + InkWell( + onTap: () => context.push( + RouteNames.locationPage, + extra: LocationType.pickup, + ), + child: AddressCard( + title: order?.orderDetails.pickupAddress.name ?? '', + address: + order?.orderDetails.pickupAddress.address ?? '', + imagePath: AppPaths.flowerLogo, + phoneNumber: (state.driverData?.data?.phone) + .toString(), + ), + ), + const SizedBox(height: 16), + SectionTitle(title: LocaleKeys.userAddress.tr()), + InkWell( + onTap: () => context.push( + RouteNames.locationPage, + extra: LocationType.user, + ), + child: AddressCard( + title: order?.userAddress.name ?? '', + address: order?.userAddress.address ?? '', + imagePath: AppPaths.flowerLogo, + phoneNumber: (state.driverData?.data?.phone) + .toString(), + ), + ), + const SizedBox(height: 24), + + SectionTitle(title: LocaleKeys.orderDetails.tr()), + OrderItems(), + const SizedBox(height: 16), + + BottomRowSection( + label: LocaleKeys.total.tr(), + value: + '${LocaleKeys.egp.tr()} ${order?.orderDetails.totalPrice.toStringAsFixed(2)}', + ), + BottomRowSection( + label: LocaleKeys.payment_method.tr(), + value: LocaleKeys.cash_on_delivery.tr(), + ), + + const SizedBox(height: 32), + + SizedBox( + width: double.infinity, + height: 55, + child: CustomButton( + isEnabled: status != OrderStatus.delivered, + onPressed: () { + if (status != OrderStatus.delivered && + order != null) { + context.read().onIntent( + UpdateOrderState(order.orderDetails.status), + ); + } + }, + isLoading: false, + text: status.buttonTextKey.tr(), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + } +} diff --git a/lib/features/driver_orders_details/presentation/pages/location_page.dart b/lib/features/driver_orders_details/presentation/pages/location_page.dart new file mode 100644 index 0000000..234d401 --- /dev/null +++ b/lib/features/driver_orders_details/presentation/pages/location_page.dart @@ -0,0 +1,262 @@ +import 'dart:typed_data'; +import 'package:flutter/services.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/app/core/ui_helper/assets/images.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/app/core/values/paths.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/location_type.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/manager/order_details_cubit.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/manager/order_details_states.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/widgets/address_card.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/widgets/section_title.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +class LocationPage extends StatefulWidget { + final LocationType locationType; + const LocationPage({super.key, required this.locationType}); + + @override + State createState() => _LocationPageState(); +} + +class _LocationPageState extends State { + late OrderDetailsCubit cubit; + + LatLng? destination; + Set polylines = {}; + + Set markers = {}; + BitmapDescriptor? driverIcon; + BitmapDescriptor? destinationIcon; + + @override + void initState() { + super.initState(); + cubit = getIt(); + cubit.getOrderDetails(); + loadMarkerIcons(); + } + + Future getMarkerIcon(String path) async { + final ByteData data = await DefaultAssetBundle.of(context).load(path); + final Uint8List bytes = data.buffer.asUint8List(); + return BitmapDescriptor.fromBytes(bytes); + } + + Future loadMarkerIcons() async { + driverIcon = await getMarkerIcon(Assets.driverLocation); + + destinationIcon = await getMarkerIcon( + widget.locationType == LocationType.pickup + ? Assets.floweryLocation + : Assets.userLocation, + ); + setState(() {}); + } + + void driverMarker(LatLng driverLocation) { + markers.removeWhere((m) => m.markerId.value == "driver_location"); + markers.add( + Marker( + markerId: const MarkerId("driver_location"), + position: driverLocation, + icon: driverIcon ?? BitmapDescriptor.defaultMarker, + infoWindow: const InfoWindow(title: "Your location"), + ), + ); + } + + void destinationMarker(LatLng destinationLocation) { + markers.add( + Marker( + markerId: const MarkerId("destination_location"), + position: destinationLocation, + icon: destinationIcon ?? BitmapDescriptor.defaultMarker, + infoWindow: const InfoWindow(title: "Destination"), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: BlocProvider( + create: (context) => cubit, + child: BlocConsumer( + listener: (context, state) { + final driver = state.driverData?.data; + final order = state.data?.data; + if (driver == null || order == null) return; + + final driverLocation = LatLng( + driver.currentLocation.lat, + driver.currentLocation.lng, + ); + String address; + + if (widget.locationType == LocationType.pickup) { + address = order.orderDetails.pickupAddress.address; + } else { + address = order.userAddress.address; + } + + print( + '<<<<<<< driver $driver, order $order, ${state.destination}, ${state.polylines}', + ); + + cubit.setDestinationFromAddress(address, driverLocation); + + driverMarker(driverLocation); + + if (state.destination == null || state.polylines == null) return; + destinationMarker(state.destination!); + + if (state.polylines != null) { + polylines = { + Polyline( + polylineId: const PolylineId("real_route"), + color: AppColors.pink, + width: 5, + points: state.polylines ?? [], + ), + }; + } + setState(() {}); + }, + + builder: (context, state) { + final driver = state.driverData?.data; + if (driver == null) { + return const Center(child: CircularProgressIndicator()); + } + + final driverLocation = LatLng( + driver.currentLocation.lat, + driver.currentLocation.lng, + ); + + return Stack( + alignment: Alignment.topLeft, + + children: [ + Column( + children: [ + Expanded( + child: GoogleMap( + initialCameraPosition: CameraPosition( + target: driverLocation, + zoom: 18, + ), + mapType: MapType.normal, + markers: markers, + polylines: polylines, + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.locationType == LocationType.pickup) ...[ + SectionTitle(title: LocaleKeys.pickupAddress.tr()), + AddressCard( + title: + state + .data + ?.data + ?.orderDetails + .pickupAddress + .name ?? + '', + address: + state + .data + ?.data + ?.orderDetails + .pickupAddress + .address ?? + '', + imagePath: AppPaths.flowerLogo, + phoneNumber: (state.driverData?.data?.phone) + .toString(), + ), + const SizedBox(height: 16), + SectionTitle(title: LocaleKeys.userAddress.tr()), + + AddressCard( + title: state.data?.data?.userAddress.name ?? '', + address: + state.data?.data?.userAddress.address ?? '', + imagePath: AppPaths.flowerLogo, + phoneNumber: (state.driverData?.data?.phone) + .toString(), + ), + ] else ...[ + SectionTitle(title: LocaleKeys.userAddress.tr()), + AddressCard( + title: state.data?.data?.userAddress.name ?? '', + address: + state.data?.data?.userAddress.address ?? '', + imagePath: AppPaths.flowerLogo, + phoneNumber: (state.driverData?.data?.phone) + .toString(), + ), + const SizedBox(height: 16), + SectionTitle(title: LocaleKeys.pickupAddress.tr()), + AddressCard( + title: + state + .data + ?.data + ?.orderDetails + .pickupAddress + .name ?? + '', + address: + state + .data + ?.data + ?.orderDetails + .pickupAddress + .address ?? + '', + imagePath: AppPaths.flowerLogo, + phoneNumber: (state.driverData?.data?.phone) + .toString(), + ), + ], + ], + ), + ), + ], + ), + + Positioned( + top: 40, + left: 16, + child: InkWell( + onTap: () => context.pop(), + child: CircleAvatar( + backgroundColor: AppColors.pink, + child: Center( + child: Icon( + Icons.arrow_back_ios_new, + color: AppColors.white, + ), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/features/driver_orders_details/presentation/widgets/address_card.dart b/lib/features/driver_orders_details/presentation/widgets/address_card.dart new file mode 100644 index 0000000..0cfca02 --- /dev/null +++ b/lib/features/driver_orders_details/presentation/widgets/address_card.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/app/core/utils/app_launcher.dart'; +import 'package:tracking_app/app/core/values/paths.dart'; + +class AddressCard extends StatelessWidget { + final String title; + final String address; + final String imagePath; + final String phoneNumber; + + const AddressCard({ + super.key, + required this.title, + required this.address, + required this.imagePath, + required this.phoneNumber, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: AppColors.lightGrey), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + CircleAvatar(backgroundImage: AssetImage(imagePath), radius: 25), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.labelSmall!.copyWith(fontWeight: FontWeight.w400), + ), + Row( + children: [ + Icon( + Icons.location_on_outlined, + size: 16, + color: AppColors.blackColor, + ), + Flexible( + child: Text( + address, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: Theme.of(context).textTheme.labelSmall!.copyWith( + fontWeight: FontWeight.w400, + color: AppColors.blackColor, + ), + ), + ), + ], + ), + ], + ), + ), + IconButton( + onPressed: () => AppLauncher.launchPhone(phoneNumber), + icon: Icon(Icons.phone_outlined, color: AppColors.pink, size: 20), + ), + + IconButton( + onPressed: () => AppLauncher.launchWhatsApp(phoneNumber), + icon: ImageIcon( + AssetImage(AppPaths.whatsappImage), + color: AppColors.pink, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/driver_orders_details/presentation/widgets/bottom_row_section.dart b/lib/features/driver_orders_details/presentation/widgets/bottom_row_section.dart new file mode 100644 index 0000000..481983d --- /dev/null +++ b/lib/features/driver_orders_details/presentation/widgets/bottom_row_section.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class BottomRowSection extends StatelessWidget { + final String label; + final String value; + const BottomRowSection({super.key, required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: AppColors.lightGrey), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: Theme.of(context).textTheme.labelMedium!.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + Text( + value, + style: Theme.of( + context, + ).textTheme.labelSmall!.copyWith(fontWeight: FontWeight.w500), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/driver_orders_details/presentation/widgets/order_item.dart b/lib/features/driver_orders_details/presentation/widgets/order_item.dart new file mode 100644 index 0000000..1d6cebc --- /dev/null +++ b/lib/features/driver_orders_details/presentation/widgets/order_item.dart @@ -0,0 +1,85 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/app/core/values/paths.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/manager/order_details_cubit.dart'; + +class OrderItems extends StatelessWidget { + const OrderItems({super.key}); + + @override + Widget build(BuildContext context) { + final order = BlocProvider.of(context).state.data!.data; + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: order?.orderDetails.items.length, + itemBuilder: (context, index) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: AppColors.lightGrey), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(50), + child: CachedNetworkImage( + imageUrl: + "${AppPaths.mediaUrl}${order!.orderDetails.items[index].image}", + placeholder: (context, url) => Shimmer( + gradient: LinearGradient( + colors: [ + AppColors.lightGrey, + AppColors.white, + AppColors.lightGrey, + ], + ), + child: const CircularProgressIndicator(), + ), + errorWidget: (context, url, error) => Icon(Icons.error), + width: 55, + height: 55, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + order.orderDetails.items[index].title, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall!.copyWith( + fontWeight: FontWeight.w400, + ), + ), + Text( + 'EGP ${order.orderDetails.items[index].price.toStringAsFixed(2)}', + style: Theme.of(context).textTheme.labelSmall!.copyWith( + fontWeight: FontWeight.w500, + color: AppColors.blackColor, + ), + ), + ], + ), + ), + Text( + 'X${order.orderDetails.items[index].quantity}', + style: Theme.of(context).textTheme.labelSmall!.copyWith( + fontWeight: FontWeight.w500, + color: AppColors.pink, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/driver_orders_details/presentation/widgets/order_status.dart b/lib/features/driver_orders_details/presentation/widgets/order_status.dart new file mode 100644 index 0000000..8f9fa8f --- /dev/null +++ b/lib/features/driver_orders_details/presentation/widgets/order_status.dart @@ -0,0 +1,90 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +enum OrderStatus { + pending, + accepted, + pickup, + outForDelivery, + arrived, + delivered, + unknown; + + static OrderStatus fromString(String? status) { + switch (status?.toLowerCase()) { + case 'pending': + return OrderStatus.pending; + case 'accepted': + return OrderStatus.accepted; + case 'picked': + case 'pickup': + return OrderStatus.pickup; + case 'out for delivery': + return OrderStatus.outForDelivery; + case 'arrived': + return OrderStatus.arrived; + case 'delivered': + return OrderStatus.delivered; + default: + debugPrint('Unknown order status: $status'); + return OrderStatus.unknown; + } + } +} + +extension OrderStatusX on OrderStatus { + int get step { + switch (this) { + case OrderStatus.pending: + case OrderStatus.accepted: + return 1; + case OrderStatus.pickup: + return 2; + case OrderStatus.outForDelivery: + return 3; + case OrderStatus.arrived: + return 4; + case OrderStatus.delivered: + return 5; + case OrderStatus.unknown: + return 1; + } + } + + String get buttonTextKey { + switch (this) { + case OrderStatus.pending: + case OrderStatus.accepted: + return LocaleKeys.btnArrivedAtPickupPoint.tr(); + case OrderStatus.pickup: + return LocaleKeys.btnStartDeliver.tr(); + case OrderStatus.outForDelivery: + return LocaleKeys.btnArrivedToUser.tr(); + case OrderStatus.arrived: + return LocaleKeys.btnDeliveredToUser.tr(); + case OrderStatus.delivered: + return LocaleKeys.orderCompleted.tr(); + case OrderStatus.unknown: + return LocaleKeys.btnArrivedAtPickupPoint.tr(); + } + } + + String get statusTextKey { + switch (this) { + case OrderStatus.pending: + case OrderStatus.accepted: + return LocaleKeys.accepted.tr(); + case OrderStatus.pickup: + return LocaleKeys.pickedUp.tr(); + case OrderStatus.outForDelivery: + return LocaleKeys.outForDelivery.tr(); + case OrderStatus.arrived: + return LocaleKeys.arrived.tr(); + case OrderStatus.delivered: + return LocaleKeys.delivered.tr(); + case OrderStatus.unknown: + return ''; + } + } +} diff --git a/lib/features/driver_orders_details/presentation/widgets/section_title.dart b/lib/features/driver_orders_details/presentation/widgets/section_title.dart new file mode 100644 index 0000000..8055f29 --- /dev/null +++ b/lib/features/driver_orders_details/presentation/widgets/section_title.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class SectionTitle extends StatelessWidget { + final String title; + const SectionTitle({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + title, + style: Theme.of( + context, + ).textTheme.bodyMedium!.copyWith(color: AppColors.blackColor), + ), + ); + } +} diff --git a/lib/features/home/api/driverOrderDataS_imp.dart b/lib/features/home/api/driverOrderDataS_imp.dart new file mode 100644 index 0000000..a6ab880 --- /dev/null +++ b/lib/features/home/api/driverOrderDataS_imp.dart @@ -0,0 +1,26 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/api_manger/api_client.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/app/core/network/safe_api_call.dart'; +import 'package:tracking_app/features/home/data/datascourse/driverOrderDatascource.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; + +@Injectable(as: DriverOrderDataSource) +class DriverOrderDataSourceImpl implements DriverOrderDataSource { + final ApiClient _apiClient; + + DriverOrderDataSourceImpl(this._apiClient); + + @override + Future> getPendingOrders(String token) { + return safeApiCall( + call: () => _apiClient.getPendingOrders(token, limit: 1000), + ); + } + + @override + Future> getProfile(String token) { + return safeApiCall(call: () => _apiClient.getProfile(token: token)); + } +} diff --git a/lib/features/home/data/datascourse/driverOrderDatascource.dart b/lib/features/home/data/datascourse/driverOrderDatascource.dart new file mode 100644 index 0000000..b0c7709 --- /dev/null +++ b/lib/features/home/data/datascourse/driverOrderDatascource.dart @@ -0,0 +1,8 @@ +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; + +abstract class DriverOrderDataSource { + Future> getPendingOrders(String token); + Future> getProfile(String token); +} diff --git a/lib/features/home/data/model/response/orderRespons.dart b/lib/features/home/data/model/response/orderRespons.dart new file mode 100644 index 0000000..0b96f51 --- /dev/null +++ b/lib/features/home/data/model/response/orderRespons.dart @@ -0,0 +1,277 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'orderRespons.g.dart'; + +@JsonSerializable() +class OrderResponse { + @JsonKey(name: "message") + final String? message; + @JsonKey(name: "metadata") + final Metadata? metadata; + @JsonKey(name: "orders") + final List? orders; + + OrderResponse({this.message, this.metadata, this.orders}); + + factory OrderResponse.fromJson(Map json) => + _$OrderResponseFromJson(json); + + Map toJson() => _$OrderResponseToJson(this); + + OrderResponse copyWith({ + String? message, + Metadata? metadata, + List? orders, + }) { + return OrderResponse( + message: message ?? this.message, + metadata: metadata ?? this.metadata, + orders: orders ?? this.orders, + ); + } +} + +@JsonSerializable() +class Metadata { + @JsonKey(name: "currentPage") + final int? currentPage; + @JsonKey(name: "totalPages") + final int? totalPages; + @JsonKey(name: "totalItems") + final int? totalItems; + @JsonKey(name: "limit") + final int? limit; + + Metadata({this.currentPage, this.totalPages, this.totalItems, this.limit}); + + factory Metadata.fromJson(Map json) => + _$MetadataFromJson(json); + + Map toJson() => _$MetadataToJson(this); +} + +@JsonSerializable() +class Order { + @JsonKey(name: "_id") + final String? id; + @JsonKey(name: "user") + final User? user; + @JsonKey(name: "orderItems") + final List? orderItems; + @JsonKey(name: "totalPrice") + final int? totalPrice; + @JsonKey(name: "paymentType") + final String? paymentType; + @JsonKey(name: "isPaid") + final bool? isPaid; + @JsonKey(name: "isDelivered") + final bool? isDelivered; + @JsonKey(name: "state") + final String? state; + @JsonKey(name: "createdAt") + final DateTime? createdAt; + @JsonKey(name: "updatedAt") + final DateTime? updatedAt; + @JsonKey(name: "orderNumber") + final String? orderNumber; + @JsonKey(name: "__v") + final int? v; + @JsonKey(name: "store") + final Store? store; + @JsonKey(name: "shippingAddress") + final ShippingAddress? shippingAddress; + @JsonKey(name: "paidAt") + final DateTime? paidAt; + + Order({ + this.id, + this.user, + this.orderItems, + this.totalPrice, + this.paymentType, + this.isPaid, + this.isDelivered, + this.state, + this.createdAt, + this.updatedAt, + this.orderNumber, + this.v, + this.store, + this.shippingAddress, + this.paidAt, + }); + + factory Order.fromJson(Map json) => _$OrderFromJson(json); + + Map toJson() => _$OrderToJson(this); +} + +@JsonSerializable() +class OrderItem { + @JsonKey(name: "product") + final Product? product; + @JsonKey(name: "price") + final int? price; + @JsonKey(name: "quantity") + final int? quantity; + @JsonKey(name: "_id") + final String? id; + + OrderItem({this.product, this.price, this.quantity, this.id}); + + factory OrderItem.fromJson(Map json) => + _$OrderItemFromJson(json); + + Map toJson() => _$OrderItemToJson(this); +} + +@JsonSerializable() +class Product { + @JsonKey(name: "_id") + final String? id; + @JsonKey(name: "title") + final String? title; + @JsonKey(name: "slug") + final String? slug; + @JsonKey(name: "description") + final String? description; + @JsonKey(name: "imgCover") + final String? imgCover; + @JsonKey(name: "images") + final List? images; + @JsonKey(name: "price") + final int? price; + @JsonKey(name: "priceAfterDiscount") + final int? priceAfterDiscount; + @JsonKey(name: "quantity") + final int? quantity; + @JsonKey(name: "category") + final String? category; + @JsonKey(name: "occasion") + final String? occasion; + @JsonKey(name: "createdAt") + final DateTime? createdAt; + @JsonKey(name: "updatedAt") + final DateTime? updatedAt; + @JsonKey(name: "__v") + final int? v; + @JsonKey(name: "sold") + final int? sold; + @JsonKey(name: "isSuperAdmin") + final bool? isSuperAdmin; + @JsonKey(name: "rateAvg") + final int? rateAvg; + @JsonKey(name: "rateCount") + final int? rateCount; + + Product({ + this.id, + this.title, + this.slug, + this.description, + this.imgCover, + this.images, + this.price, + this.priceAfterDiscount, + this.quantity, + this.category, + this.occasion, + this.createdAt, + this.updatedAt, + this.v, + this.sold, + this.isSuperAdmin, + this.rateAvg, + this.rateCount, + }); + + factory Product.fromJson(Map json) => + _$ProductFromJson(json); + + Map toJson() => _$ProductToJson(this); +} + +@JsonSerializable() +class ShippingAddress { + @JsonKey(name: "street") + final String? street; + @JsonKey(name: "city") + final String? city; + @JsonKey(name: "phone") + final String? phone; + @JsonKey(name: "lat") + final String? lat; + @JsonKey(name: "long") + final String? long; + + ShippingAddress({this.street, this.city, this.phone, this.lat, this.long}); + + factory ShippingAddress.fromJson(Map json) => + _$ShippingAddressFromJson(json); + + Map toJson() => _$ShippingAddressToJson(this); +} + +@JsonSerializable() +class Store { + @JsonKey(name: "name") + final String? name; + @JsonKey(name: "image") + final String? image; + @JsonKey(name: "address") + final String? address; + @JsonKey(name: "phoneNumber") + final String? phoneNumber; + @JsonKey(name: "latLong") + final String? latLong; + + Store({this.name, this.image, this.address, this.phoneNumber, this.latLong}); + + factory Store.fromJson(Map json) => _$StoreFromJson(json); + + Map toJson() => _$StoreToJson(this); +} + +@JsonSerializable() +class User { + @JsonKey(name: "_id") + final String? id; + @JsonKey(name: "firstName") + final String? firstName; + @JsonKey(name: "lastName") + final String? lastName; + @JsonKey(name: "email") + final String? email; + @JsonKey(name: "gender") + final String? gender; + @JsonKey(name: "phone") + final String? phone; + @JsonKey(name: "photo") + final String? photo; + @JsonKey(name: "passwordChangedAt") + final DateTime? passwordChangedAt; + @JsonKey(name: "passwordResetCode") + final String? passwordResetCode; + @JsonKey(name: "passwordResetExpires") + final DateTime? passwordResetExpires; + @JsonKey(name: "resetCodeVerified") + final bool? resetCodeVerified; + + User({ + this.id, + this.firstName, + this.lastName, + this.email, + this.gender, + this.phone, + this.photo, + this.passwordChangedAt, + this.passwordResetCode, + this.passwordResetExpires, + this.resetCodeVerified, + }); + + factory User.fromJson(Map json) => _$UserFromJson(json); + + Map toJson() => _$UserToJson(this); +} diff --git a/lib/features/home/data/repo/driverOrderRepo_impl.dart b/lib/features/home/data/repo/driverOrderRepo_impl.dart new file mode 100644 index 0000000..51cad99 --- /dev/null +++ b/lib/features/home/data/repo/driverOrderRepo_impl.dart @@ -0,0 +1,23 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/home/data/datascourse/driverOrderDatascource.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import 'package:tracking_app/features/home/domain/repo/driverOrderRepo.dart'; + +@Injectable(as: DriverOrderRepo) +class DriverOrderRepositoryImpl implements DriverOrderRepo { + final DriverOrderDataSource _dataSource; + + DriverOrderRepositoryImpl(this._dataSource); + + @override + Future> getPendingOrders(String token) { + return _dataSource.getPendingOrders(token); + } + + @override + Future> getProfile(String token) { + return _dataSource.getProfile(token); + } +} diff --git a/lib/features/home/domain/repo/driverOrderRepo.dart b/lib/features/home/domain/repo/driverOrderRepo.dart new file mode 100644 index 0000000..5fad3ee --- /dev/null +++ b/lib/features/home/domain/repo/driverOrderRepo.dart @@ -0,0 +1,8 @@ +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; + +abstract class DriverOrderRepo { + Future> getPendingOrders(String token); + Future> getProfile(String token); +} diff --git a/lib/features/home/domain/usecase/getdriverOrderUsecase.dart b/lib/features/home/domain/usecase/getdriverOrderUsecase.dart new file mode 100644 index 0000000..a138cb1 --- /dev/null +++ b/lib/features/home/domain/usecase/getdriverOrderUsecase.dart @@ -0,0 +1,15 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/home/domain/repo/driverOrderRepo.dart'; + +@injectable +class GetDriverOrdersUseCase { + final DriverOrderRepo _repository; + + GetDriverOrdersUseCase(this._repository); + + Future> call(String token) { + return _repository.getPendingOrders(token); + } +} diff --git a/lib/features/home/domain/usecase/upload_driver_fire_data_use_case.dart b/lib/features/home/domain/usecase/upload_driver_fire_data_use_case.dart new file mode 100644 index 0000000..89926b5 --- /dev/null +++ b/lib/features/home/domain/usecase/upload_driver_fire_data_use_case.dart @@ -0,0 +1,26 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; + +@injectable +class UploadDriverFireDataUseCase { + final FirebaseFirestore _firestore; + + UploadDriverFireDataUseCase(@Named('firestore') this._firestore); + + Future call( + DriverModel driver, { + required double lat, + required double lng, + String? deviceToken, + }) async { + final driverCollection = _firestore.collection('drivers'); + await driverCollection.doc(driver.Id).set({ + 'id': driver.Id, + 'name': '${driver.firstName} ${driver.lastName}', + 'phone': driver.phone, + 'currentLocation': {'lat': lat, 'lng': lng}, + 'deviceToken': deviceToken, + }, SetOptions(merge: true)); + } +} diff --git a/lib/features/home/domain/usecase/upload_order_fire_data_use_case.dart b/lib/features/home/domain/usecase/upload_order_fire_data_use_case.dart new file mode 100644 index 0000000..c35f894 --- /dev/null +++ b/lib/features/home/domain/usecase/upload_order_fire_data_use_case.dart @@ -0,0 +1,55 @@ +import 'package:cloud_firestore/cloud_firestore.dart' hide Order; +import 'package:injectable/injectable.dart' hide Order; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; + +@injectable +class UploadOrderFireDataUseCase { + final FirebaseFirestore _firestore; + + UploadOrderFireDataUseCase(@Named('firestore') this._firestore); + + Future call({required Order order, required String driverId}) async { + final orderCollection = _firestore.collection('orders'); + + final data = { + 'driver_id': driverId, + 'oder_dt': { + 'items': + order.orderItems + ?.map( + (e) => { + 'productId': e.product?.id, + 'title': e.product?.title, + 'quantity': e.quantity, + 'price': e.product?.price, + 'image': e.product?.imgCover, + }, + ) + .toList() ?? + [], + 'orderId': order.id, + 'pickupAddress': { + 'address': order.store?.address ?? '', + 'name': order.store?.name ?? '', + }, + 'status': order.state ?? 'pending', + 'totalPrice': order.totalPrice ?? 0, + 'userAddress': + '${order.shippingAddress?.street ?? ''}, ${order.shippingAddress?.city ?? ''}', + }, + 'userAddress': { + 'adress': + '${order.shippingAddress?.street ?? ''}, ${order.shippingAddress?.city ?? ''}', + 'name': '${order.user?.firstName ?? ''} ${order.user?.lastName ?? ''}', + 'user_id': order.user?.id ?? '', + }, + 'user_id': order.user?.id ?? '', + }; + + if (order.id != null) { + await orderCollection.doc(order.id).set(data, SetOptions(merge: true)); + } else { + await orderCollection.add(data); + } + } +} diff --git a/lib/features/home/presentation/manger/driverorderCubit.dart b/lib/features/home/presentation/manger/driverorderCubit.dart new file mode 100644 index 0000000..01f5170 --- /dev/null +++ b/lib/features/home/presentation/manger/driverorderCubit.dart @@ -0,0 +1,147 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart' hide Order; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/home/domain/usecase/getdriverOrderUsecase.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderIntent.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderStates.dart'; +import 'package:tracking_app/features/home/domain/repo/driverOrderRepo.dart'; +import 'package:tracking_app/features/home/domain/usecase/upload_driver_fire_data_use_case.dart'; +import 'package:tracking_app/features/home/domain/usecase/upload_order_fire_data_use_case.dart'; + +@injectable +class DriverOrderCubit extends Cubit { + final GetDriverOrdersUseCase _getDriverOrdersUseCase; + final AuthStorage _authStorage; + final UploadDriverFireDataUseCase _uploadDriverFireDataUseCase; + final UploadOrderFireDataUseCase _uploadOrderFireDataUseCase; + final DriverOrderRepo _driverOrderRepository; + + DriverOrderCubit( + this._getDriverOrdersUseCase, + this._authStorage, + this._uploadDriverFireDataUseCase, + this._uploadOrderFireDataUseCase, + this._driverOrderRepository, + ) : super(DriverOrderState()); + + void onIntent(DriverOrderIntent intent) { + switch (intent) { + case GetPendingOrders(): + _getPendingOrders(); + case RemoveOrder(order: final order): + _removeOrder(order); + case AcceptOrder(order: final order): + _acceptOrder(order); + } + } + + void _removeOrder(Order order) { + final currentResource = state.orderResource; + if (currentResource.status == Status.success && + currentResource.data != null) { + final currentOrders = currentResource.data!.orders!; + final updatedOrders = currentOrders + .where((element) => element != order) + .toList(); + emit( + state.copyWith( + orderResource: Resource.success( + currentResource.data!.copyWith(orders: updatedOrders), + ), + ), + ); + } + } + + Future _acceptOrder(Order order) async { + final token = await _authStorage.getToken(); + if (token == null) return; + + final result = await _driverOrderRepository.getProfile(token); + + if (result is SuccessApiResult) { + final profile = (result as SuccessApiResult).data; + if (profile.driver != null) { + try { + final position = await _determinePosition(); + if (position == null) { + if (kDebugMode) + print("Location permission denied or service disabled."); + return; + } + + final deviceToken = await FirebaseMessaging.instance.getToken(); + await _uploadDriverFireDataUseCase( + profile.driver!, + lat: position.latitude, + lng: position.longitude, + deviceToken: deviceToken, + ); + + await _uploadOrderFireDataUseCase( + order: order, + driverId: profile.driver?.Id ?? '', + ); + + if (order.id != null) { + await _authStorage.saveOrderId(order.id!); + } + } catch (e) { + if (kDebugMode) { + print("Firestore/Location Error: $e"); + } + } + } + } + } + + Future _determinePosition() async { + bool serviceEnabled; + LocationPermission permission; + + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + return null; + } + + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + return null; + } + } + + if (permission == LocationPermission.deniedForever) { + return null; + } + + return await Geolocator.getCurrentPosition(); + } + + Future _getPendingOrders() async { + emit(state.copyWith(orderResource: Resource.loading())); + final token = await _authStorage.getToken(); + if (token == null) { + emit( + state.copyWith(orderResource: Resource.error("User not authenticated")), + ); + return; + } + final result = await _getDriverOrdersUseCase(token); + return switch (result) { + SuccessApiResult(data: final orderResponse) => emit( + state.copyWith(orderResource: Resource.success(orderResponse)), + ), + ErrorApiResult(error: final error) => emit( + state.copyWith(orderResource: Resource.error(error)), + ), + }; + } +} diff --git a/lib/features/home/presentation/manger/driverorderIntent.dart b/lib/features/home/presentation/manger/driverorderIntent.dart new file mode 100644 index 0000000..9f88440 --- /dev/null +++ b/lib/features/home/presentation/manger/driverorderIntent.dart @@ -0,0 +1,15 @@ +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; + +sealed class DriverOrderIntent {} + +class GetPendingOrders extends DriverOrderIntent {} + +class RemoveOrder extends DriverOrderIntent { + final Order order; + RemoveOrder(this.order); +} + +class AcceptOrder extends DriverOrderIntent { + final Order order; + AcceptOrder(this.order); +} diff --git a/lib/features/home/presentation/manger/driverorderStates.dart b/lib/features/home/presentation/manger/driverorderStates.dart new file mode 100644 index 0000000..c93079f --- /dev/null +++ b/lib/features/home/presentation/manger/driverorderStates.dart @@ -0,0 +1,13 @@ +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; + +class DriverOrderState { + final Resource orderResource; + + DriverOrderState({Resource? orderResource}) + : orderResource = orderResource ?? Resource.initial(); + + DriverOrderState copyWith({Resource? orderResource}) { + return DriverOrderState(orderResource: orderResource ?? this.orderResource); + } +} diff --git a/lib/features/home/presentation/pages/driverOrderScreen.dart b/lib/features/home/presentation/pages/driverOrderScreen.dart new file mode 100644 index 0000000..41b2d8e --- /dev/null +++ b/lib/features/home/presentation/pages/driverOrderScreen.dart @@ -0,0 +1,31 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderCubit.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderIntent.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverScreenBody.dart'; + +class DriverOrderScreen extends StatelessWidget { + const DriverOrderScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + getIt()..onIntent(GetPendingOrders()), + child: Scaffold( + appBar: AppBar( + title: Text( + LocaleKeys.floweryRider.tr(), + style: const TextStyle(color: AppColors.pink), + ), + ), + body: const DriverOrderBody(), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/driverOrderButton.dart b/lib/features/home/presentation/widgets/driverOrderButton.dart new file mode 100644 index 0000000..6759d98 --- /dev/null +++ b/lib/features/home/presentation/widgets/driverOrderButton.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class DriverOrderButton extends StatelessWidget { + final String text; + final VoidCallback onTap; + final bool isPrimary; + + const DriverOrderButton({ + super.key, + required this.text, + required this.onTap, + required this.isPrimary, + }); + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final height = MediaQuery.of(context).size.height; + return InkWell( + onTap: onTap, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: width * 0.06, + vertical: height * 0.012, + ), + decoration: BoxDecoration( + color: isPrimary ? const Color(0xFFE91E63) : Colors.white, + borderRadius: BorderRadius.circular(24), + border: isPrimary ? null : Border.all(color: const Color(0xFFE91E63)), + ), + child: Text( + text, + style: TextStyle( + color: isPrimary ? Colors.white : const Color(0xFFE91E63), + fontSize: width * 0.035, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/driverOrderInfoCard.dart b/lib/features/home/presentation/widgets/driverOrderInfoCard.dart new file mode 100644 index 0000000..c8b668a --- /dev/null +++ b/lib/features/home/presentation/widgets/driverOrderInfoCard.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +class DriverOrderInfoCard extends StatelessWidget { + final String? image; + final String title; + final String subtitle; + final bool isStore; + + const DriverOrderInfoCard({ + super.key, + required this.image, + required this.title, + required this.subtitle, + required this.isStore, + }); + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final height = MediaQuery.of(context).size.height; + return Container( + padding: EdgeInsets.all(width * 0.03), + decoration: BoxDecoration( + color: const Color(0xFFF9F9F9), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFEEEEEE)), + ), + child: Row( + children: [ + Container( + width: width * 0.12, + height: width * 0.12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isStore ? const Color(0xFFE91E63) : Colors.grey[300], + image: image != null + ? DecorationImage( + image: NetworkImage(image!), + fit: BoxFit.cover, + ) + : null, + ), + child: image == null + ? Icon( + isStore ? Icons.store : Icons.person, + color: Colors.white, + ) + : null, + ), + SizedBox(width: width * 0.03), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: width * 0.035, + fontWeight: FontWeight.w500, + color: const Color(0xFF2D2D2D), + ), + ), + SizedBox(height: height * 0.005), + Row( + children: [ + Icon( + Icons.location_on_outlined, + size: width * 0.035, + color: Colors.black54, + ), + SizedBox(width: width * 0.01), + Expanded( + child: Text( + subtitle, + style: TextStyle( + fontSize: width * 0.03, + color: Colors.black54, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/driverOrderItem.dart b/lib/features/home/presentation/widgets/driverOrderItem.dart new file mode 100644 index 0000000..271950d --- /dev/null +++ b/lib/features/home/presentation/widgets/driverOrderItem.dart @@ -0,0 +1,104 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverOrderButton.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverOrderInfoCard.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverOrderSectionLabel.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +class DriverOrderItem extends StatelessWidget { + final Order order; + final VoidCallback onAccept; + final VoidCallback onReject; + + const DriverOrderItem({ + super.key, + required this.order, + required this.onAccept, + required this.onReject, + }); + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final height = MediaQuery.of(context).size.height; + return Container( + margin: EdgeInsets.symmetric( + horizontal: width * 0.04, + vertical: height * 0.01, + ), + padding: EdgeInsets.all(width * 0.04), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.driverOrderTitle.tr(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF2D2D2D), + ), + ), + SizedBox(height: height * 0.02), + DriverOrderSectionLabel(LocaleKeys.pickupAddress.tr()), + SizedBox(height: height * 0.01), + DriverOrderInfoCard( + image: order.store?.image, + title: order.store?.name ?? LocaleKeys.unknownStore.tr(), + subtitle: order.store?.address ?? LocaleKeys.noAddress.tr(), + isStore: true, + ), + SizedBox(height: height * 0.02), + DriverOrderSectionLabel(LocaleKeys.userAddress.tr()), + SizedBox(height: height * 0.01), + DriverOrderInfoCard( + image: order.user?.photo != null + ? "https://flower.elevateegy.com/uploads/${order.user!.photo!}" + : null, + title: + "${order.user?.firstName ?? ''} ${order.user?.lastName ?? ''}", + subtitle: + order.shippingAddress?.street ?? LocaleKeys.noAddress.tr(), + isStore: false, + ), + SizedBox(height: height * 0.03), + Row( + children: [ + Text( + "${order.totalPrice ?? 0} ${LocaleKeys.egp.tr()}", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF2D2D2D), + ), + ), + const Spacer(), + DriverOrderButton( + text: LocaleKeys.reject.tr(), + onTap: onReject, + isPrimary: false, + ), + SizedBox(width: width * 0.02), + DriverOrderButton( + text: LocaleKeys.accept.tr(), + onTap: onAccept, + isPrimary: true, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/driverOrderSectionLabel.dart b/lib/features/home/presentation/widgets/driverOrderSectionLabel.dart new file mode 100644 index 0000000..f15fb59 --- /dev/null +++ b/lib/features/home/presentation/widgets/driverOrderSectionLabel.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class DriverOrderSectionLabel extends StatelessWidget { + final String text; + const DriverOrderSectionLabel(this.text, {super.key}); + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + return Text( + text, + style: TextStyle(fontSize: width * 0.035, color: Colors.grey), + ); + } +} diff --git a/lib/features/home/presentation/widgets/driverScreenBody.dart b/lib/features/home/presentation/widgets/driverScreenBody.dart new file mode 100644 index 0000000..911578f --- /dev/null +++ b/lib/features/home/presentation/widgets/driverScreenBody.dart @@ -0,0 +1,80 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderCubit.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderIntent.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderStates.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverOrderItem.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +class DriverOrderBody extends StatefulWidget { + const DriverOrderBody({super.key}); + + @override + State createState() => _DriverOrderBodyState(); +} + +class _DriverOrderBodyState extends State { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final resource = state.orderResource; + + if (resource.status == Status.loading) { + return const Center(child: CircularProgressIndicator()); + } + + if (resource.status == Status.error) { + return Center( + child: Text( + resource.error ?? LocaleKeys.unknownError.tr(), + style: const TextStyle(color: Colors.red), + ), + ); + } + + if (resource.status == Status.success) { + final orders = resource.data!.orders?.reversed.toList() ?? []; + if (orders.isEmpty) { + return Center(child: Text(LocaleKeys.noPendingOrders.tr())); + } + return RefreshIndicator( + onRefresh: () async { + context.read().onIntent(GetPendingOrders()); + }, + child: ListView.builder( + itemCount: orders.length, + itemBuilder: (context, index) { + return DriverOrderItem( + order: orders[index], + onAccept: () async { + final order = orders[index]; + await getIt().saveOrderId(order.id.toString()); + debugPrint('<<<< Saved Order ID: ${order.id}'); + context.read().onIntent( + AcceptOrder(orders[index]), + ); + context.push(RouteNames.ordersDetailsPage); + }, + onReject: () { + context.read().onIntent( + RemoveOrder(orders[index]), + ); + }, + ); + }, + ), + ); + } + + return const SizedBox.shrink(); + }, + ); + } +} diff --git a/lib/features/my_orders/api/datasource/my_orders_remote_data_source_imp.dart b/lib/features/my_orders/api/datasource/my_orders_remote_data_source_imp.dart new file mode 100644 index 0000000..b419023 --- /dev/null +++ b/lib/features/my_orders/api/datasource/my_orders_remote_data_source_imp.dart @@ -0,0 +1,24 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/api_manger/api_client.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/app/core/network/safe_api_call.dart'; +import 'package:tracking_app/features/my_orders/data/datasource/my_orders_remote_data_source.dart'; +import 'package:tracking_app/features/my_orders/data/models/response/my_order_response.dart'; + +@Injectable(as: MyOrdersRemoteDataSource) +class MyOrdersRemoteDataSourceImp extends MyOrdersRemoteDataSource { + final ApiClient apiClient; + MyOrdersRemoteDataSourceImp(this.apiClient); + + @override + Future> getAllOrders({ + required String token, + int limit = 10, + int page = 1, + }) { + return safeApiCall( + call: () => + apiClient.getAllOrders(token: token, limit: limit, page: page), + ); + } +} diff --git a/lib/features/my_orders/data/datasource/my_orders_remote_data_source.dart b/lib/features/my_orders/data/datasource/my_orders_remote_data_source.dart new file mode 100644 index 0000000..8648ffa --- /dev/null +++ b/lib/features/my_orders/data/datasource/my_orders_remote_data_source.dart @@ -0,0 +1,10 @@ +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/my_orders/data/models/response/my_order_response.dart'; + +abstract class MyOrdersRemoteDataSource { + Future> getAllOrders({ + required String token, + int limit = 10, + int page = 1, + }); +} diff --git a/lib/features/my_orders/data/mappers/metadata_mapper.dart b/lib/features/my_orders/data/mappers/metadata_mapper.dart new file mode 100644 index 0000000..3b64bf2 --- /dev/null +++ b/lib/features/my_orders/data/mappers/metadata_mapper.dart @@ -0,0 +1,15 @@ +import 'package:tracking_app/features/my_orders/data/models/meta_data_dto.dart'; +import 'package:tracking_app/features/my_orders/domain/models/meta_data_entity.dart'; + +extension MetadataMapper on Metadata { + MetadataEntity toEntity() { + return MetadataEntity( + currentPage: currentPage ?? 0, + totalPages: totalPages ?? 0, + totalItems: totalItems ?? 0, + limit: limit ?? 10, + cancelledCount: cancelledCount ?? 0, + completedCount: completedCount ?? 0, + ); + } +} diff --git a/lib/features/my_orders/data/mappers/order_item_mapper.dart b/lib/features/my_orders/data/mappers/order_item_mapper.dart new file mode 100644 index 0000000..c36c2b9 --- /dev/null +++ b/lib/features/my_orders/data/mappers/order_item_mapper.dart @@ -0,0 +1,17 @@ +import 'package:tracking_app/features/my_orders/domain/models/order_item_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/product_entity.dart'; + +import '../models/order_item_model.dart'; +import 'product_mapper.dart'; + +extension OrderItemMapper on OrderItem { + OrderItemEntity toEntity() { + return OrderItemEntity( + product: + product?.toEntity() ?? + ProductEntity(id: '', price: 0, title: '', image: ''), + price: price ?? 0, + quantity: quantity ?? 0, + ); + } +} diff --git a/lib/features/my_orders/data/mappers/order_mapper.dart b/lib/features/my_orders/data/mappers/order_mapper.dart new file mode 100644 index 0000000..06571e0 --- /dev/null +++ b/lib/features/my_orders/data/mappers/order_mapper.dart @@ -0,0 +1,25 @@ +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; + +import '../models/order_model.dart'; +import 'order_item_mapper.dart'; +import 'user_mapper.dart'; +import 'store_mapper.dart'; + +extension OrderMapper on Order { + OrderEntity toEntity() { + return OrderEntity( + id: id ?? '', + user: user!.toEntity(), + store: store?.toEntity(), + address: address ?? '', + items: orderItems?.map((e) => e.toEntity()).toList() ?? [], + totalPrice: totalPrice ?? 0, + paymentType: paymentType ?? '', + isPaid: isPaid ?? false, + isDelivered: isDelivered ?? false, + state: state ?? '', + createdAt: createdAt ?? '', + orderNumber: orderNumber ?? '', + ); + } +} diff --git a/lib/features/my_orders/data/mappers/orders_list_mapper.dart b/lib/features/my_orders/data/mappers/orders_list_mapper.dart new file mode 100644 index 0000000..d1be05b --- /dev/null +++ b/lib/features/my_orders/data/mappers/orders_list_mapper.dart @@ -0,0 +1,9 @@ +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; +import '../models/order_model.dart'; +import 'order_mapper.dart'; + +extension OrdersListMapper on List { + List toEntityList() { + return map((e) => e.toEntity()).toList(); + } +} diff --git a/lib/features/my_orders/data/mappers/product_mapper.dart b/lib/features/my_orders/data/mappers/product_mapper.dart new file mode 100644 index 0000000..c7010f5 --- /dev/null +++ b/lib/features/my_orders/data/mappers/product_mapper.dart @@ -0,0 +1,13 @@ +import 'package:tracking_app/features/my_orders/domain/models/product_entity.dart'; +import '../models/product_model.dart'; + +extension ProductMapper on Product { + ProductEntity toEntity() { + return ProductEntity( + id: id ?? '', + title: title ?? '', + image: image ?? '', + price: price ?? 0, + ); + } +} diff --git a/lib/features/my_orders/data/mappers/store_mapper.dart b/lib/features/my_orders/data/mappers/store_mapper.dart new file mode 100644 index 0000000..3f4b806 --- /dev/null +++ b/lib/features/my_orders/data/mappers/store_mapper.dart @@ -0,0 +1,13 @@ +import 'package:tracking_app/features/my_orders/domain/models/store_entity.dart'; +import '../models/store_model.dart'; + +extension StoreMapper on Store { + StoreEntity toEntity() { + return StoreEntity( + name: name ?? '', + image: image ?? '', + address: address ?? '', + phoneNumber: phoneNumber ?? '', + ); + } +} diff --git a/lib/features/my_orders/data/mappers/user_mapper.dart b/lib/features/my_orders/data/mappers/user_mapper.dart new file mode 100644 index 0000000..9feb6e1 --- /dev/null +++ b/lib/features/my_orders/data/mappers/user_mapper.dart @@ -0,0 +1,14 @@ +import 'package:tracking_app/features/my_orders/domain/models/user_entity.dart'; +import '../models/user_model.dart'; + +extension UserMapper on User { + UserEntity toEntity() { + return UserEntity( + id: id ?? '', + firstName: firstName ?? '', + lastName: lastName ?? '', + phone: phone ?? '', + photo: photo ?? '', + ); + } +} diff --git a/lib/features/my_orders/data/models/meta_data_dto.dart b/lib/features/my_orders/data/models/meta_data_dto.dart new file mode 100644 index 0000000..017f445 --- /dev/null +++ b/lib/features/my_orders/data/models/meta_data_dto.dart @@ -0,0 +1,36 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'meta_data_dto.g.dart'; + +@JsonSerializable() +class Metadata { + @JsonKey(name: "currentPage") + final int? currentPage; + @JsonKey(name: "totalPages") + final int? totalPages; + @JsonKey(name: "totalItems") + final int? totalItems; + @JsonKey(name: "limit") + final int? limit; + @JsonKey(name: "cancelledCount") + final int? cancelledCount; + @JsonKey(name: "completedCount") + final int? completedCount; + + Metadata({ + this.currentPage, + this.totalPages, + required this.totalItems, + required this.limit, + this.cancelledCount = 0, + this.completedCount = 0, + }); + + factory Metadata.fromJson(Map json) { + return _$MetadataFromJson(json); + } + + Map toJson() { + return _$MetadataToJson(this); + } +} diff --git a/lib/features/my_orders/data/models/order_item_model.dart b/lib/features/my_orders/data/models/order_item_model.dart new file mode 100644 index 0000000..b53bf5e --- /dev/null +++ b/lib/features/my_orders/data/models/order_item_model.dart @@ -0,0 +1,26 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'product_model.dart'; + +part 'order_item_model.g.dart'; + +@JsonSerializable() +class OrderItem { + @JsonKey(name: "_id") + final String? id; + + @JsonKey(name: "product") + final Product? product; + + @JsonKey(name: "price") + final int? price; + + @JsonKey(name: "quantity") + final int? quantity; + + OrderItem({this.id, this.product, this.price, this.quantity}); + + factory OrderItem.fromJson(Map json) => + _$OrderItemFromJson(json); + + Map toJson() => _$OrderItemToJson(this); +} diff --git a/lib/features/my_orders/data/models/order_model.dart b/lib/features/my_orders/data/models/order_model.dart new file mode 100644 index 0000000..761a46e --- /dev/null +++ b/lib/features/my_orders/data/models/order_model.dart @@ -0,0 +1,72 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'order_item_model.dart'; +import 'user_model.dart'; +import 'store_model.dart'; + +part 'order_model.g.dart'; + +@JsonSerializable() +class Order { + @JsonKey(name: "_id") + final String? id; + + @JsonKey(name: "user") + final User? user; + + @JsonKey(name: "store") + final Store? store; + + @JsonKey(name: "address") + final String? address; + + @JsonKey(name: "orderItems") + final List? orderItems; + + @JsonKey(name: "totalPrice") + final int? totalPrice; + + @JsonKey(name: "paymentType") + final String? paymentType; + + @JsonKey(name: "isPaid") + final bool? isPaid; + + @JsonKey(name: "isDelivered") + final bool? isDelivered; + + @JsonKey(name: "state") + final String? state; + + @JsonKey(name: "createdAt") + final String? createdAt; + + @JsonKey(name: "updatedAt") + final String? updatedAt; + + @JsonKey(name: "orderNumber") + final String? orderNumber; + + @JsonKey(name: "__v") + final int? v; + + Order({ + this.id, + this.user, + this.store, + this.address, + this.orderItems, + this.totalPrice, + this.paymentType, + this.isPaid, + this.isDelivered, + this.state, + this.createdAt, + this.updatedAt, + this.orderNumber, + this.v, + }); + + factory Order.fromJson(Map json) => _$OrderFromJson(json); + + Map toJson() => _$OrderToJson(this); +} diff --git a/lib/features/my_orders/data/models/product_model.dart b/lib/features/my_orders/data/models/product_model.dart new file mode 100644 index 0000000..359f9ac --- /dev/null +++ b/lib/features/my_orders/data/models/product_model.dart @@ -0,0 +1,25 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'product_model.g.dart'; + +@JsonSerializable() +class Product { + @JsonKey(name: "_id") + final String? id; + + @JsonKey(name: "title") + final String? title; + + @JsonKey(name: "image") + final String? image; + + @JsonKey(name: "price") + final int? price; + + Product({this.id, this.title, this.image, this.price}); + + factory Product.fromJson(Map json) => + _$ProductFromJson(json); + + Map toJson() => _$ProductToJson(this); +} diff --git a/lib/features/my_orders/data/models/response/my_order_response.dart b/lib/features/my_orders/data/models/response/my_order_response.dart new file mode 100644 index 0000000..0a298e3 --- /dev/null +++ b/lib/features/my_orders/data/models/response/my_order_response.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../meta_data_dto.dart'; +import '../order_model.dart'; + +part 'my_order_response.g.dart'; + +@JsonSerializable() +class MyOrderResponse { + @JsonKey(name: "message") + final String? message; + + @JsonKey(name: "metadata") + final Metadata? metadata; + + @JsonKey(name: "orders") + final List? orders; + + MyOrderResponse({this.message, this.metadata, this.orders}); + + factory MyOrderResponse.fromJson(Map json) => + _$MyOrderResponseFromJson(json); + + Map toJson() => _$MyOrderResponseToJson(this); +} diff --git a/lib/features/my_orders/data/models/store_model.dart b/lib/features/my_orders/data/models/store_model.dart new file mode 100644 index 0000000..ceff9dd --- /dev/null +++ b/lib/features/my_orders/data/models/store_model.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'store_model.g.dart'; + +@JsonSerializable() +class Store { + @JsonKey(name: "name") + final String? name; + + @JsonKey(name: "image") + final String? image; + + @JsonKey(name: "address") + final String? address; + + @JsonKey(name: "phoneNumber") + final String? phoneNumber; + + @JsonKey(name: "latLong") + final String? latLong; + + Store({this.name, this.image, this.address, this.phoneNumber, this.latLong}); + + factory Store.fromJson(Map json) => _$StoreFromJson(json); + + Map toJson() => _$StoreToJson(this); +} diff --git a/lib/features/my_orders/data/models/user_model.dart b/lib/features/my_orders/data/models/user_model.dart new file mode 100644 index 0000000..c302aac --- /dev/null +++ b/lib/features/my_orders/data/models/user_model.dart @@ -0,0 +1,45 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'user_model.g.dart'; + +@JsonSerializable() +class User { + @JsonKey(name: "_id") + final String? id; + + @JsonKey(name: "firstName") + final String? firstName; + + @JsonKey(name: "lastName") + final String? lastName; + + @JsonKey(name: "email") + final String? email; + + @JsonKey(name: "gender") + final String? gender; + + @JsonKey(name: "phone") + final String? phone; + + @JsonKey(name: "photo") + final String? photo; + + @JsonKey(name: "passwordChangedAt") + final String? passwordChangedAt; + + User({ + this.id, + this.firstName, + this.lastName, + this.email, + this.gender, + this.phone, + this.photo, + this.passwordChangedAt, + }); + + factory User.fromJson(Map json) => _$UserFromJson(json); + + Map toJson() => _$UserToJson(this); +} diff --git a/lib/features/my_orders/data/repo/my_orders_repo_imp.dart b/lib/features/my_orders/data/repo/my_orders_repo_imp.dart new file mode 100644 index 0000000..f7f3ed4 --- /dev/null +++ b/lib/features/my_orders/data/repo/my_orders_repo_imp.dart @@ -0,0 +1,178 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/my_orders/data/datasource/my_orders_remote_data_source.dart'; +import 'package:tracking_app/features/my_orders/data/mappers/metadata_mapper.dart'; +import 'package:tracking_app/features/my_orders/data/mappers/order_mapper.dart'; +import 'package:tracking_app/features/my_orders/data/models/response/my_order_response.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_item_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/product_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/store_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/repo/my_orders_repo.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/meta_data_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/user_entity.dart'; + +@Injectable(as: MyOrdersRepo) +class MyOrdersRepoImpl implements MyOrdersRepo { + final MyOrdersRemoteDataSource remoteDataSource; + + MyOrdersRepoImpl(this.remoteDataSource); + + @override + Future> getAllOrders({ + required String token, + int limit = 10, + int page = 1, + }) async { + try { + final result = await remoteDataSource.getAllOrders( + token: token, + limit: limit, + page: page, + ); + + if (result is SuccessApiResult) { + final response = result.data; + List orders = + response.orders?.map((e) => e.toEntity()).toList() ?? []; + MetadataEntity? metadata = response.metadata?.toEntity(); + + if (orders.isEmpty) { + orders = _getDummyOrders(); + metadata = const MetadataEntity( + currentPage: 1, + totalPages: 1, + totalItems: 4, + limit: 10, + cancelledCount: 1, + completedCount: 3, + ); + } + + return SuccessApiResult( + data: MyOrdersResult(orders: orders, metadata: metadata), + ); + } else if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } else { + return ErrorApiResult(error: 'Unknown error'); + } + } catch (e) { + return ErrorApiResult(error: e.toString()); + } + } + + List _getDummyOrders() { + final dummyItems = [ + OrderItemEntity( + product: ProductEntity( + id: "p1", + title: "Red roses, 15 Pink Rose Bouquet", + image: + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT6-k6E9vG_c9B_I0m_K-7J1f8e6C9F5G1g5A&s", + price: 600, + ), + price: 600, + quantity: 1, + ), + OrderItemEntity( + product: ProductEntity( + id: "p2", + title: "Red roses, 15 Pink Rose Bouquet", + image: + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT6-k6E9vG_c9B_I0m_K-7J1f8e6C9F5G1g5A&s", + price: 600, + ), + price: 600, + quantity: 4, + ), + ]; + + return [ + OrderEntity( + id: "123456", + user: UserEntity( + id: "u1", + firstName: "Noor", + lastName: "mohamed", + phone: "01012345678", + photo: "https://i.pravatar.cc/150?u=u1", + ), + store: StoreEntity( + name: "Flowery store", + image: + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT6-k6E9vG_c9B_I0m_K-7J1f8e6C9F5G1g5A&s", + address: "20th st, Sheikh Zayed, Giza", + phoneNumber: "01012345678", + ), + address: "20th st, Sheikh Zayed, Giza", + items: dummyItems, + totalPrice: 3000, + paymentType: "Cash on delivery", + isPaid: true, + isDelivered: true, + state: "Completed", + createdAt: DateTime.now() + .subtract(const Duration(hours: 2)) + .toIso8601String(), + orderNumber: "123456", + ), + OrderEntity( + id: "123457", + user: UserEntity( + id: "u1", + firstName: "Nooor", + lastName: "mohamed", + phone: "01012345678", + photo: "https://i.pravatar.cc/150?u=u1", + ), + store: StoreEntity( + name: "Flowery store", + image: + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT6-k6E9vG_c9B_I0m_K-7J1f8e6C9F5G1g5A&s", + address: "20th st, Sheikh Zayed, Giza", + phoneNumber: "01012345678", + ), + address: "20th st, Sheikh Zayed, Giza", + items: dummyItems, + totalPrice: 3000, + paymentType: "Cash on delivery", + isPaid: false, + isDelivered: false, + state: "Cancelled", + createdAt: DateTime.now() + .subtract(const Duration(hours: 4)) + .toIso8601String(), + orderNumber: "123456", + ), + OrderEntity( + id: "123458", + user: UserEntity( + id: "u1", + firstName: "Noor", + lastName: "mohamed", + phone: "01012345678", + photo: "https://i.pravatar.cc/150?u=u1", + ), + store: StoreEntity( + name: "Flowery store", + image: + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT6-k6E9vG_c9B_I0m_K-7J1f8e6C9F5G1g5A&s", + address: "20th st, Sheikh Zayed, Giza", + phoneNumber: "01012345678", + ), + address: "20th st, Sheikh Zayed, Giza", + items: dummyItems, + totalPrice: 3000, + paymentType: "Cash on delivery", + isPaid: false, + isDelivered: false, + state: "Pending", + createdAt: DateTime.now() + .subtract(const Duration(hours: 6)) + .toIso8601String(), + orderNumber: "123456", + ), + ]; + } +} diff --git a/lib/features/my_orders/domain/models/meta_data_entity.dart b/lib/features/my_orders/domain/models/meta_data_entity.dart new file mode 100644 index 0000000..b22d3e1 --- /dev/null +++ b/lib/features/my_orders/domain/models/meta_data_entity.dart @@ -0,0 +1,17 @@ +class MetadataEntity { + final int currentPage; + final int totalPages; + final int totalItems; + final int limit; + final int cancelledCount; + final int completedCount; + + const MetadataEntity({ + required this.currentPage, + required this.totalPages, + required this.totalItems, + required this.limit, + this.cancelledCount = 0, + this.completedCount = 0, + }); +} diff --git a/lib/features/my_orders/domain/models/order_entity.dart b/lib/features/my_orders/domain/models/order_entity.dart new file mode 100644 index 0000000..36acd73 --- /dev/null +++ b/lib/features/my_orders/domain/models/order_entity.dart @@ -0,0 +1,33 @@ +import 'package:tracking_app/features/my_orders/domain/models/order_item_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/user_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/store_entity.dart'; + +class OrderEntity { + final String id; + final UserEntity user; + final StoreEntity? store; + final String address; + final List items; + final int totalPrice; + final String paymentType; + final bool isPaid; + final bool isDelivered; + final String state; + final String createdAt; + final String orderNumber; + + OrderEntity({ + required this.id, + required this.user, + this.store, + this.address = '', + required this.items, + required this.totalPrice, + required this.paymentType, + required this.isPaid, + required this.isDelivered, + required this.state, + required this.createdAt, + required this.orderNumber, + }); +} diff --git a/lib/features/my_orders/domain/models/order_item_entity.dart b/lib/features/my_orders/domain/models/order_item_entity.dart new file mode 100644 index 0000000..b9f2977 --- /dev/null +++ b/lib/features/my_orders/domain/models/order_item_entity.dart @@ -0,0 +1,13 @@ +import 'package:tracking_app/features/my_orders/domain/models/product_entity.dart'; + +class OrderItemEntity { + final ProductEntity product; + final int price; + final int quantity; + + OrderItemEntity({ + required this.product, + required this.price, + required this.quantity, + }); +} diff --git a/lib/features/my_orders/domain/models/product_entity.dart b/lib/features/my_orders/domain/models/product_entity.dart new file mode 100644 index 0000000..64bbd78 --- /dev/null +++ b/lib/features/my_orders/domain/models/product_entity.dart @@ -0,0 +1,13 @@ +class ProductEntity { + final String id; + final String title; + final String image; + final int price; + + ProductEntity({ + required this.id, + required this.title, + required this.image, + required this.price, + }); +} diff --git a/lib/features/my_orders/domain/models/store_entity.dart b/lib/features/my_orders/domain/models/store_entity.dart new file mode 100644 index 0000000..62a61d8 --- /dev/null +++ b/lib/features/my_orders/domain/models/store_entity.dart @@ -0,0 +1,13 @@ +class StoreEntity { + final String name; + final String image; + final String address; + final String phoneNumber; + + StoreEntity({ + required this.name, + required this.image, + required this.address, + required this.phoneNumber, + }); +} diff --git a/lib/features/my_orders/domain/models/user_entity.dart b/lib/features/my_orders/domain/models/user_entity.dart new file mode 100644 index 0000000..9dbd361 --- /dev/null +++ b/lib/features/my_orders/domain/models/user_entity.dart @@ -0,0 +1,15 @@ +class UserEntity { + final String id; + final String firstName; + final String lastName; + final String phone; + final String photo; + + UserEntity({ + required this.id, + required this.firstName, + required this.lastName, + required this.phone, + required this.photo, + }); +} diff --git a/lib/features/my_orders/domain/repo/my_orders_repo.dart b/lib/features/my_orders/domain/repo/my_orders_repo.dart new file mode 100644 index 0000000..b129443 --- /dev/null +++ b/lib/features/my_orders/domain/repo/my_orders_repo.dart @@ -0,0 +1,18 @@ +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/my_orders/domain/models/meta_data_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; + +class MyOrdersResult { + final List orders; + final MetadataEntity? metadata; + + MyOrdersResult({required this.orders, this.metadata}); +} + +abstract class MyOrdersRepo { + Future> getAllOrders({ + required String token, + int limit, + int page, + }); +} diff --git a/lib/features/my_orders/domain/usecases/get_order_use_case.dart b/lib/features/my_orders/domain/usecases/get_order_use_case.dart new file mode 100644 index 0000000..6137a31 --- /dev/null +++ b/lib/features/my_orders/domain/usecases/get_order_use_case.dart @@ -0,0 +1,18 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/my_orders/domain/repo/my_orders_repo.dart'; + +@injectable +class GetOrderUseCase { + final MyOrdersRepo repo; + + GetOrderUseCase(this.repo); + + Future> call({ + required String token, + int page = 1, + int limit = 10, + }) { + return repo.getAllOrders(token: token, page: page, limit: limit); + } +} diff --git a/lib/features/my_orders/presentation/manager/my_orders_cubit.dart b/lib/features/my_orders/presentation/manager/my_orders_cubit.dart new file mode 100644 index 0000000..2709eba --- /dev/null +++ b/lib/features/my_orders/presentation/manager/my_orders_cubit.dart @@ -0,0 +1,134 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/my_orders/domain/usecases/get_order_use_case.dart'; + +import 'my_orders_intent.dart'; +import 'my_orders_state.dart'; + +@injectable +class MyOrdersCubit extends Cubit { + final GetOrderUseCase _getOrdersUseCase; + final AuthStorage _authStorage; + + int _page = 1; + bool _hasMore = true; + + MyOrdersCubit(this._getOrdersUseCase, this._authStorage) + : super(MyOrdersState()); + + void doIntent(MyOrdersIntent intent) { + switch (intent.runtimeType) { + case GetMyOrdersIntent: + _getOrders(intent as GetMyOrdersIntent); + break; + + case LoadMoreOrdersIntent: + _loadMore(); + break; + + case OpenOrderDetailsIntent: + emit( + state.copyWith( + selectedOrder: (intent as OpenOrderDetailsIntent).order, + ), + ); + break; + + case FilterCompletedOrdersIntent: + _filterCompleted(); + break; + + case FilterCancelledOrdersIntent: + _filterCancelled(); + break; + } + } + + Future _getOrders(GetMyOrdersIntent intent) async { + emit(state.copyWith(ordersResource: Resource.loading())); + + final token = await _authStorage.getToken(); + if (token == null || token.isEmpty) { + emit(state.copyWith(ordersResource: Resource.error("Token not found"))); + return; + } + _hasMore = true; + + final result = await _getOrdersUseCase.call( + token: 'Bearer $token', + page: intent.page, + limit: intent.limit, + ); + + if (isClosed) return; + switch (result) { + case SuccessApiResult(): + final data = result.data; + _hasMore = data.metadata != null && _page < data.metadata!.totalPages; + + emit( + state.copyWith( + orders: data.orders, + metadata: data.metadata, + ordersResource: Resource.success(data), + ), + ); + break; + + case ErrorApiResult(): + emit(state.copyWith(ordersResource: Resource.error(result.error))); + break; + } + } + + Future _loadMore() async { + if (!_hasMore || state.isLoadingMore) return; + + emit(state.copyWith(isLoadingMore: true)); + + final token = await _authStorage.getToken(); + if (token == null || token.isEmpty) { + emit(state.copyWith(isLoadingMore: false)); + return; + } + + _page++; + + final result = await _getOrdersUseCase.call( + token: 'Bearer $token', + page: _page, + ); + + if (isClosed) return; + + switch (result) { + case SuccessApiResult(): + emit( + state.copyWith( + orders: [...state.orders, ...result.data.orders], + metadata: result.data.metadata, + isLoadingMore: false, + ), + ); + break; + + case ErrorApiResult(): + emit(state.copyWith(isLoadingMore: false)); + break; + } + } + + void _filterCompleted() { + final filtered = state.orders.where((e) => e.isDelivered == true).toList(); + + emit(state.copyWith(orders: filtered)); + } + + void _filterCancelled() { + final filtered = state.orders.where((e) => e.state == 'cancelled').toList(); + emit(state.copyWith(orders: filtered)); + } +} diff --git a/lib/features/my_orders/presentation/manager/my_orders_intent.dart b/lib/features/my_orders/presentation/manager/my_orders_intent.dart new file mode 100644 index 0000000..ddcd989 --- /dev/null +++ b/lib/features/my_orders/presentation/manager/my_orders_intent.dart @@ -0,0 +1,22 @@ +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; + +sealed class MyOrdersIntent {} + +class GetMyOrdersIntent extends MyOrdersIntent { + final int page; + final int limit; + + GetMyOrdersIntent({this.page = 1, this.limit = 10}); +} + +class LoadMoreOrdersIntent extends MyOrdersIntent {} + +class OpenOrderDetailsIntent extends MyOrdersIntent { + final OrderEntity order; + + OpenOrderDetailsIntent(this.order); +} + +class FilterCompletedOrdersIntent extends MyOrdersIntent {} + +class FilterCancelledOrdersIntent extends MyOrdersIntent {} diff --git a/lib/features/my_orders/presentation/manager/my_orders_state.dart b/lib/features/my_orders/presentation/manager/my_orders_state.dart new file mode 100644 index 0000000..9401a4d --- /dev/null +++ b/lib/features/my_orders/presentation/manager/my_orders_state.dart @@ -0,0 +1,35 @@ +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/my_orders/domain/models/meta_data_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; + +class MyOrdersState { + final Resource ordersResource; + final List orders; + final MetadataEntity? metadata; + final OrderEntity? selectedOrder; + final bool isLoadingMore; + + MyOrdersState({ + Resource? ordersResource, + this.orders = const [], + this.metadata, + this.selectedOrder, + this.isLoadingMore = false, + }) : ordersResource = ordersResource ?? Resource.initial(); + + MyOrdersState copyWith({ + Resource? ordersResource, + List? orders, + MetadataEntity? metadata, + OrderEntity? selectedOrder, + bool? isLoadingMore, + }) { + return MyOrdersState( + ordersResource: ordersResource ?? this.ordersResource, + orders: orders ?? this.orders, + metadata: metadata ?? this.metadata, + selectedOrder: selectedOrder ?? this.selectedOrder, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + ); + } +} diff --git a/lib/features/my_orders/presentation/pages/my_orders_page.dart b/lib/features/my_orders/presentation/pages/my_orders_page.dart new file mode 100644 index 0000000..cf578e8 --- /dev/null +++ b/lib/features/my_orders/presentation/pages/my_orders_page.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_cubit.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_intent.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/my_orders_page_body.dart'; + +class MyOrdersPage extends StatelessWidget { + const MyOrdersPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + getIt() + ..doIntent(GetMyOrdersIntent(page: 1, limit: 10)), + child: Scaffold( + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: const Text( + "My orders", + style: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: false, + ), + backgroundColor: Colors.white, + body: const MyOrdersPageBody(), + ), + ); + } +} diff --git a/lib/features/my_orders/presentation/pages/order_details_page.dart b/lib/features/my_orders/presentation/pages/order_details_page.dart new file mode 100644 index 0000000..f9ea715 --- /dev/null +++ b/lib/features/my_orders/presentation/pages/order_details_page.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/address_title.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/order_item_tile.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/section_lable.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/summary_row.dart'; + +class OrderDetailsPage extends StatelessWidget { + final OrderEntity order; + + const OrderDetailsPage({super.key, required this.order}); + + @override + Widget build(BuildContext context) { + final isCancelled = order.state.toLowerCase() == 'cancelled'; + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black, size: 20), + onPressed: () => context.pop(), + ), + title: const Text( + "Order details", + style: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: false, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + isCancelled ? Icons.cancel : Icons.check_circle, + size: 20, + color: isCancelled ? AppColors.red : AppColors.green, + ), + const SizedBox(width: 8), + Text( + order.state, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isCancelled ? AppColors.red : AppColors.green, + ), + ), + ], + ), + Text( + "# ${order.orderNumber}", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.blackColor, + ), + ), + ], + ), + const SizedBox(height: 24), + const SectionLabel(label: "Pickup address"), + const SizedBox(height: 8), + AddressTile( + title: order.store?.name ?? "Unknown Store", + address: order.store?.address ?? "No Address Provided", + image: order.store?.image ?? "https://i.pravatar.cc/150?u=s1", + isStore: true, + ), + const SizedBox(height: 20), + const SectionLabel(label: "User address"), + const SizedBox(height: 8), + AddressTile( + title: "${order.user.firstName} ${order.user.lastName}", + address: order.address.isNotEmpty + ? order.address + : "No Address Provided", + image: order.user.photo, + isStore: false, + ), + const SizedBox(height: 24), + const SectionLabel(label: "Order details"), + const SizedBox(height: 12), + ...order.items.map((item) => OrderItemTile(item: item)), + const SizedBox(height: 12), + SummaryRow(label: "Total", value: "Egp ${order.totalPrice}"), + const SizedBox(height: 12), + SummaryRow(label: "Payment method", value: order.paymentType), + ], + ), + ), + ); + } +} diff --git a/lib/features/my_orders/presentation/widgets/address_title.dart b/lib/features/my_orders/presentation/widgets/address_title.dart new file mode 100644 index 0000000..fc249bc --- /dev/null +++ b/lib/features/my_orders/presentation/widgets/address_title.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class AddressTile extends StatelessWidget { + final String title; + final String address; + final String image; + final bool isStore; + + const AddressTile({ + super.key, + required this.title, + required this.address, + required this.image, + required this.isStore, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade100), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + image: NetworkImage(image), + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.blackColor, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon( + Icons.location_on_outlined, + size: 14, + color: AppColors.grey2, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + address, + style: const TextStyle( + fontSize: 12, + color: AppColors.grey2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/my_orders/presentation/widgets/my_orders_page_body.dart b/lib/features/my_orders/presentation/widgets/my_orders_page_body.dart new file mode 100644 index 0000000..f672487 --- /dev/null +++ b/lib/features/my_orders/presentation/widgets/my_orders_page_body.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/core/widgets/show_snak_bar.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_cubit.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_state.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/orders_filters_row.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/orders_list_view.dart'; + +class MyOrdersPageBody extends StatelessWidget { + const MyOrdersPageBody({super.key}); + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (prev, curr) => prev.ordersResource != curr.ordersResource, + listener: (context, state) { + if (state.ordersResource.isError == true) { + showAppSnackbar( + context, + state.ordersResource.error ?? "Failed to load orders", + ); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + const OrdersFiltersRow(), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + "Recent orders", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.blackColor, + ), + ), + ), + const SizedBox(height: 12), + const Expanded(child: OrdersListView()), + ], + ), + ); + } +} diff --git a/lib/features/my_orders/presentation/widgets/order_card.dart b/lib/features/my_orders/presentation/widgets/order_card.dart new file mode 100644 index 0000000..3c833bb --- /dev/null +++ b/lib/features/my_orders/presentation/widgets/order_card.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/address_title.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/section_lable.dart'; + +class OrderCard extends StatelessWidget { + final OrderEntity order; + final VoidCallback onTap; + + const OrderCard({super.key, required this.order, required this.onTap}); + + @override + Widget build(BuildContext context) { + final isCancelled = order.state.toLowerCase() == 'cancelled'; + + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF9F9F9), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Flower order", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.grey, + ), + ), + Row( + children: [ + Icon( + isCancelled ? Icons.cancel : Icons.check_circle, + size: 18, + color: isCancelled ? AppColors.red : AppColors.green, + ), + const SizedBox(width: 4), + Text( + order.state, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isCancelled ? AppColors.red : AppColors.green, + ), + ), + ], + ), + Text( + "# ${order.orderNumber}", + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.blackColor, + ), + ), + ], + ), + const SizedBox(height: 12), + SectionLabel(label: "Pickup address"), + const SizedBox(height: 8), + AddressTile( + title: order.store?.name ?? "Unknown Store", + address: order.store?.address ?? "No Address Provided", + image: + order.store?.image ?? + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT6-k6E9vG_c9B_I0m_K-7J1f8e6C9F5G1g5A&s", + isStore: true, + ), + const SizedBox(height: 12), + SectionLabel(label: "User address"), + const SizedBox(height: 8), + AddressTile( + title: "${order.user.firstName} ${order.user.lastName}", + address: order.address.isNotEmpty + ? order.address + : "No Address Provided", + image: order.user.photo, + isStore: false, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/my_orders/presentation/widgets/order_item_tile.dart b/lib/features/my_orders/presentation/widgets/order_item_tile.dart new file mode 100644 index 0000000..8448837 --- /dev/null +++ b/lib/features/my_orders/presentation/widgets/order_item_tile.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_item_entity.dart'; + +class OrderItemTile extends StatelessWidget { + final OrderItemEntity item; + + const OrderItemTile({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade100), + ), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(item.product.image), + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.product.title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.blackColor, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + "EGP ${item.price}", + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.blackColor, + ), + ), + ], + ), + ), + Text( + "X${item.quantity}", + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.red, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/my_orders/presentation/widgets/orders_filters_row.dart b/lib/features/my_orders/presentation/widgets/orders_filters_row.dart new file mode 100644 index 0000000..7b6a160 --- /dev/null +++ b/lib/features/my_orders/presentation/widgets/orders_filters_row.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_cubit.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_intent.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_state.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/summary_card.dart'; + +class OrdersFiltersRow extends StatelessWidget { + const OrdersFiltersRow({super.key}); + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + + return BlocBuilder( + builder: (context, state) { + final metadata = state.metadata; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: SummaryCard( + title: "Cancelled", + count: "${metadata?.cancelledCount ?? 0}", + color: AppColors.red, + icon: Icons.cancel_outlined, + onTap: () => cubit.doIntent(FilterCancelledOrdersIntent()), + ), + ), + const SizedBox(width: 16), + Expanded( + child: SummaryCard( + title: "Completed", + count: "${metadata?.completedCount ?? 0}", + color: AppColors.green, + icon: Icons.check_circle_outline, + onTap: () => cubit.doIntent(FilterCompletedOrdersIntent()), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/my_orders/presentation/widgets/orders_list_view.dart b/lib/features/my_orders/presentation/widgets/orders_list_view.dart new file mode 100644 index 0000000..e9e034d --- /dev/null +++ b/lib/features/my_orders/presentation/widgets/orders_list_view.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_cubit.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_intent.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_state.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/order_card.dart'; + +class OrdersListView extends StatelessWidget { + const OrdersListView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.ordersResource.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.orders.isEmpty) { + return const Center(child: Text("No orders found")); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: state.orders.length + (state.isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == state.orders.length) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ); + } + + final order = state.orders[index]; + + return OrderCard( + order: order, + onTap: () { + context.read().doIntent( + OpenOrderDetailsIntent(order), + ); + context.push(RouteNames.orderDetails, extra: order); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/my_orders/presentation/widgets/section_lable.dart b/lib/features/my_orders/presentation/widgets/section_lable.dart new file mode 100644 index 0000000..6805822 --- /dev/null +++ b/lib/features/my_orders/presentation/widgets/section_lable.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class SectionLabel extends StatelessWidget { + final String label; + + const SectionLabel({super.key, required this.label}); + + @override + Widget build(BuildContext context) { + return Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppColors.grey2, + fontWeight: FontWeight.w500, + ), + ); + } +} diff --git a/lib/features/my_orders/presentation/widgets/summary_card.dart b/lib/features/my_orders/presentation/widgets/summary_card.dart new file mode 100644 index 0000000..127d7fa --- /dev/null +++ b/lib/features/my_orders/presentation/widgets/summary_card.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class SummaryCard extends StatelessWidget { + final String title; + final String count; + final Color color; + final IconData icon; + final VoidCallback onTap; + + const SummaryCard({ + required this.title, + required this.count, + required this.color, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFFDF0F3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + count, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.blackColor, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + title, + style: TextStyle( + fontSize: 13, + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/my_orders/presentation/widgets/summary_row.dart b/lib/features/my_orders/presentation/widgets/summary_row.dart new file mode 100644 index 0000000..9c0d692 --- /dev/null +++ b/lib/features/my_orders/presentation/widgets/summary_row.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; + +class SummaryRow extends StatelessWidget { + final String label; + final String value; + + const SummaryRow({super.key, required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF9F9F9), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade100), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.blackColor, + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 14, + color: AppColors.grey2, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/profile/api/profile_lacal_datasource_imp.dart b/lib/features/profile/api/profile_lacal_datasource_imp.dart new file mode 100644 index 0000000..08154c2 --- /dev/null +++ b/lib/features/profile/api/profile_lacal_datasource_imp.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/features/profile/data/datasorce/profile_lacal_datasource.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; + +@LazySingleton(as: ProfileLocalDataSource) +class ProfileLocalDataSourceImpl implements ProfileLocalDataSource { + final AuthStorage storage; + + ProfileLocalDataSourceImpl(this.storage); + + @override + Future saveUser(DriverModel user) async { + await storage.saveUserJson(jsonEncode(user.toJson())); + } + + @override + Future getUser() async { + final json = await storage.getUserJson(); + if (json == null) return null; + return DriverModel.fromJson(jsonDecode(json)); + } +} diff --git a/lib/features/profile/api/profile_remote_datasource_imp.dart b/lib/features/profile/api/profile_remote_datasource_imp.dart new file mode 100644 index 0000000..87ccf5c --- /dev/null +++ b/lib/features/profile/api/profile_remote_datasource_imp.dart @@ -0,0 +1,41 @@ +import 'dart:io'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/api_manger/api_client.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/app/core/network/safe_api_call.dart'; +import 'package:tracking_app/features/profile/data/datasorce/profile_remote_datasource.dart'; +import 'package:tracking_app/features/profile/data/models/requests/edit_profile_request.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; + +@Injectable(as: ProfileRemoteDatasource) +class ProfileRemoteDatasourceImp extends ProfileRemoteDatasource { + final ApiClient apiClient; + ProfileRemoteDatasourceImp(this.apiClient); + + @override + Future> editProfile({ + required String token, + EditProfileRequest? request, + }) { + return safeApiCall( + call: () => apiClient.editProfile(token: token, request: request!), + ); + } + + @override + Future> uploadPhoto({ + required String token, + required File photo, + }) { + return safeApiCall( + call: () => apiClient.uploadPhoto(token: token, photo: photo), + ); + } + + @override + Future> getProfile({required String token}) { + return safeApiCall( + call: () => apiClient.getProfile(token: token), + ); + } +} diff --git a/lib/features/profile/data/datasorce/profile_lacal_datasource.dart b/lib/features/profile/data/datasorce/profile_lacal_datasource.dart new file mode 100644 index 0000000..eee316b --- /dev/null +++ b/lib/features/profile/data/datasorce/profile_lacal_datasource.dart @@ -0,0 +1,6 @@ +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; + +abstract class ProfileLocalDataSource { + Future saveUser(DriverModel user); + Future getUser(); +} diff --git a/lib/features/profile/data/datasorce/profile_remote_datasource.dart b/lib/features/profile/data/datasorce/profile_remote_datasource.dart new file mode 100644 index 0000000..7df383f --- /dev/null +++ b/lib/features/profile/data/datasorce/profile_remote_datasource.dart @@ -0,0 +1,18 @@ +import 'dart:io'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/profile/data/models/requests/edit_profile_request.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; + +abstract class ProfileRemoteDatasource { + Future> editProfile({ + required String token, + EditProfileRequest? request, + }); + + Future> getProfile({required String token}); + + Future> uploadPhoto({ + required String token, + required File photo, + }); +} diff --git a/lib/features/profile/data/models/driver_model.dart b/lib/features/profile/data/models/driver_model.dart new file mode 100644 index 0000000..b0ae28a --- /dev/null +++ b/lib/features/profile/data/models/driver_model.dart @@ -0,0 +1,83 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'driver_model.g.dart'; + +@JsonSerializable() +class DriverModel { + @JsonKey(name: "_id") + final String? Id; + @JsonKey(name: "country") + final String? country; + @JsonKey(name: "firstName") + final String? firstName; + @JsonKey(name: "lastName") + final String? lastName; + @JsonKey(name: "vehicleType") + final String? vehicleType; + @JsonKey(name: "vehicleNumber") + final String? vehicleNumber; + @JsonKey(name: "vehicleLicense") + final String? vehicleLicense; + @JsonKey(name: "NID") + final String? NID; + @JsonKey(name: "NIDImg") + final String? NIDImg; + @JsonKey(name: "email") + final String? email; + @JsonKey(name: "password") + final String? password; + @JsonKey(name: "gender") + final String? gender; + @JsonKey(name: "phone") + final String? phone; + @JsonKey(name: "photo") + final String? photo; + @JsonKey(name: "role") + final String? role; + @JsonKey(name: "createdAt") + final String? createdAt; + + DriverModel({ + this.Id, + this.country, + this.firstName, + this.lastName, + this.vehicleType, + this.vehicleNumber, + this.vehicleLicense, + this.NID, + this.NIDImg, + this.email, + this.password, + this.gender, + this.phone, + this.photo, + this.role, + this.createdAt, + }); + + factory DriverModel.fromJson(Map json) { + return _$DriverModelFromJson(json); + } + + Map toJson() { + return _$DriverModelToJson(this); + } + + static DriverModel fromEditProfileUser(DriverModel user) { + return DriverModel( + Id: user.Id, + country: user.country, + firstName: user.firstName, + lastName: user.lastName, + vehicleType: user.vehicleType, + vehicleNumber: user.vehicleNumber, + vehicleLicense: user.vehicleLicense, + NID: user.NID, + NIDImg: user.NIDImg, + email: user.email, + phone: user.phone, + password: null, + ); + } +} diff --git a/lib/features/profile/data/models/requests/edit_profile_request.dart b/lib/features/profile/data/models/requests/edit_profile_request.dart new file mode 100644 index 0000000..d25ec7f --- /dev/null +++ b/lib/features/profile/data/models/requests/edit_profile_request.dart @@ -0,0 +1,42 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'edit_profile_request.g.dart'; + +@JsonSerializable(includeIfNull: false) +class EditProfileRequest { + @JsonKey(name: "firstName") + final String? firstName; + + @JsonKey(name: "lastName") + final String? lastName; + + @JsonKey(name: "email") + final String? email; + + @JsonKey(name: "phone") + final String? phone; + + @JsonKey(name: "vehicleType") + final String? vehicleType; + + @JsonKey(name: "vehicleNumber") + final String? vehicleNumber; + + @JsonKey(name: "vehicleLicense") + final String? vehicleLicense; + + EditProfileRequest({ + this.firstName, + this.lastName, + this.email, + this.phone, + this.vehicleType, + this.vehicleNumber, + this.vehicleLicense, + }); + + factory EditProfileRequest.fromJson(Map json) => + _$EditProfileRequestFromJson(json); + + Map toJson() => _$EditProfileRequestToJson(this); +} diff --git a/lib/features/profile/data/models/requests/edit_profile_request.g.dart b/lib/features/profile/data/models/requests/edit_profile_request.g.dart new file mode 100644 index 0000000..b30edf7 --- /dev/null +++ b/lib/features/profile/data/models/requests/edit_profile_request.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'edit_profile_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +EditProfileRequest _$EditProfileRequestFromJson(Map json) => + EditProfileRequest( + firstName: json['firstName'] as String?, + lastName: json['lastName'] as String?, + email: json['email'] as String?, + phone: json['phone'] as String?, + vehicleType: json['vehicleType'] as String?, + vehicleNumber: json['vehicleNumber'] as String?, + vehicleLicense: json['vehicleLicense'] as String?, + ); + +Map _$EditProfileRequestToJson(EditProfileRequest instance) => + { + 'firstName': ?instance.firstName, + 'lastName': ?instance.lastName, + 'email': ?instance.email, + 'phone': ?instance.phone, + 'vehicleType': ?instance.vehicleType, + 'vehicleNumber': ?instance.vehicleNumber, + 'vehicleLicense': ?instance.vehicleLicense, + }; diff --git a/lib/features/profile/data/models/responses/edit_profile_response.dart b/lib/features/profile/data/models/responses/edit_profile_response.dart new file mode 100644 index 0000000..c2f6dbd --- /dev/null +++ b/lib/features/profile/data/models/responses/edit_profile_response.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; + +part 'edit_profile_response.g.dart'; + +@JsonSerializable() +class EditProfileResponse { + @JsonKey(name: "message") + final String? message; + @JsonKey(name: "driver") + final DriverModel? driver; + + EditProfileResponse({this.message, this.driver}); + + factory EditProfileResponse.fromJson(Map json) { + return _$EditProfileResponseFromJson(json); + } + + Map toJson() { + return _$EditProfileResponseToJson(this); + } +} diff --git a/lib/features/profile/data/models/responses/edit_profile_response.g.dart b/lib/features/profile/data/models/responses/edit_profile_response.g.dart new file mode 100644 index 0000000..aba1a56 --- /dev/null +++ b/lib/features/profile/data/models/responses/edit_profile_response.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'edit_profile_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +EditProfileResponse _$EditProfileResponseFromJson(Map json) => + EditProfileResponse( + message: json['message'] as String?, + driver: json['driver'] == null + ? null + : DriverModel.fromJson(json['driver'] as Map), + ); + +Map _$EditProfileResponseToJson( + EditProfileResponse instance, +) => {'message': instance.message, 'driver': instance.driver}; diff --git a/lib/features/profile/data/repo/profile_repo_imp.dart b/lib/features/profile/data/repo/profile_repo_imp.dart new file mode 100644 index 0000000..b98863f --- /dev/null +++ b/lib/features/profile/data/repo/profile_repo_imp.dart @@ -0,0 +1,113 @@ +import 'dart:io'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/profile/data/datasorce/profile_lacal_datasource.dart'; +import 'package:tracking_app/features/profile/data/datasorce/profile_remote_datasource.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/data/models/requests/edit_profile_request.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import 'package:tracking_app/features/profile/domain/repo/profile_repo.dart'; + +@Injectable(as: ProfileRepo) +class ProfileRepoImpl implements ProfileRepo { + final ProfileRemoteDatasource profileDatasource; + final ProfileLocalDataSource localDataSource; + + ProfileRepoImpl(this.profileDatasource, this.localDataSource); + + @override + Future> getProfile({ + required String token, + }) async { + try { + // final localUser = await localDataSource.getUser(); + + // if (localUser != null) { + // return SuccessApiResult( + // data: EditProfileResponse.fromJson(localUser.toJson()), + // ); + // } + final result = await profileDatasource.getProfile(token: token); + + if (result is SuccessApiResult) { + final driver = DriverModel.fromJson(result.data.toJson()); + await localDataSource.saveUser(driver); + + return SuccessApiResult(data: result.data); + } else if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } else { + return ErrorApiResult(error: 'Unknown error'); + } + } catch (e) { + return ErrorApiResult(error: e.toString()); + } + } + + @override + Future> editProfile({ + required String token, + String? firstName, + String? lastName, + String? email, + String? phone, + String? vehicleType, + String? vehicleNumber, + String? vehicleLicense, + }) async { + try { + final result = await profileDatasource.editProfile( + token: token, + request: EditProfileRequest( + firstName: firstName, + lastName: lastName, + email: email, + phone: phone, + vehicleType: vehicleType, + vehicleNumber: vehicleNumber, + vehicleLicense: vehicleLicense, + ), + ); + + if (result is SuccessApiResult) { + final driver = DriverModel.fromJson(result.data.toJson()); + await localDataSource.saveUser(driver); + + return SuccessApiResult(data: result.data); + } else if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } else { + return ErrorApiResult(error: 'Unknown error'); + } + } catch (e) { + return ErrorApiResult(error: e.toString()); + } + } + + @override + Future> uploadPhoto({ + required String token, + required File photo, + }) async { + try { + final result = await profileDatasource.uploadPhoto( + token: token, + photo: photo, + ); + + if (result is SuccessApiResult) { + final driver = DriverModel.fromJson(result.data.toJson()); + + await localDataSource.saveUser(driver); + + return SuccessApiResult(data: result.data); + } else if (result is ErrorApiResult) { + return ErrorApiResult(error: result.error); + } else { + return ErrorApiResult(error: 'Unknown error'); + } + } catch (e) { + return ErrorApiResult(error: e.toString()); + } + } +} diff --git a/lib/features/profile/domain/repo/profile_repo.dart b/lib/features/profile/domain/repo/profile_repo.dart new file mode 100644 index 0000000..98183a3 --- /dev/null +++ b/lib/features/profile/domain/repo/profile_repo.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; + +abstract class ProfileRepo { + Future> editProfile({ + required String token, + String? firstName, + String? lastName, + String? email, + String? phone, + String? vehicleType, + String? vehicleNumber, + String? vehicleLicense, + }); + + Future> uploadPhoto({ + required String token, + required File photo, + }); + + Future> getProfile({required String token}); +} diff --git a/lib/features/profile/domain/usecases/edit_profile_usecase.dart b/lib/features/profile/domain/usecases/edit_profile_usecase.dart new file mode 100644 index 0000000..0819144 --- /dev/null +++ b/lib/features/profile/domain/usecases/edit_profile_usecase.dart @@ -0,0 +1,33 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import 'package:tracking_app/features/profile/domain/repo/profile_repo.dart'; + +@injectable +class EditProfileUseCase { + final ProfileRepo repository; + + EditProfileUseCase(this.repository); + + Future> call({ + required String token, + String? firstName, + String? lastName, + String? email, + String? phone, + String? vehicleType, + String? vehicleNumber, + String? vehicleLicense, + }) async { + return await repository.editProfile( + token: token, + firstName: firstName, + lastName: lastName, + email: email, + phone: phone, + vehicleType: vehicleType, + vehicleNumber: vehicleNumber, + vehicleLicense: vehicleLicense, + ); + } +} diff --git a/lib/features/profile/domain/usecases/get_profile_usecase.dart b/lib/features/profile/domain/usecases/get_profile_usecase.dart new file mode 100644 index 0000000..6ac8df0 --- /dev/null +++ b/lib/features/profile/domain/usecases/get_profile_usecase.dart @@ -0,0 +1,14 @@ +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import 'package:tracking_app/features/profile/domain/repo/profile_repo.dart'; + +@injectable +class GetProfileUsecase { + final ProfileRepo repository; + GetProfileUsecase(this.repository); + + Future> call({required String token}) async { + return await repository.getProfile(token: token); + } +} diff --git a/lib/features/profile/domain/usecases/upload_profile_photo_usecase.dart b/lib/features/profile/domain/usecases/upload_profile_photo_usecase.dart new file mode 100644 index 0000000..79ef804 --- /dev/null +++ b/lib/features/profile/domain/usecases/upload_profile_photo_usecase.dart @@ -0,0 +1,19 @@ +import 'dart:io'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import 'package:tracking_app/features/profile/domain/repo/profile_repo.dart'; + +@injectable +class UploadProfilePhotoUseCase { + final ProfileRepo repository; + + UploadProfilePhotoUseCase(this.repository); + + Future> call({ + required String token, + required File photo, + }) async { + return await repository.uploadPhoto(token: token, photo: photo); + } +} diff --git a/lib/features/profile/presentation/managers/profile_cubit.dart b/lib/features/profile/presentation/managers/profile_cubit.dart new file mode 100644 index 0000000..e9da0a9 --- /dev/null +++ b/lib/features/profile/presentation/managers/profile_cubit.dart @@ -0,0 +1,216 @@ +import 'dart:convert'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import 'package:tracking_app/features/profile/domain/usecases/edit_profile_usecase.dart'; +import 'package:tracking_app/features/profile/domain/usecases/upload_profile_photo_usecase.dart'; +import 'package:tracking_app/features/profile/domain/usecases/get_profile_usecase.dart'; +import 'profile_intent.dart'; +import 'profile_state.dart'; + +@injectable +class ProfileCubit extends Cubit { + final EditProfileUseCase _editProfileUseCase; + final UploadProfilePhotoUseCase _uploadPhotoUseCase; + final GetProfileUsecase _getProfileUsecase; + final AuthStorage _authStorage; + + ProfileCubit( + this._editProfileUseCase, + this._uploadPhotoUseCase, + this._getProfileUsecase, + this._authStorage, + ) : super(ProfileState()) { + _initialize(); + } + + Future _initialize() async { + await _loadUserFromLocal(); + await _getProfile(); + } + + Future _loadUserFromLocal() async { + final userJson = await _authStorage.getUserJson(); + + if (userJson != null) { + final driver = DriverModel.fromJson(jsonDecode(userJson)); + emit(state.copyWith(driver: driver)); + } + } + + void doIntent(ProfileIntent intent) { + switch (intent.runtimeType) { + case GetProfileIntent: + _getProfile(); + break; + case PerformEditProfile: + _editProfile(intent as PerformEditProfile); + break; + case SelectPhotoIntent: + _selectPhoto(intent as SelectPhotoIntent); + break; + case UploadSelectedPhotoIntent: + _uploadPhoto(); + break; + } + } + + Future _getProfile() async { + emit(state.copyWith(getProfileResource: Resource.loading())); + + final token = await _authStorage.getToken(); + + if (token == null || token.isEmpty) { + emit( + state.copyWith(getProfileResource: Resource.error("Token not found")), + ); + return; + } + + final result = await _getProfileUsecase.call(token: 'Bearer $token'); + + if (isClosed) return; + + switch (result) { + case SuccessApiResult(): + final user = result.data.driver; + + if (user != null) { + final driverModel = DriverModel.fromEditProfileUser(user); + + await _authStorage.saveUserJson(jsonEncode(driverModel.toJson())); + + emit( + state.copyWith( + driver: driverModel, + getProfileResource: Resource.success(result.data), + ), + ); + } + break; + + case ErrorApiResult(): + emit(state.copyWith(getProfileResource: Resource.error(result.error))); + break; + } + } + + Future _editProfile(PerformEditProfile intent) async { + emit(state.copyWith(editProfileResource: Resource.loading())); + + final token = await _authStorage.getToken(); + + if (token == null || token.isEmpty) { + emit( + state.copyWith(editProfileResource: Resource.error("Token not found")), + ); + return; + } + + if (intent.photo != null) { + final uploadResult = await _uploadPhotoUseCase.call( + token: 'Bearer $token', + photo: intent.photo!, + ); + + if (uploadResult is ErrorApiResult) { + emit( + state.copyWith( + editProfileResource: Resource.error(uploadResult.error), + ), + ); + return; + } + } + final result = await _editProfileUseCase.call( + token: 'Bearer $token', + firstName: intent.firstName, + lastName: intent.lastName, + email: intent.email, + phone: intent.phone, + vehicleType: intent.vehicleType, + vehicleNumber: intent.vehicleNumber, + vehicleLicense: intent.vehicleLicense, + ); + + if (isClosed) return; + + switch (result) { + case SuccessApiResult(): + final updatedUser = result.data.driver; + + if (updatedUser != null) { + final driverModel = DriverModel.fromEditProfileUser(updatedUser); + + await _authStorage.saveUserJson(jsonEncode(driverModel.toJson())); + + emit( + state.copyWith( + driver: driverModel, + editProfileResource: Resource.success(result.data), + clearSelectedPhoto: true, + ), + ); + } + break; + + case ErrorApiResult(): + emit(state.copyWith(editProfileResource: Resource.error(result.error))); + break; + } + } + + void _selectPhoto(SelectPhotoIntent intent) { + emit(state.copyWith(selectedPhoto: intent.photo)); + } + + Future _uploadPhoto() async { + if (state.selectedPhoto == null) return; + + emit(state.copyWith(uploadPhotoResource: Resource.loading())); + + final token = await _authStorage.getToken(); + + if (token == null || token.isEmpty) { + emit( + state.copyWith(uploadPhotoResource: Resource.error("Token not found")), + ); + return; + } + + final result = await _uploadPhotoUseCase.call( + token: 'Bearer $token', + photo: state.selectedPhoto!, + ); + + if (isClosed) return; + + switch (result) { + case SuccessApiResult(): + final updatedUser = result.data.driver; + + if (updatedUser != null) { + final driverModel = DriverModel.fromEditProfileUser(updatedUser); + + await _authStorage.saveUserJson(jsonEncode(driverModel.toJson())); + + emit( + state.copyWith( + driver: driverModel, + clearSelectedPhoto: true, + uploadPhotoResource: Resource.success(result.data), + ), + ); + } + break; + + case ErrorApiResult(): + emit(state.copyWith(uploadPhotoResource: Resource.error(result.error))); + break; + } + } +} diff --git a/lib/features/profile/presentation/managers/profile_intent.dart b/lib/features/profile/presentation/managers/profile_intent.dart new file mode 100644 index 0000000..1604c93 --- /dev/null +++ b/lib/features/profile/presentation/managers/profile_intent.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +sealed class ProfileIntent {} + +class GetProfileIntent extends ProfileIntent {} + +class PerformEditProfile extends ProfileIntent { + final String? firstName; + final String? lastName; + final String? email; + final String? phone; + final String? vehicleType; + final String? vehicleNumber; + final String? vehicleLicense; + final File? photo; + + PerformEditProfile({ + this.firstName, + this.lastName, + this.email, + this.phone, + this.vehicleType, + this.vehicleNumber, + this.vehicleLicense, + this.photo, + }); +} + +class SelectPhotoIntent extends ProfileIntent { + final File photo; + SelectPhotoIntent(this.photo); +} + +class UploadSelectedPhotoIntent extends ProfileIntent {} + +class SelectVehicleLicenseIntent extends ProfileIntent { + final File file; + SelectVehicleLicenseIntent(this.file); +} + +class UploadVehicleLicenseIntent extends ProfileIntent {} diff --git a/lib/features/profile/presentation/managers/profile_state.dart b/lib/features/profile/presentation/managers/profile_state.dart new file mode 100644 index 0000000..a9ed624 --- /dev/null +++ b/lib/features/profile/presentation/managers/profile_state.dart @@ -0,0 +1,48 @@ +import 'dart:io'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; + +class ProfileState { + final Resource getProfileResource; + final Resource editProfileResource; + final Resource uploadPhotoResource; + final File? selectedPhoto; + final File? selectedVehicleLicense; + final DriverModel? driver; + + ProfileState({ + Resource? getProfileResource, + Resource? editProfileResource, + Resource? uploadPhotoResource, + this.selectedPhoto, + this.selectedVehicleLicense, + this.driver, + }) : getProfileResource = getProfileResource ?? Resource.initial(), + editProfileResource = editProfileResource ?? Resource.initial(), + uploadPhotoResource = uploadPhotoResource ?? Resource.initial(); + + ProfileState copyWith({ + Resource? getProfileResource, + Resource? editProfileResource, + Resource? uploadPhotoResource, + File? selectedPhoto, + File? selectedVehicleLicense, + bool clearSelectedPhoto = false, + bool clearVehicleLicense = false, + DriverModel? driver, + }) { + return ProfileState( + getProfileResource: getProfileResource ?? this.getProfileResource, + editProfileResource: editProfileResource ?? this.editProfileResource, + uploadPhotoResource: uploadPhotoResource ?? this.uploadPhotoResource, + selectedPhoto: clearSelectedPhoto + ? null + : (selectedPhoto ?? this.selectedPhoto), + selectedVehicleLicense: clearVehicleLicense + ? null + : (selectedVehicleLicense ?? this.selectedVehicleLicense), + driver: driver ?? this.driver, + ); + } +} diff --git a/lib/features/profile/presentation/pages/edit_driver_profile_page.dart b/lib/features/profile/presentation/pages/edit_driver_profile_page.dart new file mode 100644 index 0000000..53b4fb6 --- /dev/null +++ b/lib/features/profile/presentation/pages/edit_driver_profile_page.dart @@ -0,0 +1,33 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/widgets/edit_driver_profile_page_body.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +class EditDriverProfilePage extends StatelessWidget { + final DriverModel? driver; + const EditDriverProfilePage({super.key, this.driver}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => getIt(), + child: Scaffold( + appBar: AppBar( + title: Text( + LocaleKeys.editDriverProfile.tr(), + style: TextStyle(color: Colors.black), + ), + backgroundColor: Colors.white, + elevation: 0, + leading: const BackButton(color: Colors.black), + ), + backgroundColor: Colors.white, + body: EditDriverProfilePageBody(user: driver), + ), + ); + } +} diff --git a/lib/features/profile/presentation/pages/edit_vehicle_page.dart b/lib/features/profile/presentation/pages/edit_vehicle_page.dart new file mode 100644 index 0000000..ecb59bf --- /dev/null +++ b/lib/features/profile/presentation/pages/edit_vehicle_page.dart @@ -0,0 +1,33 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/widgets/edit_vehicle_page_body.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +class EditVehiclePage extends StatelessWidget { + final DriverModel? driver; + const EditVehiclePage({super.key, this.driver}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => getIt(), + child: Scaffold( + appBar: AppBar( + title: Text( + LocaleKeys.editVehicle.tr(), + style: TextStyle(color: Colors.black), + ), + backgroundColor: Colors.white, + elevation: 0, + leading: const BackButton(color: Colors.black), + ), + backgroundColor: Colors.white, + body: EditVehiclePageBody(driver: driver), + ), + ); + } +} diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart new file mode 100644 index 0000000..f47f17c --- /dev/null +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/app/core/widgets/custom_app_bar.dart'; +import 'package:tracking_app/features/auth/presentation/logout/manager/logout_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_intent.dart'; +import 'package:tracking_app/features/profile/presentation/widgets/notification_with_badge_widget.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; +import '../widgets/profile_page_body.dart'; + +class ProfilePage extends StatelessWidget { + const ProfilePage({super.key}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => + getIt()..doIntent(GetProfileIntent()), + ), + BlocProvider(create: (_) => getIt()), + ], + child: SafeArea( + child: Scaffold( + appBar: CustomAppBar( + title: LocaleKeys.profile, + actions: const [NotificationWithBadgeWidget()], + ), + body: const ProfilePageBody(), + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/edit_driver_profile_form.dart b/lib/features/profile/presentation/widgets/edit_driver_profile_form.dart new file mode 100644 index 0000000..4c9be41 --- /dev/null +++ b/lib/features/profile/presentation/widgets/edit_driver_profile_form.dart @@ -0,0 +1,306 @@ +import 'dart:io'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_intent.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_state.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; +import 'profile_image_section.dart'; + +class EditDriverProfileForm extends StatefulWidget { + final String firstName; + final String lastName; + final String email; + final String phone; + final String? photo; + + const EditDriverProfileForm({ + super.key, + required this.firstName, + required this.lastName, + required this.email, + required this.phone, + this.photo, + }); + + @override + State createState() => _EditDriverProfileFormState(); +} + +class _EditDriverProfileFormState extends State { + late final TextEditingController firstNameController; + late final TextEditingController lastNameController; + late final TextEditingController emailController; + late final TextEditingController phoneController; + + final authStorage = getIt(); + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + firstNameController = TextEditingController(text: widget.firstName); + lastNameController = TextEditingController(text: widget.lastName); + emailController = TextEditingController(text: widget.email); + phoneController = TextEditingController(text: widget.phone); + } + + @override + void dispose() { + firstNameController.dispose(); + lastNameController.dispose(); + emailController.dispose(); + phoneController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + + return BlocListener( + listener: (context, state) { + if (state.driver != null) { + if (state.driver!.firstName != null && + firstNameController.text != state.driver!.firstName) { + firstNameController.text = state.driver!.firstName!; + } + if (state.driver!.lastName != null && + lastNameController.text != state.driver!.lastName) { + lastNameController.text = state.driver!.lastName!; + } + if (state.driver!.email != null && + emailController.text != state.driver!.email) { + emailController.text = state.driver!.email!; + } + if (state.driver!.phone != null && state.driver!.phone!.isNotEmpty) { + if (phoneController.text != state.driver!.phone) { + phoneController.text = state.driver!.phone!; + } + } + } + }, + child: BlocBuilder( + builder: (context, state) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + children: [ + ProfileImageSection(), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: TextFormField( + controller: firstNameController, + decoration: InputDecoration( + labelText: LocaleKeys.firstName.tr(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: lastNameController, + decoration: InputDecoration( + labelText: LocaleKeys.lastName.tr(), + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + TextFormField( + controller: emailController, + decoration: InputDecoration( + labelText: LocaleKeys.email.tr(), + ), + ), + + const SizedBox(height: 16), + + TextFormField( + controller: phoneController, + decoration: InputDecoration( + labelText: LocaleKeys.phone.tr(), + ), + ), + + const SizedBox(height: 16), + + TextFormField( + readOnly: true, + decoration: InputDecoration( + labelText: LocaleKeys.password.tr(), + hintText: '.......................', + suffix: GestureDetector( + onTap: () { + context.push(RouteNames.changePassword); + }, + child: Text( + LocaleKeys.change.tr(), + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + obscureText: true, + ), + + const SizedBox(height: 32), + + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: state.editProfileResource.isLoading == true + ? null + : () async { + final token = await authStorage.getToken(); + if (token == null) return; + + cubit.doIntent( + PerformEditProfile( + firstName: firstNameController.text.trim(), + lastName: lastNameController.text.trim(), + email: emailController.text.trim(), + phone: phoneController.text.trim(), + photo: state.selectedPhoto?.path != null + ? File(state.selectedPhoto!.path) + : null, + ), + ); + }, + child: Text( + state.editProfileResource.isLoading == true + ? LocaleKeys.loading.tr() + : LocaleKeys.update.tr(), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + + // final state = context.watch().state; + + // return SingleChildScrollView( + // padding: const EdgeInsets.all(16), + // child: Form( + // key: _formKey, + // child: Column( + // children: [ + // ProfileImageSection(), + // const SizedBox(height: 32), + // Row( + // children: [ + // Expanded( + // child: TextFormField( + // controller: firstNameController, + // decoration: InputDecoration( + // labelText: LocaleKeys.firstName.tr(), + // ), + // ), + // ), + // const SizedBox(width: 12), + // Expanded( + // child: TextFormField( + // controller: lastNameController, + // decoration: InputDecoration( + // labelText: LocaleKeys.lastName.tr(), + // ), + // ), + // ), + // ], + // ), + + // const SizedBox(height: 16), + + // TextFormField( + // controller: emailController, + // decoration: InputDecoration(labelText: LocaleKeys.email.tr()), + // ), + + // const SizedBox(height: 16), + + // TextFormField( + // controller: phoneController, + // decoration: InputDecoration(labelText: LocaleKeys.phone.tr()), + // ), + + // const SizedBox(height: 16), + + // TextFormField( + // readOnly: true, + // decoration: InputDecoration( + // labelText: LocaleKeys.password.tr(), + // hintText: '.......................', + // suffix: GestureDetector( + // onTap: () { + // context.push(RouteNames.changePassword); + // }, + // child: Text( + // LocaleKeys.change.tr(), + // style: TextStyle( + // color: Theme.of(context).primaryColor, + // fontWeight: FontWeight.w600, + // ), + // ), + // ), + // ), + // obscureText: true, + // ), + + // const SizedBox(height: 32), + + // SizedBox( + // width: double.infinity, + // height: 52, + // child: ElevatedButton( + // onPressed: state.editProfileResource.isLoading == true + // ? null + // : () async { + // final token = await authStorage.getToken(); + // if (token == null) return; + + // if (state.selectedPhoto != null) { + // cubit.doIntent(UploadSelectedPhotoIntent()); + // } + + // cubit.doIntent( + // PerformEditProfile( + // firstName: firstNameController.text.trim(), + // lastName: lastNameController.text.trim(), + // email: emailController.text.trim(), + // phone: phoneController.text.trim(), + // ), + // ); + // }, + // child: Text( + // state.editProfileResource.isLoading == true + // ? LocaleKeys.loading.tr() + // : LocaleKeys.update.tr(), + // ), + // ), + // ), + // ], + // ), + // ), + // ); + } +} diff --git a/lib/features/profile/presentation/widgets/edit_driver_profile_page_body.dart b/lib/features/profile/presentation/widgets/edit_driver_profile_page_body.dart new file mode 100644 index 0000000..5ea55b3 --- /dev/null +++ b/lib/features/profile/presentation/widgets/edit_driver_profile_page_body.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/core/widgets/show_snak_bar.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_state.dart'; +import 'edit_driver_profile_form.dart'; + +class EditDriverProfilePageBody extends StatelessWidget { + final DriverModel? user; + + const EditDriverProfilePageBody({super.key, this.user}); + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (prev, curr) => + prev.editProfileResource != curr.editProfileResource || + prev.uploadPhotoResource != curr.uploadPhotoResource, + listener: (context, state) { + if (state.editProfileResource.isSuccess == true) { + showAppSnackbar(context, "Profile updated successfully"); + } else if (state.editProfileResource.isError == true) { + showAppSnackbar( + context, + state.editProfileResource.error ?? "Edit profile failed", + ); + } + + if (state.uploadPhotoResource.isSuccess == true) { + showAppSnackbar(context, "Photo uploaded successfully"); + } else if (state.uploadPhotoResource.isError == true) { + showAppSnackbar( + context, + state.uploadPhotoResource.error ?? "Upload photo failed", + ); + } + }, + child: EditDriverProfileForm( + firstName: user?.firstName ?? '', + lastName: user?.lastName ?? '', + email: user?.email ?? '', + phone: user?.phone ?? '', + photo: user?.photo, + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/edit_vehicle_form.dart b/lib/features/profile/presentation/widgets/edit_vehicle_form.dart new file mode 100644 index 0000000..69209f4 --- /dev/null +++ b/lib/features/profile/presentation/widgets/edit_vehicle_form.dart @@ -0,0 +1,160 @@ +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_intent.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_state.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +class EditVehicleForm extends StatefulWidget { + final String vehicleType; + final String vehicleNumber; + final String vehicleLicense; + + const EditVehicleForm({ + super.key, + required this.vehicleType, + required this.vehicleNumber, + required this.vehicleLicense, + }); + + @override + State createState() => _EditVehicleFormState(); +} + +class _EditVehicleFormState extends State { + late final TextEditingController vehicleTypeController; + late final TextEditingController vehicleNumberController; + late final TextEditingController vehicleLicenseController; + + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + vehicleTypeController = TextEditingController(text: widget.vehicleType); + vehicleNumberController = TextEditingController(text: widget.vehicleNumber); + vehicleLicenseController = TextEditingController( + text: widget.vehicleLicense, + ); + } + + @override + void dispose() { + vehicleTypeController.dispose(); + vehicleNumberController.dispose(); + vehicleLicenseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + // final state = context.watch().state; + + return BlocListener( + listener: (context, state) { + if (state.driver != null) { + if (state.driver!.vehicleType != null && + vehicleTypeController.text != state.driver!.vehicleType) { + vehicleTypeController.text = state.driver!.vehicleType!; + } + if (state.driver!.vehicleNumber != null && + vehicleNumberController.text != state.driver!.vehicleNumber) { + vehicleNumberController.text = state.driver!.vehicleNumber!; + } + if (state.driver!.vehicleLicense != null && + vehicleLicenseController.text != state.driver!.vehicleLicense) { + vehicleLicenseController.text = state.driver!.vehicleLicense!; + } + } + }, + child: BlocBuilder( + builder: (context, state) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: vehicleTypeController, + decoration: InputDecoration( + labelText: LocaleKeys.vehicle_type.tr(), + ), + ), + + const SizedBox(height: 16), + + TextFormField( + controller: vehicleNumberController, + decoration: InputDecoration( + labelText: LocaleKeys.vehicle_number.tr(), + ), + ), + + const SizedBox(height: 16), + + TextFormField( + controller: vehicleLicenseController, + readOnly: true, + onTap: () async { + final picked = await ImagePicker().pickImage( + source: ImageSource.gallery, + ); + + if (picked != null) { + final file = File(picked.path); + + cubit.doIntent(SelectVehicleLicenseIntent(file)); + + vehicleLicenseController.text = picked.name; + + cubit.doIntent(UploadVehicleLicenseIntent()); + } + }, + decoration: InputDecoration( + labelText: LocaleKeys.vehicle_license.tr(), + suffixIcon: Icon(Icons.upload), + ), + ), + + const SizedBox(height: 32), + + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: state.editProfileResource.isLoading == true + ? null + : () { + cubit.doIntent( + PerformEditProfile( + vehicleType: vehicleTypeController.text + .trim(), + vehicleNumber: vehicleNumberController.text + .trim(), + vehicleLicense: vehicleLicenseController.text + .trim(), + ), + ); + }, + child: Text( + state.editProfileResource.isLoading == true + ? LocaleKeys.loading.tr() + : LocaleKeys.update.tr(), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/edit_vehicle_page_body.dart b/lib/features/profile/presentation/widgets/edit_vehicle_page_body.dart new file mode 100644 index 0000000..3a75c16 --- /dev/null +++ b/lib/features/profile/presentation/widgets/edit_vehicle_page_body.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tracking_app/app/core/widgets/show_snak_bar.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_state.dart'; +import 'edit_vehicle_form.dart'; + +class EditVehiclePageBody extends StatelessWidget { + final DriverModel? driver; + + const EditVehiclePageBody({super.key, this.driver}); + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (prev, curr) => + prev.editProfileResource != curr.editProfileResource, + listener: (context, state) { + if (state.editProfileResource.isSuccess == true) { + showAppSnackbar(context, "Vehicle updated successfully"); + } else if (state.editProfileResource.isError == true) { + showAppSnackbar( + context, + state.editProfileResource.error ?? "Update failed", + ); + } + }, + child: EditVehicleForm( + vehicleType: driver?.vehicleType ?? '', + vehicleNumber: driver?.vehicleNumber ?? '', + vehicleLicense: driver?.vehicleLicense ?? '', + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/info_card.dart b/lib/features/profile/presentation/widgets/info_card.dart new file mode 100644 index 0000000..4da8613 --- /dev/null +++ b/lib/features/profile/presentation/widgets/info_card.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class InfoCard extends StatelessWidget { + final Widget? child; + const InfoCard({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.white10, + shape: RoundedRectangleBorder( + side: BorderSide(color: Colors.grey, width: 1.0), + borderRadius: BorderRadius.circular(8.0), + ), + child: SizedBox( + width: double.infinity, + height: 100, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 5), + child: child, + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/language_bottom_sheet.dart b/lib/features/profile/presentation/widgets/language_bottom_sheet.dart new file mode 100644 index 0000000..6bc92b0 --- /dev/null +++ b/lib/features/profile/presentation/widgets/language_bottom_sheet.dart @@ -0,0 +1,65 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../../../app/core/ui_helper/color/colors.dart'; +import '../../../../app/core/ui_helper/style/font_style.dart'; +import '../../../../generated/locale_keys.g.dart'; +import 'language_tile.dart'; + +class LanguageBottomSheet extends StatelessWidget { + const LanguageBottomSheet({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 44, + height: 5, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(99), + ), + ), + ), + const SizedBox(height: 16), + Text( + LocaleKeys.change_language.tr(), + style: AppStyles.black14Medium.copyWith( + color: AppColors.pink, + fontSize: 18, + ), + ), + const SizedBox(height: 16), + LanguageTile( + title: LocaleKeys.arabic.tr(), + value: const Locale('ar'), + groupValue: context.locale, + onChanged: (loc) async { + await context.setLocale(loc); + if (context.mounted) Navigator.pop(context); + }, + ), + const SizedBox(height: 12), + LanguageTile( + title: LocaleKeys.english.tr(), + value: const Locale('en'), + groupValue: context.locale, + onChanged: (loc) async { + await context.setLocale(loc); + if (context.mounted) Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/language_tile.dart b/lib/features/profile/presentation/widgets/language_tile.dart new file mode 100644 index 0000000..d2a0086 --- /dev/null +++ b/lib/features/profile/presentation/widgets/language_tile.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/features/profile/presentation/widgets/radio_circle.dart'; +import '../../../../app/core/ui_helper/color/colors.dart'; +import '../../../../app/core/ui_helper/style/font_style.dart'; + +class LanguageTile extends StatelessWidget { + final String title; + final Locale value; + final Locale groupValue; + final ValueChanged onChanged; + + const LanguageTile({ + super.key, + required this.title, + required this.value, + required this.groupValue, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final selected = value == groupValue; + + return InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => onChanged(value), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? AppColors.pink : Colors.grey.shade200, + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: selected + ? AppStyles.black14bold.copyWith(color: AppColors.pink) + : AppStyles.black14Medium, + ), + ), + RadioCircle(selected: selected), + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/notification_with_badge_widget.dart b/lib/features/profile/presentation/widgets/notification_with_badge_widget.dart new file mode 100644 index 0000000..34d3f1c --- /dev/null +++ b/lib/features/profile/presentation/widgets/notification_with_badge_widget.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class NotificationWithBadgeWidget extends StatelessWidget { + const NotificationWithBadgeWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + IconButton(icon: const Icon(Icons.notifications), onPressed: () {}), + Positioned( + right: 8, + top: 8, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints(minWidth: 16, minHeight: 16), + child: const Text( + '3', + style: TextStyle(color: Colors.white, fontSize: 10), + textAlign: TextAlign.center, + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/profile/presentation/widgets/profile_avatar.dart b/lib/features/profile/presentation/widgets/profile_avatar.dart new file mode 100644 index 0000000..a5ae874 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_avatar.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +class ProfileAvatar extends StatelessWidget { + final String? imageUrl; + final String userName; + + const ProfileAvatar({super.key, this.imageUrl, required this.userName}); + + String getInitials(String name) { + if (name.isEmpty) return ''; + final parts = name.trim().split(RegExp(r'\s+')); + if (parts.isEmpty || parts[0].isEmpty) return ''; + if (parts.length == 1) return parts[0][0]; + return parts[0][0] + parts[1][0]; + } + + Color getRandomBackgroundColor(String name) { + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.red, + Colors.teal, + Colors.brown, + ]; + final index = name.hashCode % colors.length; + return colors[index]; + } + + @override + Widget build(BuildContext context) { + return CircleAvatar( + radius: 30, + backgroundColor: imageUrl == null + ? getRandomBackgroundColor(userName) + : null, + backgroundImage: imageUrl != null ? NetworkImage(imageUrl!) : null, + child: imageUrl == null + ? Text( + getInitials(userName).toUpperCase(), + style: TextStyle( + fontSize: 50 / 2, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ) + : null, + ); + } +} diff --git a/lib/features/profile/presentation/widgets/profile_image_section.dart b/lib/features/profile/presentation/widgets/profile_image_section.dart new file mode 100644 index 0000000..89b31e3 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_image_section.dart @@ -0,0 +1,60 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_intent.dart'; + +class ProfileImageSection extends StatelessWidget { + const ProfileImageSection({super.key}); + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + final state = context.watch().state; + + ImageProvider? image; + if (state.selectedPhoto != null) { + image = kIsWeb + ? NetworkImage(state.selectedPhoto!.path) + : FileImage(File(state.selectedPhoto!.path)); + } + + return Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + CircleAvatar( + radius: 50, + backgroundColor: Colors.grey.shade200, + backgroundImage: image, + child: image == null + ? const Icon(Icons.person, size: 50, color: Colors.grey) + : null, + ), + if (state.uploadPhotoResource.isLoading == true) + const CircularProgressIndicator(color: AppColors.pink), + ], + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () async { + final picker = ImagePicker(); + final file = await picker.pickImage(source: ImageSource.gallery); + if (file != null) { + cubit.doIntent(SelectPhotoIntent(File(file.path))); + } + }, + icon: const Icon(Icons.camera_alt, color: AppColors.pink), + label: const Text( + "Change Photo", + style: TextStyle(color: AppColors.pink), + ), + ), + ], + ); + } +} diff --git a/lib/features/profile/presentation/widgets/profile_item.dart b/lib/features/profile/presentation/widgets/profile_item.dart new file mode 100644 index 0000000..e78d6a6 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_item.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/app/core/ui_helper/style/font_style.dart'; + +class ProfileItem extends StatelessWidget { + const ProfileItem({ + super.key, + required this.itemName, + required this.icon, + this.onTap, + this.trailing, + }); + + final String itemName; + final IconData icon; + final VoidCallback? onTap; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon, color: AppColors.grey), + title: Text(itemName, style: AppStyles.font12Black), + trailing: trailing, + onTap: onTap, + ); + } +} diff --git a/lib/features/profile/presentation/widgets/profile_page_body.dart b/lib/features/profile/presentation/widgets/profile_page_body.dart new file mode 100644 index 0000000..15b7ddf --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_page_body.dart @@ -0,0 +1,178 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/app/core/ui_helper/style/font_style.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_intent.dart'; +import 'package:tracking_app/features/profile/presentation/widgets/info_card.dart'; +import 'package:tracking_app/features/profile/presentation/widgets/profile_avatar.dart'; +import 'package:tracking_app/features/profile/presentation/widgets/profile_item.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; +import '../../../../app/core/router/route_names.dart'; +import '../../../auth/presentation/logout/manager/logout_cubit.dart'; +import '../../../auth/presentation/logout/manager/logout_intent.dart'; +import '../../../auth/presentation/logout/manager/logout_state.dart'; +import 'language_bottom_sheet.dart'; + +class ProfilePageBody extends StatelessWidget { + const ProfilePageBody({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + final user = state.driver; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + children: [ + const SizedBox(height: 16), + InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () async { + await context.push(RouteNames.editDriverProfile, extra: user); + if (context.mounted) { + context.read().doIntent(GetProfileIntent()); + } + }, + child: InfoCard( + child: Row( + children: [ + ProfileAvatar( + userName: + "${user?.firstName ?? ''} ${user?.lastName ?? ''}", + imageUrl: user?.photo, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${user?.firstName ?? 'Admin'} ${user?.lastName ?? 'User'}", + style: AppStyles.black14bold, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 5), + Text( + user?.email ?? 'test@gmail.com', + style: AppStyles.black14Medium, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 5), + Text( + user?.phone ?? '01010101010', + style: AppStyles.black14Medium, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const Icon(Icons.arrow_forward_ios), + ], + ), + ), + ), + + const SizedBox(height: 16), + + InfoCard( + child: Row( + children: [ + Expanded( + child: InkWell( + onTap: () async { + await context.push(RouteNames.editVehicle, extra: user); + if (context.mounted) { + context.read().doIntent( + GetProfileIntent(), + ); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Vehicle Info", style: AppStyles.black14bold), + const SizedBox(height: 5), + Text( + user?.vehicleType ?? "N/A", + style: AppStyles.black14Medium, + ), + const SizedBox(height: 5), + Text( + user?.vehicleNumber ?? "N/A", + style: AppStyles.black14Medium, + ), + ], + ), + ), + ), + const Icon(Icons.arrow_forward_ios), + ], + ), + ), + + const SizedBox(height: 16), + + ProfileItem( + itemName: "Language", + icon: Icons.language, + onTap: () { + showModalBottomSheet( + context: context, + builder: (context) => const LanguageBottomSheet(), + ); + }, + trailing: Text( + context.locale.languageCode == 'ar' ? "Arabic" : "English", + style: AppStyles.font14Black.copyWith(color: AppColors.pink), + ), + ), + BlocConsumer( + listener: (context, state) { + if (state.logoutResource.isSuccess) { + context.go(RouteNames.login); + } + if (state.logoutResource.isError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + state.logoutResource.error ?? + LocaleKeys.logoutFailed.tr(), + ), + ), + ); + } + }, + builder: (context, state) { + final isLoading = state.logoutResource.isLoading; + return ProfileItem( + itemName: LocaleKeys.logout.tr(), + icon: Icons.logout, + trailing: isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(Icons.logout, color: AppColors.pink), + onTap: isLoading + ? null + : () { + context.read().doIntent(PerformLogout()); + }, + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/radio_circle.dart b/lib/features/profile/presentation/widgets/radio_circle.dart new file mode 100644 index 0000000..ddea206 --- /dev/null +++ b/lib/features/profile/presentation/widgets/radio_circle.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/core/ui_helper/color/colors.dart'; + +class RadioCircle extends StatelessWidget { + final bool selected; + const RadioCircle({super.key, required this.selected}); + + @override + Widget build(BuildContext context) { + return Container( + width: 22, + height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected ? AppColors.pink : Colors.grey.shade400, + width: 2, + ), + ), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? AppColors.pink : Colors.transparent, + ), + ), + ); + } +} diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 0000000..f4c5a20 --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,74 @@ +// File generated by FlutterFire CLI. +// ignore_for_file: type=lint +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for macos - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.windows: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for windows - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'AIzaSyB1-EtHvgb14c5UzVggOoJRa6j8oto53Jg', + appId: '1:725835190067:android:1a8871c3f15cdafae53846', + messagingSenderId: '725835190067', + projectId: 'elevate-flower-app', + storageBucket: 'elevate-flower-app.firebasestorage.app', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'AIzaSyABD3fIZrCMoxFhtNpvSRhY9ZHTHt49rQU', + appId: '1:725835190067:ios:3af9533994ff8587e53846', + messagingSenderId: '725835190067', + projectId: 'elevate-flower-app', + storageBucket: 'elevate-flower-app.firebasestorage.app', + iosBundleId: 'com.example.trackingApp', + ); + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyDKWdkFjeKkEAfKFrMO2svs48t2d9OqRGw', + appId: '1:725835190067:web:86225b1572d53a90e53846', + messagingSenderId: '725835190067', + projectId: 'elevate-flower-app', + authDomain: 'elevate-flower-app.firebaseapp.com', + storageBucket: 'elevate-flower-app.firebasestorage.app', + ); +} diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart new file mode 100644 index 0000000..d0907a7 --- /dev/null +++ b/lib/generated/locale_keys.g.dart @@ -0,0 +1,277 @@ +// DO NOT EDIT. This is code generated via package:easy_localization/generate.dart + +// ignore_for_file: constant_identifier_names + +abstract class LocaleKeys { + static const firstName = 'firstName'; + static const lastName = 'lastName'; + static const email = 'email'; + static const password = 'password'; + static const confirmPassword = 'confirmPassword'; + static const phone = 'phone'; + static const gender = 'gender'; + static const enterFirstName = 'enterFirstName'; + static const enterLastName = 'enterLastName'; + static const enterEmail = 'enterEmail'; + static const enterPassword = 'enterPassword'; + static const enterPhoneNumber = 'enterPhoneNumber'; + static const enterRePassword = 'enterRePassword'; + static const femaleGender = 'femaleGender'; + static const maleGender = 'maleGender'; + static const femaleValue = 'femaleValue'; + static const maleValue = 'maleValue'; + static const createAccount = 'createAccount'; + static const termsAndConditions = 'termsAndConditions'; + static const alreadyHaveAccount = 'alreadyHaveAccount'; + static const login = 'login'; + static const signup = 'signup'; + static const emailRequired = 'emailRequired'; + static const emailInvalid = 'emailInvalid'; + static const passwordRequired = 'passwordRequired'; + static const passwordLengthInvalid = 'passwordLengthInvalid'; + static const passwordUpperLetterInvalid = 'passwordUpperLetterInvalid'; + static const passwordLowerLetterInvalid = 'passwordLowerLetterInvalid'; + static const passwordNumbersInvalid = 'passwordNumbersInvalid'; + static const passwordSpecialCharInvalid = 'passwordSpecialCharInvalid'; + static const confirmPasswordRequired = 'confirmPasswordRequired'; + static const passwordsDoNotMatch = 'passwordsDoNotMatch'; + static const phoneRequired = 'phoneRequired'; + static const phoneInvalid = 'phoneInvalid'; + static const firstNameRequired = 'firstNameRequired'; + static const lastNameRequired = 'lastNameRequired'; + static const nameInvalid = 'nameInvalid'; + static const genderRequired = 'genderRequired'; + static const loading = 'loading'; + static const registrationSuccessful = 'registrationSuccessful'; + static const ok = 'ok'; + static const error = 'error'; + static const success = 'success'; + static const emailVerification = 'emailVerification'; + static const rememberMe = 'rememberMe'; + static const forgotPassword = 'forgotPassword'; + static const forgotPasswordTitle = 'forgotPasswordTitle'; + static const continueAsGuest = 'continueAsGuest'; + static const dontHaveAnAccount = 'dontHaveAnAccount'; + static const signUp = 'signUp'; + static const enterYourEmail = 'enterYourEmail'; + static const enterYourPassword = 'enterYourPassword'; + static const associatedEmail = 'associatedEmail'; + static const userName = 'userName'; + static const newPassword = 'newPassword'; + static const confirm = 'confirm'; + static const continueTxt = 'continueTxt'; + static const instruction = 'instruction'; + static const didNotReceive = 'didNotReceive'; + static const resend = 'resend'; + static const resetPassword = 'resetPassword'; + static const yourEmailVerified = 'yourEmailVerified'; + static const check_email_for_verification_code = + 'check_email_for_verification_code'; + static const passwordValidation = 'passwordValidation'; + static const connectionTimeout = 'connectionTimeout'; + static const noInternet = 'noInternet'; + static const unauthorized = 'unauthorized'; + static const serverError = 'serverError'; + static const unknownError = 'unknownError'; + static const an_error_occurred = 'an_error_occurred'; + static const weakPassword = 'weakPassword'; + static const passwordWithCapital = 'passwordWithCapital'; + static const passwordWithNumber = 'passwordWithNumber'; + static const passwordDontMatch = 'passwordDontMatch'; + static const confirmPasswordMsg = 'confirmPasswordMsg'; + static const invalidNumber = 'invalidNumber'; + static const required = 'required'; + static const least3Characters = 'least3Characters'; + static const least6Characters = 'least6Characters'; + static const invalidName = 'invalidName'; + static const phoneNumber = 'phoneNumber'; + static const passwordUpdated = 'passwordUpdated'; + static const addToCard = 'addToCard'; + static const noProductsfound = 'noProductsfound'; + static const viewAll = 'viewAll'; + static const search = 'search'; + static const categories = 'categories'; + static const bestSelling = 'bestSelling'; + static const occasions = 'occasions'; + static const allPricesIncludeTax = 'allPricesIncludeTax'; + static const productAddedToCart = 'productAddedToCart'; + static const something_went_wrong = 'something_went_wrong'; + static const cart = 'cart'; + static const items = 'items'; + static const deliverTo = 'deliverTo'; + static const egp = 'egp'; + static const subTotal = 'subTotal'; + static const deliveryFee = 'deliveryFee'; + static const total = 'total'; + static const checkout = 'checkout'; + static const productDeletedSuccessfully = 'productDeletedSuccessfully'; + static const productUpdated = 'productUpdated'; + static const currentPassword = 'currentPassword'; + static const enterCurrentPassword = 'enterCurrentPassword'; + static const enterNewPassword = 'enterNewPassword'; + static const confirmNewPassword = 'confirmNewPassword'; + static const update = 'update'; + static const changePassword = 'changePassword'; + static const no_products_found = 'no_products_found'; + static const change_language = 'change_language'; + static const arabic = 'arabic'; + static const english = 'english'; + static const initialSearchMsg = 'initialSearchMsg'; + static const welcomeMessage = 'welcomeMessage'; + static const home = 'home'; + static const profile = 'profile'; + static const defaultErrorMessage = 'defaultErrorMessage'; + static const bestseller = 'bestseller'; + static const sessionExpiredMessage = 'sessionExpiredMessage'; + static const notificationsKey = 'notificationsKey'; + static const noProfileFound = 'noProfileFound'; + static const register = 'register'; + static const pleaseLoginToAccessProfile = 'pleaseLoginToAccessProfile'; + static const aboutUs = 'aboutUs'; + static const language = 'language'; + static const notifications = 'notifications'; + static const savedAddresses = 'savedAddresses'; + static const myOrders = 'myOrders'; + static const noName = 'noName'; + static const noEmail = 'noEmail'; + static const editProfile = 'editProfile'; + static const logout = 'logout'; + static const logoutFailed = 'logoutFailed'; + static const order_success = 'order_success'; + static const failed_load_addresses = 'failed_load_addresses'; + static const no_addresses = 'no_addresses'; + static const order_status = 'order_status'; + static const delivered = 'delivered'; + static const paid = 'paid'; + static const pending = 'pending'; + static const instant_delivery_info = 'instant_delivery_info'; + static const schedule = 'schedule'; + static const delivery_address = 'delivery_address'; + static const add_new = 'add_new'; + static const payment_method = 'payment_method'; + static const cash_on_delivery = 'cash_on_delivery'; + static const credit_card = 'credit_card'; + static const it_is_a_gift = 'it_is_a_gift'; + static const recipient_name = 'recipient_name'; + static const recipient_phone = 'recipient_phone'; + static const place_order = 'place_order'; + static const instant = 'instant'; + static const arrive_by_datetime = 'arrive_by_datetime'; + static const in_cart = 'in_cart'; + static const invalidRecipientName = 'invalidRecipientName'; + static const invalidAddress = 'invalidAddress'; + static const requiredRecipientName = 'requiredRecipientName'; + static const requiredAddress = 'requiredAddress'; + static const requiredCity = 'requiredCity'; + static const requiredArea = 'requiredArea'; + static const address = 'address'; + static const enter_address = 'enter_address'; + static const phone_number = 'phone_number'; + static const enter_phone_number = 'enter_phone_number'; + static const enter_recipient_name = 'enter_recipient_name'; + static const save_address = 'save_address'; + static const area = 'area'; + static const city = 'city'; + static const location_permission = 'location_permission'; + static const location_service_off_message = 'location_service_off_message'; + static const location_permission_denied_forever_message = + 'location_permission_denied_forever_message'; + static const location_permission_denied_message = + 'location_permission_denied_message'; + static const open_settings = 'open_settings'; + static const open_location_settings = 'open_location_settings'; + static const allow_location = 'allow_location'; + static const move_map_to_choose_location = 'move_map_to_choose_location'; + static const address_saved_successfully = 'address_saved_successfully'; + static const failed_to_save_address = 'failed_to_save_address'; + static const addNewAddress = 'addNewAddress'; + static const savedAddress = 'savedAddress'; + static const sortBy = 'sortBy'; + static const lowestPrice = 'lowestPrice'; + static const highestPrice = 'highestPrice'; + static const newest = 'newest'; + static const oldest = 'oldest'; + static const discount = 'discount'; + static const filter = 'filter'; + static const active = 'active'; + static const completed = 'completed'; + static const no_orders_found = 'no_orders_found'; + static const track_order = 'track_order'; + static const order_number = 'order_number'; + static const all_notifications_cleared = 'all_notifications_cleared'; + static const notification_deleted_successfully = + 'notification_deleted_successfully'; + static const clear_all = 'clear_all'; + static const no_notifications_yet = 'no_notifications_yet'; + static const orders = 'orders'; + static const onboardingTitle = 'onboardingTitle'; + static const onboardingDescription = 'onboardingDescription'; + static const applyNow = 'applyNow'; + static const wrongEmailOrPassword = 'wrongEmailOrPassword'; + static const apply = 'apply'; + static const welcomeApply = 'welcomeApply'; + static const joinTeamMessage = 'joinTeamMessage'; + static const country = 'country'; + static const firstLegalName = 'firstLegalName'; + static const enterFirstLegalName = 'enterFirstLegalName'; + static const secondLegalName = 'secondLegalName'; + static const enterSecondLegalName = 'enterSecondLegalName'; + static const vehicleType = 'vehicleType'; + static const vehicleNumber = 'vehicleNumber'; + static const enterVehicleNumber = 'enterVehicleNumber'; + static const vehicleLicense = 'vehicleLicense'; + static const uploadLicensePhoto = 'uploadLicensePhoto'; + static const idNumber = 'idNumber'; + static const enterNationalId = 'enterNationalId'; + static const idImage = 'idImage'; + static const uploadIdImage = 'uploadIdImage'; + static const continueText = 'continueText'; + static const requiredField = 'requiredField'; + static const licensePhotoRequired = 'licensePhotoRequired'; + static const idImageRequired = 'idImageRequired'; + static const failedToLoadCountries = 'failedToLoadCountries'; + static const failedToLoadVehicles = 'failedToLoadVehicles'; + static const applicationSubmittedSuccessfully = + 'applicationSubmittedSuccessfully'; + static const submissionFailed = 'submissionFailed'; + static const applicationSubmitted = 'applicationSubmitted'; + static const congratulationsMessage = 'congratulationsMessage'; + static const reviewMessage = 'reviewMessage'; + static const backToLogin = 'backToLogin'; + static const checkEmailMessage = 'checkEmailMessage'; + static const change = 'change'; + static const vehicle_type = 'vehicle_type'; + static const vehicle_number = 'vehicle_number'; + static const vehicle_license = 'vehicle_license'; + static const editDriverProfile = 'editDriverProfile'; + static const editVehicle = 'editVehicle'; + static const cannotBeSame = 'cannotBeSame'; + static const orderDetails = 'orderDetails'; + static const status = 'status'; + static const orderId = 'orderId'; + static const pickupAddress = 'pickupAddress'; + static const floweryStore = 'floweryStore'; + static const userAddress = 'userAddress'; + static const arrivedAtPickupPoint = 'arrivedAtPickupPoint'; + static const startDelivery = 'startDelivery'; + static const arriverAtDestination = 'arriverAtDestination'; + static const confirmDelivery = 'confirmDelivery'; + static const deliveryConfirmed = 'deliveryConfirmed'; + static const orderCompleted = 'orderCompleted'; + static const accepted = 'accepted'; + static const pickedUp = 'pickedUp'; + static const outForDelivery = 'outForDelivery'; + static const arrived = 'arrived'; + static const driverOrderTitle = 'driverOrderTitle'; + static const unknownStore = 'unknownStore'; + static const noAddress = 'noAddress'; + static const accept = 'accept'; + static const reject = 'reject'; + static const noPendingOrders = 'noPendingOrders'; + static const floweryRider = 'floweryRider'; + static const btnArrivedAtPickupPoint = 'btnArrivedAtPickupPoint'; + static const btnStartDeliver = 'btnStartDeliver'; + static const btnArrivedToUser = 'btnArrivedToUser'; + static const btnDeliveredToUser = 'btnDeliveredToUser'; + static const finishYourOrder = 'finishYourOrder'; +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..5281b8d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,122 +1,60 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; - -void main() { - runApp(const MyApp()); +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/app/core/firebase/cloud_messaging.dart'; +import 'package:tracking_app/app/core/router/app_router.dart'; +import 'package:tracking_app/app/core/ui_helper/theme/app_theme.dart'; +import 'package:tracking_app/firebase_options.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await EasyLocalization.ensureInitialized(); + configureDependencies(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + FirebaseMessaging.onBackgroundMessage( + CloudMessaging.firebaseMessagingBackgroundHandler, + ); + await CloudMessaging.setupFlutterNotifications(); + CloudMessaging.printDeviceToken(); + runApp( + EasyLocalization( + supportedLocales: const [Locale('en'), Locale('ar')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: const TrackingApp(), + ), + ); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - // This widget is the root of your application. +class TrackingApp extends StatefulWidget { + const TrackingApp({super.key}); @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } + State createState() => _TrackingAppState(); } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - +class _TrackingAppState extends State { @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); + initState() { + super.initState(); + FirebaseMessaging.instance.requestPermission( + alert: true, + badge: true, + sound: true, + ); + FirebaseMessaging.onMessage.listen(CloudMessaging.showFlutterNotification); } @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), + return MaterialApp.router( + debugShowCheckedModeBanner: false, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + theme: AppTheme.lightTheme, + routerConfig: appRouter, ); } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..7299b5c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,14 @@ #include "generated_plugin_registrant.h" +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { + 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_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..786ff5c 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/login_test_output.txt b/login_test_output.txt new file mode 100644 index 0000000..bb06f43 Binary files /dev/null and b/login_test_output.txt differ diff --git a/login_test_output_utf8.txt b/login_test_output_utf8.txt new file mode 100644 index 0000000..c91df39 --- /dev/null +++ b/login_test_output_utf8.txt @@ -0,0 +1,58 @@ +00:00 +0: loading C:/Users/20101/StudioProjects/tracking_app/test/features/auth/presentation/login/pages/loginScreen_test.dart +00:00 +0: (setUpAll) +[­ƒîÄ Easy Localization] [DEBUG] Localization initialized +00:00 +0: LoginScreen renders correctly +[­ƒîÄ Easy Localization] [DEBUG] Start +[­ƒîÄ Easy Localization] [DEBUG] Init state +[­ƒîÄ Easy Localization] [DEBUG] Build +[­ƒîÄ Easy Localization] [DEBUG] Init Localization Delegate +[­ƒîÄ Easy Localization] [DEBUG] Init provider +[­ƒîÄ Easy Localization] [WARNING] Localization key [login] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [email] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [enterEmail] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [password] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [enterPassword] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [rememberMe] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [forgotPasswordTitle] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [login] not found +ÔòÉÔòÉÔòí EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK Ôò×ÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉ +The following TestFailure was thrown running a test: +Expected: exactly one matching candidate + Actual: _TextWidgetFinder: + Which: means none were found but one was expected + +When the exception was thrown, this was the stack: +#4 main. (file:///C:/Users/20101/StudioProjects/tracking_app/test/features/auth/presentation/login/pages/loginScreen_test.dart:64:5) + +#5 testWidgets.. (package:flutter_test/src/widget_tester.dart:192:15) + +#6 TestWidgetsFlutterBinding._runTestBody (package:flutter_test/src/binding.dart:1059:5) + + +(elided one frame from package:stack_trace) + +This was caught by the test expectation on the following line: + file:///C:/Users/20101/StudioProjects/tracking_app/test/features/auth/presentation/login/pages/loginScreen_test.dart line 64 +The test description was: + LoginScreen renders correctly +ÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉÔòÉ +00:02 +0 -1: LoginScreen renders correctly [E] + Test failed. See exception logs above. + The test description was: LoginScreen renders correctly + +00:02 +0 -1: Enters text into email and password fields +[­ƒîÄ Easy Localization] [DEBUG] Start +[­ƒîÄ Easy Localization] [DEBUG] Init state +[­ƒîÄ Easy Localization] [DEBUG] Build +[­ƒîÄ Easy Localization] [DEBUG] Init Localization Delegate +[­ƒîÄ Easy Localization] [DEBUG] Init provider +[­ƒîÄ Easy Localization] [WARNING] Localization key [login] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [email] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [enterEmail] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [password] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [enterPassword] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [rememberMe] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [forgotPasswordTitle] not found +[­ƒîÄ Easy Localization] [WARNING] Localization key [login] not found +00:03 +1 -1: (tearDownAll) +00:03 +1 -1: Some tests failed. diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..cd23da7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,30 @@ import FlutterMacOS import Foundation +import cloud_firestore +import file_selector_macos +import firebase_core +import firebase_crashlytics +import firebase_messaging +import flutter_local_notifications +import geolocator_apple +import package_info_plus +import path_provider_foundation +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + 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")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/pubspec.lock b/pubspec.lock index 3decd54..917619d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,149 +1,1415 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: cd83f7d6bd4e4c0b0b4fef802e8796784032e1cc23d7b0e982cf5d05d9bbe182 + url: "https://pub.dev" + source: hosted + version: "1.3.66" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.dev" + source: hosted + version: "7.7.1" + 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: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + 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" + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81 + url: "https://pub.dev" + source: hosted + version: "9.2.0" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: ce76b1d48875e3233fde17717c23d1f60a91cc631597e49a400c89b475395b1d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: d1d57f7807debd7349b4726a19fd32ec8bc177c71ad0febf91a20f84cd2d4b46 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30 + url: "https://pub.dev" + source: hosted + version: "2.7.1" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "066dda7f73d8eb48ba630a55acb50c4a84a2e6b453b1cb4567f581729e794f7b" + url: "https://pub.dev" + source: hosted + version: "9.3.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" + url: "https://pub.dev" + source: hosted + version: "8.12.3" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + sha256: "54484b2fc49f41b46f35b60a54b12351181eeaad22c0e3def276a81e17ae7c9b" + url: "https://pub.dev" + source: hosted + version: "6.1.2" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + sha256: dfaa8b2c0d0a824af289d4159816a5c78417feec264c2194081d645687195158 + url: "https://pub.dev" + source: hosted + version: "7.0.6" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + sha256: "35d01f502b3b701d700470d32a8f82704dac8341a66e86c074900cde5bab343d" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + 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" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + url: "https://pub.dev" + source: hosted + version: "5.9.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + sha256: "2ccdf9db8fe4d9c5a75c122e6275674508fd0f0d49c827354967b8afcc56bbed" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + easy_logger: + dependency: transitive + description: + name: easy_logger + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + 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: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + 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: f0997fee80fbb6d2c658c5b88ae87ba1f9506b5b37126db64fc2e75d8e977fbb + url: "https://pub.dev" + source: hosted + version: "4.5.0" + 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: "856ca92bf2d75a63761286ab8e791bda3a85184c2b641764433b619647acfca6" + url: "https://pub.dev" + source: hosted + version: "3.5.0" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: a6e6cb8b2ea1214533a54e4c1b11b19c40f6a29333f3ab0854a479fdc3237c5b + url: "https://pub.dev" + source: hosted + version: "5.0.7" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: fc6837c4c64c48fa94cab8a872a632b9194fa9208ca76a822f424b3da945584d + url: "https://pub.dev" + source: hosted + version: "3.8.17" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "06fad40ea14771e969a8f2bbce1944aa20ee2f4f57f4eca5b3ba346b65f3f644" + url: "https://pub.dev" + source: hosted + version: "16.1.1" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "6c49e901c77e6e10e86d98e32056a087eb1ca1b93acdf58524f1961e617657b7" + url: "https://pub.dev" + source: hosted + version: "4.7.6" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "2756f8fea583ffb9d294d15ddecb3a9ad429b023b70c9990c151fc92c54a32b3" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 + url: "https://pub.dev" + source: hosted + version: "9.1.1" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + 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: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad" + url: "https://pub.dev" + source: hosted + version: "20.1.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 + url: "https://pub.dev" + source: hosted + version: "7.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_otp_text_field: + dependency: "direct main" + description: + name: flutter_otp_text_field + sha256: e7e589dc51cde120d63da6db55f3cef618f5d013d12adba76137ca1a51ce1390 + url: "https://pub.dev" + source: hosted + version: "1.5.1+1" + 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_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + 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: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" + 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_it: + dependency: "direct main" + description: + name: get_it + sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66" + url: "https://pub.dev" + source: hosted + version: "9.2.1" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896" + url: "https://pub.dev" + source: hosted + version: "17.1.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + 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: "9b0d6dab3de6955837575dc371dd772fcb5d0a90f6a4954e8c066472f9938550" + url: "https://pub.dev" + source: hosted + version: "2.14.2" + google_maps_flutter_android: + dependency: transitive + description: + name: google_maps_flutter_android + sha256: "8b569c7abc52bc62d4502bf93847d487c0843f3e6a2a8e122b72e98843b2ab4c" + url: "https://pub.dev" + source: hosted + version: "2.19.1" + google_maps_flutter_ios: + dependency: transitive + description: + name: google_maps_flutter_ios + sha256: a2e3c7ad2392ea65d6775704716d0aa3c3d226cb984fd0a688bca40f6be1a451 + url: "https://pub.dev" + source: hosted + version: "2.17.4" + google_maps_flutter_platform_interface: + dependency: transitive + description: + name: google_maps_flutter_platform_interface + sha256: "0f8c6674d70c7e9a09cd34f63b18ebaf8a5822e85b558128eae0fdf02b4a3e93" + url: "https://pub.dev" + source: hosted + version: "2.14.2" + 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" + googleapis_auth: + dependency: "direct main" + description: + name: googleapis_auth + sha256: b81fe352cc4a330b3710d2b7ad258d9bcef6f909bb759b306bf42973a7d046db + url: "https://pub.dev" + source: hosted + version: "2.0.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b + url: "https://pub.dev" + source: hosted + version: "4.3.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + 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: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + 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" + injectable: + dependency: "direct main" + description: + name: injectable + sha256: "32b36a9d87f18662bee0b1951b81f47a01f2bf28cd6ea94f60bc5453c7bf598c" + url: "https://pub.dev" + source: hosted + version: "2.7.1+4" + injectable_generator: + dependency: "direct dev" + description: + name: injectable_generator + sha256: "09c55dba52b53d17411b90134a6751270b8930abd2529e2637d700fc99b0d5b5" + url: "https://pub.dev" + source: hosted + version: "2.8.1" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3 + url: "https://pub.dev" + source: hosted + version: "6.11.2" + 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" + lean_builder: + dependency: transitive + description: + name: lean_builder + sha256: ef5cd5f907157eb7aa87d1704504b5a6386d2cbff88a3c2b3344477bab323ee9 + url: "https://pub.dev" + source: hosted + version: "0.1.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.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: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99" + url: "https://pub.dev" + source: hosted + version: "5.5.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + network_image_mock: + dependency: "direct dev" + description: + name: network_image_mock + sha256: "855cdd01d42440e0cffee0d6c2370909fc31b3bcba308a59829f24f64be42db7" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + 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_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + 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" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.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" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + pretty_dio_logger: + dependency: "direct main" + description: + name: pretty_dio_logger + sha256: "36f2101299786d567869493e2f5731de61ce130faa14679473b26905a92b6407" url: "https://pub.dev" source: hosted - version: "2.13.0" - boolean_selector: + version: "1.4.0" + protobuf: dependency: transitive description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + name: protobuf + sha256: "2fcc8a202ca7ec17dab7c97d6b6d91cf03aa07fe6f65f8afbb6dfa52cc5bd902" url: "https://pub.dev" source: hosted - version: "2.1.2" - characters: + version: "5.1.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: dependency: transitive description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "1.4.0" - clock: + version: "2.2.0" + pubspec_parse: dependency: transitive description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.1.2" - collection: + version: "1.5.0" + recase: dependency: transitive description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 url: "https://pub.dev" source: hosted - version: "1.19.1" - cupertino_icons: + version: "4.1.0" + retrofit: dependency: "direct main" description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + name: retrofit + sha256: "84063c18a00d55af41d6b8401edf8473e8c215bd7068ef7ec5e34c60657ffdbe" url: "https://pub.dev" source: hosted - version: "1.0.8" - fake_async: + version: "4.9.1" + retrofit_generator: + dependency: "direct dev" + description: + name: retrofit_generator + sha256: "7ec323f3329ad2ca0bcdc96fe02ec7f2486ecfac6cd2d035b03c398ef6f42308" + url: "https://pub.dev" + source: hosted + version: "10.2.0" + rxdart: dependency: transitive description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "1.3.3" - flutter: + version: "0.28.0" + sanitize_html: + dependency: transitive + description: + name: sanitize_html + sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + shared_preferences: dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "6.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - leak_tracker: + version: "2.5.4" + shared_preferences_android: dependency: transitive description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" url: "https://pub.dev" source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: + version: "2.4.21" + shared_preferences_foundation: dependency: transitive description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "3.0.10" - leak_tracker_testing: + version: "2.5.6" + shared_preferences_linux: dependency: transitive description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "3.0.2" - lints: + version: "2.4.1" + shared_preferences_platform_interface: dependency: transitive description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "6.1.0" - matcher: + version: "2.4.1" + shared_preferences_web: dependency: transitive description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "0.12.17" - material_color_utilities: + version: "2.4.3" + shared_preferences_windows: dependency: transitive description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "0.11.1" - meta: + version: "2.4.1" + shelf: dependency: transitive description: - name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.17.0" - path: + version: "1.4.2" + shelf_packages_handler: dependency: transitive description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + skeletonizer: + dependency: "direct main" + description: + name: skeletonizer + sha256: "9f38f9b47ec3cf2235a6a4f154a88a95432bc55ba98b3e2eb6ced5c1974bc122" + url: "https://pub.dev" + source: hosted + version: "2.1.3" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -152,6 +1418,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -168,6 +1474,14 @@ packages: 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: @@ -176,6 +1490,14 @@ packages: 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" term_glyph: dependency: transitive description: @@ -184,14 +1506,150 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.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: "0.7.7" + 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: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" + url: "https://pub.dev" + source: hosted + version: "1.2.0" vector_math: dependency: transitive description: @@ -208,6 +1666,86 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.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" + xxh3: + dependency: transitive + description: + name: xxh3 + sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.10.4 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9185ff4..05942c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,78 +1,72 @@ name: tracking_app description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. +publish_to: 'none' version: 1.0.0+1 environment: - sdk: ^3.10.4 + sdk: ">=3.8.1 <4.0.0" -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. + bloc: ^9.2.0 cupertino_icons: ^1.0.8 + dio: ^5.9.1 + easy_localization: ^3.0.8 + equatable: ^2.0.8 + flutter_bloc: ^9.1.1 + flutter_otp_text_field: ^1.5.1+1 + flutter_svg: ^2.2.3 + get_it: ^9.2.0 + go_router: ^17.1.0 + injectable: ^2.7.1+4 + intl: ^0.20.2 + json_annotation: ^4.9.0 + pretty_dio_logger: ^1.4.0 + provider: ^6.1.5+1 + retrofit: 4.9.1 + shared_preferences: ^2.2.2 + shimmer: ^3.0.0 + skeletonizer: ^2.1.2 + image_picker: ^1.2.1 + google_maps_flutter: ^2.14.0 + geolocator: ^14.0.2 + firebase_core: ^4.4.0 + lottie: ^3.3.2 + url_launcher: ^6.1.10 + firebase_messaging: ^16.1.1 + flutter_local_notifications: ^20.0.0 + firebase_crashlytics: ^5.0.7 + cloud_firestore: ^6.1.2 + cached_network_image: ^3.3.1 + flutter_polyline_points: ^3.1.0 + googleapis_auth: ^2.0.0 dev_dependencies: + bloc_test: ^10.0.0 + build_runner: ^2.4.13 + flutter_lints: ^6.0.0 + injectable_generator: ^2.4.1 + json_serializable: ^6.8.0 + mockito: ^5.4.4 + retrofit_generator: 10.2.0 + network_image_mock: ^2.1.1 + mocktail: ^1.0.3 + flutter_test: sdk: flutter - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images + assets: + - assets/translations/ + - assets/data/ + - assets/images/ + - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: # fonts: # - family: Schyler # fonts: @@ -83,7 +77,4 @@ flutter: # fonts: # - asset: fonts/TrajanPro.ttf # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + # weight: 700 \ No newline at end of file diff --git a/test/app/config/auth_storage/auth_storage_test.dart b/test/app/config/auth_storage/auth_storage_test.dart new file mode 100644 index 0000000..187c7bf --- /dev/null +++ b/test/app/config/auth_storage/auth_storage_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; + +void main() { + late AuthStorage authStorage; + + setUp(() { + authStorage = AuthStorage(); + SharedPreferences.setMockInitialValues({}); + }); + + group('AuthStorage Tests', () { + test( + 'saveToken should call setString with correct key and value', + () async { + const token = 'test_token_123'; + + await authStorage.saveToken(token); + + final storedToken = await authStorage.getToken(); + expect(storedToken, token); + }, + ); + + test('getRememberMe should return false by default if not set', () async { + final result = await authStorage.getRememberMe(); + + expect(result, false); + }); + + test('setRememberMe should store boolean value correctly', () async { + await authStorage.setRememberMe(true); + + final result = await authStorage.getRememberMe(); + expect(result, true); + }); + + test('saveUserJson and getUserJson should handle string data', () async { + const userJson = '{"id": 1, "name": "Gemini"}'; + + await authStorage.saveUserJson(userJson); + final result = await authStorage.getUserJson(); + + expect(result, userJson); + }); + + test('clearOrderId should remove the order id from prefs', () async { + await authStorage.saveOrderId('order_999'); + + await authStorage.clearOrderId(); + final result = await authStorage.getOrderId(); + + expect(result, null); + }); + + test('clearAll should reset all stored values', () async { + await authStorage.saveToken('token'); + await authStorage.setRememberMe(true); + await authStorage.saveOrderId('123'); + + await authStorage.clearAll(); + + expect(await authStorage.getToken(), null); + expect(await authStorage.getRememberMe(), false); + expect(await authStorage.getOrderId(), null); + }); + }); +} diff --git a/test/app/config/validation/app_validation_test.dart b/test/app/config/validation/app_validation_test.dart new file mode 100644 index 0000000..b8f6a47 --- /dev/null +++ b/test/app/config/validation/app_validation_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tracking_app/app/config/validation/app_validation.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +void main() { + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + EasyLocalization.logger.enableLevels = []; + }); + + group('Validators Unit Tests', () { + group('firstNameValidator', () { + test('should return required error for null or empty', () { + expect( + Validators.firstNameValidator(null), + LocaleKeys.firstNameRequired.tr(), + ); + expect( + Validators.firstNameValidator(''), + LocaleKeys.firstNameRequired.tr(), + ); + }); + + test( + 'should return invalid error for names < 3 or > 50 or with numbers', + () { + expect( + Validators.firstNameValidator('Ab'), + LocaleKeys.nameInvalid.tr(), + ); + expect( + Validators.firstNameValidator('Ab12'), + LocaleKeys.nameInvalid.tr(), + ); + }, + ); + + test('should return null for valid first name', () { + expect(Validators.firstNameValidator('Ahmed'), null); + }); + }); + + group('phoneValidator', () { + test('should return required error for empty phone', () { + expect(Validators.phoneValidator(''), LocaleKeys.phoneRequired.tr()); + }); + + test('should return invalid for non-Egyptian format or wrong length', () { + // Regex بيطلب يبدأ بـ +201 وبعدها [0-2, 5] وبعدها 8 أرقام + expect( + Validators.phoneValidator('01012345678'), + LocaleKeys.phoneInvalid.tr(), + ); // ناقص الـ +20 + expect( + Validators.phoneValidator('+201312345678'), + LocaleKeys.phoneInvalid.tr(), + ); // رقم 3 مش موجود في الـ range + }); + + test('should return null for valid Egyptian phone (+2010...)', () { + expect(Validators.phoneValidator('+201012345678'), null); + }); + }); + + group('passwordValidator', () { + test( + 'should validate length, capital, small, number, and special char', + () { + expect( + Validators.passwordValidator(''), + LocaleKeys.passwordRequired.tr(), + ); + expect( + Validators.passwordValidator('123ab'), + LocaleKeys.passwordLengthInvalid.tr(), + ); + expect( + Validators.passwordValidator('abcdef123!'), + LocaleKeys.passwordUpperLetterInvalid.tr(), + ); + expect( + Validators.passwordValidator('ABCDEF123!'), + LocaleKeys.passwordLowerLetterInvalid.tr(), + ); + expect( + Validators.passwordValidator('Abcdefgh!'), + LocaleKeys.passwordNumbersInvalid.tr(), + ); + expect( + Validators.passwordValidator('Abcdefgh1'), + LocaleKeys.passwordSpecialCharInvalid.tr(), + ); + }, + ); + + test('should return null for strong password', () { + expect(Validators.passwordValidator('Strong123!'), null); + }); + }); + + group('newPasswordValidator', () { + test('should return error if same as current password', () { + const currentPass = 'OldPass123!'; + expect( + Validators.newPasswordValidator(currentPass, currentPass), + LocaleKeys.cannotBeSame.tr(), + ); + }); + }); + + group('confirmPasswordValidator', () { + test('should return error if passwords do not match', () { + expect( + Validators.confirmPasswordValidator('Pass1', 'Pass2'), + LocaleKeys.passwordsDoNotMatch.tr(), + ); + }); + }); + + group('emailValidator', () { + test('should return invalid for wrong email formats', () { + expect( + Validators.emailValidator('test@'), + LocaleKeys.emailInvalid.tr(), + ); + expect( + Validators.emailValidator('test@domain'), + LocaleKeys.emailInvalid.tr(), + ); + }); + + test('should return null for valid email', () { + expect(Validators.emailValidator('user@example.com'), null); + }); + }); + + group('genderValidator', () { + test('should return error if gender is not selected', () { + expect( + Validators.genderValidator(null), + LocaleKeys.genderRequired.tr(), + ); + expect(Validators.genderValidator(''), LocaleKeys.genderRequired.tr()); + }); + }); + }); +} diff --git a/test/app/core/ui_helper/style/font_style_test.dart b/test/app/core/ui_helper/style/font_style_test.dart new file mode 100644 index 0000000..559dae4 --- /dev/null +++ b/test/app/core/ui_helper/style/font_style_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/app/core/ui_helper/style/font_style.dart'; + +void main() { + group('AppStyles Tests', () { + test('font32BlackSemiBold should have correct properties', () { + final style = AppStyles.font32BlackSemiBold; + + expect(style.fontSize, 32); + expect(style.color, AppColors.blackColor); + expect(style.fontWeight, FontWeight.w500); + expect(style.fontFamily, 'SansArabic'); + }); + + test('subtitle should have correct properties', () { + final style = AppStyles.subtitle; + + expect(style.fontSize, 12); + expect(style.color, AppColors.grey); + expect(style.fontWeight, FontWeight.normal); + }); + + test('All styles should use the correct default fontFamily', () { + expect(AppStyles.black24SemiBold.fontFamily, 'SansArabic'); + expect(AppStyles.font16Black.fontFamily, 'SansArabic'); + expect(AppStyles.purple18bold.fontFamily, 'SansArabic'); + }); + + test('Special case: medium20 should use Inter font', () { + expect(AppStyles.medium20.fontFamily, 'Inter'); + expect(AppStyles.medium20.fontSize, 20); + }); + + test('red14Normal should return red color from AppColors', () { + expect(AppStyles.red14Normal.color, AppColors.red); + expect(AppStyles.red14Normal.fontSize, 14); + }); + + test('Check for specific bug: font12White color fix', () { + expect(AppStyles.font12White.color, AppColors.blackColor); + }); + }); +} diff --git a/test/app/core/utils/app_launcher_test.dart b/test/app/core/utils/app_launcher_test.dart new file mode 100644 index 0000000..a17a87b --- /dev/null +++ b/test/app/core/utils/app_launcher_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:tracking_app/app/core/utils/app_launcher.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +import 'app_launcher_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(mixingIn: [MockPlatformInterfaceMixin]), +]) +void main() { + late MockUrlLauncherPlatform mockPlatform; + + setUp(() { + mockPlatform = MockUrlLauncherPlatform(); + UrlLauncherPlatform.instance = mockPlatform; + }); + + group('AppLauncher Tests', () { + test('launchPhone should call launchUrl with tel scheme', () async { + const phoneNumber = '0123456789'; + const expectedUrl = 'tel:0123456789'; + + when(mockPlatform.canLaunch(expectedUrl)).thenAnswer((_) async => true); + when( + mockPlatform.launchUrl(expectedUrl, any), + ).thenAnswer((_) async => true); + + AppLauncher.launchPhone(phoneNumber); + + await untilCalled(mockPlatform.launchUrl(expectedUrl, any)); + + verify(mockPlatform.launchUrl(expectedUrl, any)).called(1); + }); + + test( + 'launchWhatsApp should format Egyptian numbers correctly and launch', + () async { + const phoneNumber = '01012345678'; + const expectedUrl = 'whatsapp://send?phone=201012345678'; + + when( + mockPlatform.launchUrl(expectedUrl, any), + ).thenAnswer((_) async => true); + + AppLauncher.launchWhatsApp(phoneNumber); + + await untilCalled(mockPlatform.launchUrl(expectedUrl, any)); + + verify(mockPlatform.launchUrl(expectedUrl, any)).called(1); + }, + ); + + test('launchWhatsApp should strip non-numeric characters', () async { + const phoneNumber = '+20 (123) 456-789'; + const expectedUrl = 'whatsapp://send?phone=20123456789'; + + when( + mockPlatform.launchUrl(expectedUrl, any), + ).thenAnswer((_) async => true); + + AppLauncher.launchWhatsApp(phoneNumber); + await untilCalled(mockPlatform.launchUrl(expectedUrl, any)); + + verify(mockPlatform.launchUrl(expectedUrl, any)).called(1); + }); + }); +} diff --git a/test/app/core/utils/validators_helper_test.dart b/test/app/core/utils/validators_helper_test.dart new file mode 100644 index 0000000..595ca7e --- /dev/null +++ b/test/app/core/utils/validators_helper_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/app/core/utils/validators_helper.dart'; +import 'package:tracking_app/app/core/values/user_error_mesagges.dart'; + +void main() { + group('Validators Tests', () { + group('validateEmail', () { + test('should return error if email is empty', () { + expect(Validators.validateEmail(''), UserErrorMessages.emailRequired); + expect(Validators.validateEmail(null), UserErrorMessages.emailRequired); + }); + + test('should return error if email format is invalid', () { + expect( + Validators.validateEmail('test'), + UserErrorMessages.invalidEmail, + ); + expect( + Validators.validateEmail('test@'), + UserErrorMessages.invalidEmail, + ); + expect( + Validators.validateEmail('test@domain'), + UserErrorMessages.invalidEmail, + ); + }); + + test('should return null if email is valid', () { + expect(Validators.validateEmail('test@example.com'), null); + }); + }); + + group('validatePassword', () { + test('should return error if password is empty', () { + expect( + Validators.validatePassword(''), + UserErrorMessages.passwordRequired, + ); + }); + + test('should return error if password < 6 characters', () { + expect( + Validators.validatePassword('Ab1'), + UserErrorMessages.least6Characters, + ); + }); + + test('should return error if no capital letter', () { + expect( + Validators.validatePassword('abc12345'), + UserErrorMessages.passwordWithCapital, + ); + }); + + test('should return error if no number', () { + expect( + Validators.validatePassword('Abcdefgh'), + UserErrorMessages.passwordWithNumber, + ); + }); + + test('should return null if password is valid', () { + expect(Validators.validatePassword('Password123'), null); + }); + }); + + group('validateRePassword', () { + test('should return error if confirm password is empty', () { + expect( + Validators.validateRePassword('', 'Password123'), + UserErrorMessages.confirmPassword, + ); + }); + + test('should return error if passwords do not match', () { + expect( + Validators.validateRePassword('123', '456'), + UserErrorMessages.passwordDontMatch, + ); + }); + + test('should return null if passwords match', () { + expect(Validators.validateRePassword('Pass123', 'Pass123'), null); + }); + }); + + group('validatePhone', () { + test('should return error if phone is empty', () { + expect(Validators.validatePhone(''), UserErrorMessages.phoneRequired); + }); + + test( + 'should return error if phone format is invalid (Egyptian format)', + () { + expect( + Validators.validatePhone('12345678901'), + UserErrorMessages.invalidNumber, + ); // No 01 at start + expect( + Validators.validatePhone('0101234567'), + UserErrorMessages.invalidNumber, + ); // Too short + }, + ); + + test('should return null if phone is valid', () { + expect(Validators.validatePhone('01012345678'), null); + expect(Validators.validatePhone('01112345678'), null); + }); + }); + + group('validateName / RecipientName / Address', () { + test('validateName should catch special characters and length', () { + expect( + Validators.validateName('ab'), + UserErrorMessages.least3Characters, + ); + expect(Validators.validateName('John@'), UserErrorMessages.invalidName); + expect(Validators.validateName('John Doe'), null); + }); + + test('validateRecipientName should return specific error messages', () { + expect( + Validators.validateRecipientName(''), + UserErrorMessages.requiredRecipientName, + ); + expect( + Validators.validateRecipientName('Al!'), + UserErrorMessages.invalidRecipientName, + ); + }); + + test('validateAddress should return specific error messages', () { + expect( + Validators.validateAddress(''), + UserErrorMessages.requiredAddress, + ); + expect( + Validators.validateAddress('Cairo#5'), + UserErrorMessages.invalidAddress, + ); + expect(Validators.validateAddress('Maadi, Cairo'), null); + }); + }); + }); +} diff --git a/test/features/Onboarding/presentation/pages/onboardingScreen_test.dart b/test/features/Onboarding/presentation/pages/onboardingScreen_test.dart new file mode 100644 index 0000000..335f250 --- /dev/null +++ b/test/features/Onboarding/presentation/pages/onboardingScreen_test.dart @@ -0,0 +1,73 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tracking_app/features/Onboarding/presentation/pages/onboardingScreen.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; + +import 'onboardingScreen_test.mocks.dart'; + +class MockAssetLoader extends AssetLoader { + @override + Future> load(String path, Locale locale) async { + return { + "onboardingTitle": "Welcome to ", + "onboardingDescription": "Flowery rider app ", + "login": "Login", + "applyNow": "Apply Now", + }; + } +} + +@GenerateMocks([AuthRepo]) +void main() { + late MockAuthRepo mockAuthRepo; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + }); + + setUp(() { + mockAuthRepo = MockAuthRepo(); + }); + + Widget createWidgetUnderTest() { + return EasyLocalization( + supportedLocales: const [Locale('en')], + path: 'assets/translations', + assetLoader: MockAssetLoader(), + startLocale: const Locale('en'), + child: Builder( + builder: (context) { + return MaterialApp( + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + home: const Onboardingscreen(), + ); + }, + ), + ); + } + + group('Onboardingscreen Widget Test', () { + testWidgets('renders all UI elements correctly', ( + WidgetTester tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + }); + + expect(find.byType(Image), findsOneWidget); + expect(find.text('Welcome to '), findsOneWidget); + expect(find.text('Flowery rider app '), findsOneWidget); + + expect(find.text('Login'), findsOneWidget); + expect(find.text('Apply Now'), findsOneWidget); + }); + }); +} diff --git a/test/features/app_sections/presentation/manager/app_section_cubit_test.dart b/test/features/app_sections/presentation/manager/app_section_cubit_test.dart new file mode 100644 index 0000000..82f8197 --- /dev/null +++ b/test/features/app_sections/presentation/manager/app_section_cubit_test.dart @@ -0,0 +1,85 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/app_sections/presentation/manager/app_section_cubit.dart'; +import 'package:tracking_app/features/app_sections/presentation/manager/app_section_states.dart'; + +void main() { + late AppSectionCubit cubit; + + setUp(() { + cubit = AppSectionCubit(); + }); + tearDown(() async { + await cubit.close(); + }); + + group('App section cubit', () { + blocTest( + 'emits index 0 when updateIndex(0) is called', + build: () => cubit, + act: (cubit) => cubit.updateIndex(0), + expect: () => [ + isA().having( + (s) => s.selectedIndex, + 'selectedIndex', + 0, + ), + ], + ); + + blocTest( + 'emits index 1 when updateIndex(1) is called', + build: () => cubit, + act: (cubit) => cubit.updateIndex(1), + expect: () => [ + isA().having( + (s) => s.selectedIndex, + 'selectedIndex', + 1, + ), + ], + ); + + blocTest( + 'emits index 2 when updateIndex(2) is called', + build: () => cubit, + act: (cubit) => cubit.updateIndex(2), + expect: () => [ + isA().having( + (s) => s.selectedIndex, + 'selectedIndex', + 2, + ), + ], + ); + + blocTest( + 'does not emit when updating with the same index', + build: () => cubit, + seed: () => AppSectionStates(selectedIndex: 2), + act: (cubit) => cubit.updateIndex(2), + expect: () => [ + isA().having( + (s) => s.selectedIndex, + 'selectedIndex', + 2, + ), + ], + ); + + blocTest( + 'emits correct states when updateIndex is called multiple times', + build: () => cubit, + act: (cubit) { + cubit.updateIndex(0); + cubit.updateIndex(1); + cubit.updateIndex(2); + }, + expect: () => [ + isA().having((s) => s.selectedIndex, 'index', 0), + isA().having((s) => s.selectedIndex, 'index', 1), + isA().having((s) => s.selectedIndex, 'index', 2), + ], + ); + }); +} diff --git a/test/features/app_sections/presentation/widgets/app_section_view_test.dart b/test/features/app_sections/presentation/widgets/app_section_view_test.dart new file mode 100644 index 0000000..58c58f6 --- /dev/null +++ b/test/features/app_sections/presentation/widgets/app_section_view_test.dart @@ -0,0 +1,132 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/features/app_sections/presentation/manager/app_section_cubit.dart'; +import 'package:tracking_app/features/app_sections/presentation/manager/app_section_states.dart'; +import 'package:tracking_app/features/app_sections/presentation/pages/home_page_test.dart'; +import 'package:tracking_app/features/app_sections/presentation/pages/orders_page_test.dart'; +import 'package:tracking_app/features/app_sections/presentation/pages/profile_page_test.dart'; +import 'package:tracking_app/features/app_sections/presentation/widgets/app_section_view.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderCubit.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderStates.dart'; +import 'package:tracking_app/features/home/presentation/pages/driverOrderScreen.dart'; +import 'package:tracking_app/features/profile/presentation/pages/profile_page.dart'; + +import 'app_section_view_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec(), MockSpec()]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late MockAppSectionCubit mockAppSectionCubit; + late MockDriverOrderCubit mockDriverOrderCubit; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + }); + + setUp(() { + mockAppSectionCubit = MockAppSectionCubit(); + mockDriverOrderCubit = MockDriverOrderCubit(); + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerFactory(() => mockDriverOrderCubit); + }); + + tearDown(() { + if (getIt.isRegistered()) { + getIt.unregister(); + } + }); + + Widget buildTestableWidget() { + return EasyLocalization( + supportedLocales: const [Locale('en')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: MaterialApp( + home: MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => mockAppSectionCubit), + BlocProvider(create: (_) => mockDriverOrderCubit), + ], + child: AppSectionsView(), + ), + ), + ); + } + + group('AppSectionsView Widget Test', () { + testWidgets('should show DriverOrderScreen by default (index 0)', ( + WidgetTester tester, + ) async { + when( + mockAppSectionCubit.state, + ).thenReturn(AppSectionStates(selectedIndex: 0)); + when(mockAppSectionCubit.stream).thenAnswer( + (_) => + Stream.value(AppSectionStates(selectedIndex: 0)), + ); + + // Stub DriverOrderCubit + when( + mockDriverOrderCubit.state, + ).thenReturn(DriverOrderState(orderResource: Resource.loading())); + when( + mockDriverOrderCubit.stream, + ).thenAnswer((_) => Stream.empty()); + + await tester.pumpWidget(buildTestableWidget()); + // No tap needed for default + + expect(find.byType(DriverOrderScreen), findsOneWidget); + }); + + // testWidgets('should navigate to Orders page when tapping Orders', ( + // WidgetTester tester, + // ) async { + // when( + // mockAppSectionCubit.state, + // ).thenReturn(AppSectionStates(selectedIndex: 1)); + // when(mockAppSectionCubit.stream).thenAnswer( + // (_) => + // Stream.value(AppSectionStates(selectedIndex: 1)), + // ); + + // // Stub DriverOrderCubit just in case (though not used in index 1 view) + // when( + // mockDriverOrderCubit.state, + // ).thenReturn(DriverOrderState(orderResource: Resource.loading())); + // when( + // mockDriverOrderCubit.stream, + // ).thenAnswer((_) => Stream.empty()); + + // await tester.pumpWidget(buildTestableWidget()); + // await tester.tap(find.byIcon(Icons.fact_check_outlined)); + // await tester.pump(); + + // expect(find.byType(OrdersPageTest), findsOneWidget); + // }); + + // testWidgets('should navigate to Profile page when tapping Profile', ( + // WidgetTester tester, + // ) async { + // when(mockAppSectionCubit.state).thenReturn(AppSectionStates(selectedIndex: 2)); + // when(mockAppSectionCubit.stream).thenAnswer( + // (_) => Stream.value(AppSectionStates(selectedIndex: 2)), + // ); + // await tester.pumpWidget(buildTestableWidget()); + // await tester.tap(find.byIcon(Icons.person_outlined)); + // await tester.pump(); + // expect(find.byType(ProfilePage), findsOneWidget); + // }); + }); +} diff --git a/test/features/auth/api/datasource/auth_remote_datasource_impl_test.dart b/test/features/auth/api/datasource/auth_remote_datasource_impl_test.dart new file mode 100644 index 0000000..3b78f8f --- /dev/null +++ b/test/features/auth/api/datasource/auth_remote_datasource_impl_test.dart @@ -0,0 +1,336 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:retrofit/retrofit.dart'; +import 'package:tracking_app/app/core/api_manger/api_client.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/api/datasource/auth_remote_datasource_impl.dart'; +import 'package:tracking_app/features/auth/data/model/request/LoginRequest.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:tracking_app/features/auth/data/model/response/change_password_dto.dart'; +import 'package:tracking_app/features/auth/data/models/request/forget_password_request.dart'; +import 'package:tracking_app/features/auth/data/models/request/resetpassword_request.dart'; +import 'package:tracking_app/features/auth/data/models/request/verifyreset_request.dart'; +import 'package:tracking_app/features/auth/data/models/response/forgetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/resetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/verifyreset_response.dart'; +import 'auth_remote_datasource_impl_test.mocks.dart'; + +@GenerateMocks([ApiClient]) +void main() { + late MockApiClient mockApiClient; + late AuthRemoteDataSourceImpl authRemoteDataSourceImpl; + late AuthRemoteDataSourceImpl + dataSource; // initialize for login/change password tests + + setUpAll(() { + mockApiClient = MockApiClient(); + authRemoteDataSourceImpl = AuthRemoteDataSourceImpl(mockApiClient); + dataSource = AuthRemoteDataSourceImpl(mockApiClient); + provideDummy>( + SuccessApiResult(data: ChangePasswordDto()), + ); + provideDummy>( + ErrorApiResult(error: ''), + ); + }); + + final forgetPasswordRequest = ForgetPasswordRequest( + email: "test@example.com", + ); + + group("AuthRemoteDatasourceImpl.forgetPassword()", () { + test( + "returns SuccessApiResult when apiClient returns valid response", + () async { + final expectedResponse = ForgetpasswordResponse( + message: "Password reset code sent to email", + ); + final dioResponse = Response( + requestOptions: RequestOptions(path: '/forget-password'), + data: expectedResponse, + statusCode: 200, + ); + final fakeHttpResponse = HttpResponse( + dioResponse.data!, + dioResponse, + ); + + when( + mockApiClient.forgetPassword(any), + ).thenAnswer((_) async => fakeHttpResponse); + + final result = await authRemoteDataSourceImpl.forgetPassword( + forgetPasswordRequest, + ); + + expect(result, isA>()); + final successResult = + result as SuccessApiResult; + expect(successResult.data.message, "Password reset code sent to email"); + verify(mockApiClient.forgetPassword(any)).called(1); + }, + ); + + test("returns ErrorApiResult when apiClient throws Exception", () async { + when( + mockApiClient.forgetPassword(any), + ).thenThrow(Exception("Network Error")); + + final result = await authRemoteDataSourceImpl.forgetPassword( + forgetPasswordRequest, + ); + + expect(result, isA()); + final errorResult = result as ErrorApiResult; + expect(errorResult.error, contains("Network Error")); + verify(mockApiClient.forgetPassword(any)).called(1); + }); + }); + + group("AuthRemoteDatasourceImpl.resetPassword()", () { + final resetPasswordRequest = ResetPasswordRequest( + email: "test@example.com", + newPassword: "12345678", + ); + + test( + "returns SuccessApiResult when apiClient returns valid response", + () async { + final expectedResponse = ResetpasswordResponse( + message: "Password reset successfully", + ); + final dioResponse = Response( + requestOptions: RequestOptions(path: '/reset-password'), + data: expectedResponse, + statusCode: 200, + ); + final fakeHttpResponse = HttpResponse( + dioResponse.data!, + dioResponse, + ); + + when( + mockApiClient.resetPassword(any), + ).thenAnswer((_) async => fakeHttpResponse); + + final result = await authRemoteDataSourceImpl.resetPassword( + resetPasswordRequest, + ); + + expect(result, isA>()); + final successResult = result as SuccessApiResult; + expect(successResult.data.message, "Password reset successfully"); + verify(mockApiClient.resetPassword(any)).called(1); + }, + ); + + test("returns ErrorApiResult when apiClient throws Exception", () async { + when( + mockApiClient.resetPassword(any), + ).thenThrow(Exception("Reset failed")); + + final result = await authRemoteDataSourceImpl.resetPassword( + resetPasswordRequest, + ); + + expect(result, isA()); + final errorResult = result as ErrorApiResult; + expect(errorResult.error, contains("Reset failed")); + verify(mockApiClient.resetPassword(any)).called(1); + }); + }); + + group("AuthRemoteDatasourceImpl.verifyResetCode()", () { + final verifyResetCodeRequest = VerifyResetRequest(resetCode: "1234"); + + test( + "returns SuccessApiResult when apiClient returns valid response", + () async { + final expectedResponse = VerifyresetResponse( + status: "Code verified successfully", + ); + final dioResponse = Response( + requestOptions: RequestOptions(path: '/verify-reset-code'), + data: expectedResponse, + statusCode: 200, + ); + final fakeHttpResponse = HttpResponse( + dioResponse.data!, + dioResponse, + ); + + when( + mockApiClient.verifyResetCode(any), + ).thenAnswer((_) async => fakeHttpResponse); + + final result = await authRemoteDataSourceImpl.verifyResetCode( + verifyResetCodeRequest, + ); + + expect(result, isA>()); + final successResult = result as SuccessApiResult; + expect(successResult.data.status, "Code verified successfully"); + verify(mockApiClient.verifyResetCode(any)).called(1); + }, + ); + + test("returns ErrorApiResult when apiClient throws Exception", () async { + when( + mockApiClient.verifyResetCode(any), + ).thenThrow(Exception("Invalid code")); + + final result = await authRemoteDataSourceImpl.verifyResetCode( + verifyResetCodeRequest, + ); + + expect(result, isA()); + final errorResult = result as ErrorApiResult; + expect(errorResult.error, contains("Invalid code")); + verify(mockApiClient.verifyResetCode(any)).called(1); + }); + }); + + // ---------- login ---------- + final tLoginRequest = LoginRequest( + email: 'test@example.com', + password: 'password123', + ); + final tLoginResponse = LoginResponse(token: 'token123', message: 'Success'); + + group('AuthRemoteDataSourceImpl.login', () { + test('should return SuccessApiResult when login is successful', () async { + when(mockApiClient.login(any)).thenAnswer((_) async => tLoginResponse); + final result = await dataSource.login(tLoginRequest); + expect(result, isA>()); + expect((result as SuccessApiResult).data, tLoginResponse); + verify(mockApiClient.login(tLoginRequest)).called(1); + }); + + test( + 'should return ErrorApiResult with "wrongEmailOrPassword" on 401 error', + () async { + when(mockApiClient.login(any)).thenThrow( + DioException( + requestOptions: RequestOptions(path: ''), + response: Response( + requestOptions: RequestOptions(path: ''), + statusCode: 401, + ), + ), + ); + final result = await dataSource.login(tLoginRequest); + expect(result, isA>()); + expect( + (result as ErrorApiResult).error, + 'wrongEmailOrPassword', + ); + }, + ); + + test( + 'should return ErrorApiResult with message from response on other DioErrors', + () async { + const tErrorMessage = 'Some other error'; + when(mockApiClient.login(any)).thenThrow( + DioException( + requestOptions: RequestOptions(path: ''), + response: Response( + requestOptions: RequestOptions(path: ''), + statusCode: 400, + data: {'message': tErrorMessage}, + ), + ), + ); + final result = await dataSource.login(tLoginRequest); + expect(result, isA>()); + expect((result as ErrorApiResult).error, tErrorMessage); + }, + ); + + test( + 'should return ErrorApiResult with exception message on unknown error', + () async { + const tExceptionMessage = 'Exception: Unknown error'; + when(mockApiClient.login(any)).thenThrow(Exception('Unknown error')); + final result = await dataSource.login(tLoginRequest); + expect(result, isA>()); + expect( + (result as ErrorApiResult).error, + tExceptionMessage, + ); + }, + ); + }); + + group("AuthRemoteDatasourceImpl.changePassword()", () { + test('should return ApiSuccess when change password succeeds', () async { + final fakeDto = ChangePasswordDto( + message: 'Success', + token: 'fake_token', + error: null, + ); + final fakeResponse = HttpResponse( + fakeDto, + Response( + requestOptions: RequestOptions(path: '/change-password'), + statusCode: 200, + ), + ); + when( + mockApiClient.changePassword( + token: 'Bearer fake_token', + body: {'password': 'Mm@123456', 'newPassword': "Mmmmmm@1"}, + ), + ).thenAnswer((_) async => fakeResponse); + + final result = + await dataSource.changePassword( + token: 'fake_token', + password: 'Mm@123456', + newPassword: "Mmmmmm@1", + ) + as SuccessApiResult; + + expect(result, isA>()); + expect(result.data.token, fakeDto.token); + expect(result.data.message, fakeDto.message); + verify( + mockApiClient.changePassword( + token: 'Bearer fake_token', + body: {'password': 'Mm@123456', 'newPassword': "Mmmmmm@1"}, + ), + ).called(1); + }); + + test( + 'should return ApiFailure when change password throws exception', + () async { + when( + mockApiClient.changePassword( + token: anyNamed('token'), + body: anyNamed('body'), + ), + ).thenThrow(Exception('Network error')); + + final result = + await dataSource.changePassword( + token: 'fake_token', + password: 'Mariam@123', + newPassword: "Mariam@1234", + ) + as ErrorApiResult; + + expect(result, isA>()); + expect(result.error.toString(), contains("Network error")); + verify( + mockApiClient.changePassword( + token: 'Bearer fake_token', + body: {'password': 'Mariam@123', 'newPassword': "Mariam@1234"}, + ), + ).called(1); + }, + ); + }); +} diff --git a/test/features/auth/data/mapper/change_password_dto_mapper_test.dart b/test/features/auth/data/mapper/change_password_dto_mapper_test.dart new file mode 100644 index 0000000..167ca64 --- /dev/null +++ b/test/features/auth/data/mapper/change_password_dto_mapper_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/auth/data/mapper/change_password_dto_mapper.dart'; +import 'package:tracking_app/features/auth/data/model/response/change_password_dto.dart'; +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; + +void main() { + group('ChangePasswordDtoMapper', () { + test('should map ChangePasswordDto to ChangePasswordModel correctly', () { + final dto = ChangePasswordDto( + message: 'change pass successfully', + error: 'error', + token: '', + ); + + final result = dto.toChangePassModel(); + + expect(result, isA()); + expect(result.message, 'change pass successfully'); + expect(result.error, dto.error); + }); + }); +} diff --git a/test/features/auth/data/model/request/LoginRequest_test.dart b/test/features/auth/data/model/request/LoginRequest_test.dart new file mode 100644 index 0000000..3507a2f --- /dev/null +++ b/test/features/auth/data/model/request/LoginRequest_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/auth/data/model/request/LoginRequest.dart'; + +void main() { + const tEmail = 'test@example.com'; + const tPassword = 'password123'; + final tLoginRequest = LoginRequest(email: tEmail, password: tPassword); + + group('LoginRequest', () { + test('should be a subclass of LoginRequest entity', () async { + // assert + expect(tLoginRequest, isA()); + }); + + test('fromJson should return a valid model', () async { + // arrange + final Map jsonMap = { + "email": tEmail, + "password": tPassword, + }; + + // act + final result = LoginRequest.fromJson(jsonMap); + + // assert + expect(result.email, tEmail); + expect(result.password, tPassword); + }); + + test('toJson should return a JSON map containing proper data', () async { + // act + final result = tLoginRequest.toJson(); + + // assert + final expectedMap = {"email": tEmail, "password": tPassword}; + expect(result, expectedMap); + }); + }); +} diff --git a/test/features/auth/data/model/response/LoginResponse_test.dart b/test/features/auth/data/model/response/LoginResponse_test.dart new file mode 100644 index 0000000..daab2dd --- /dev/null +++ b/test/features/auth/data/model/response/LoginResponse_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; + +void main() { + const tMessage = 'Success'; + const tToken = 'token123'; + final tLoginResponse = LoginResponse(message: tMessage, token: tToken); + + group('LoginResponse', () { + test('should be a subclass of LoginResponse entity', () async { + // assert + expect(tLoginResponse, isA()); + }); + + test('fromJson should return a valid model', () async { + // arrange + final Map jsonMap = { + "message": tMessage, + "token": tToken, + }; + + // act + final result = LoginResponse.fromJson(jsonMap); + + // assert + expect(result.message, tMessage); + expect(result.token, tToken); + }); + + test('toJson should return a JSON map containing proper data', () async { + // act + final result = tLoginResponse.toJson(); + + // assert + final expectedMap = {"message": tMessage, "token": tToken}; + expect(result, expectedMap); + }); + }); +} diff --git a/test/features/auth/data/model/response/change_password_dto_test.dart b/test/features/auth/data/model/response/change_password_dto_test.dart new file mode 100644 index 0000000..97f7aa6 --- /dev/null +++ b/test/features/auth/data/model/response/change_password_dto_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/auth/data/model/response/change_password_dto.dart'; + +void main() { + group('ChangePasswordDto Json serialization', () { + test('fromJson should parse correctly', () { + final json = { + 'message': 'change pass successfully', + 'token': '', + 'error': null, + }; + + final result = ChangePasswordDto.fromJson(json); + expect(result.message, 'change pass successfully'); + }); + test('toJson should parse correctly', () { + final dto = ChangePasswordDto( + message: 'success', + error: 'error message', + token: '', + ); + + expect(dto.message, 'success'); + expect(dto.error, 'error message'); + }); + }); +} diff --git a/test/features/auth/data/models/response/forgetpassword_response_test.dart b/test/features/auth/data/models/response/forgetpassword_response_test.dart new file mode 100644 index 0000000..206333e --- /dev/null +++ b/test/features/auth/data/models/response/forgetpassword_response_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/auth/data/models/response/forgetpassword_response.dart'; + +void main() { + group("ForgetpasswordResponse", () { + test("fromJson should parse correctly", () { + // Arrange + final json = {"message": "Reset email sent", "info": "Check your inbox"}; + + // Act + final model = ForgetpasswordResponse.fromJson(json); + + // Assert + expect(model.message, "Reset email sent"); + expect(model.info, "Check your inbox"); + }); + + test("toJson should return correct map", () { + // Arrange + final model = ForgetpasswordResponse( + message: "Reset email sent", + info: "Check your inbox", + ); + + // Act + final json = model.toJson(); + + // Assert + expect(json["message"], "Reset email sent"); + expect(json["info"], "Check your inbox"); + }); + + test("copyWith should override only provided fields", () { + // Arrange + final model = ForgetpasswordResponse( + message: "Old message", + info: "Old info", + ); + + // Act + final updatedModel = model.copyWith(message: "New message"); + + // Assert + expect(updatedModel.message, "New message"); + expect(updatedModel.info, "Old info"); // unchanged + }); + + test("should handle null values correctly", () { + // Arrange + final model = ForgetpasswordResponse(); + + // Assert + expect(model.message, null); + expect(model.info, null); + + final json = model.toJson(); + expect(json.containsKey("message"), true); + expect(json.containsKey("info"), true); + }); + }); +} diff --git a/test/features/auth/data/models/response/resetpassword_response_test.dart b/test/features/auth/data/models/response/resetpassword_response_test.dart new file mode 100644 index 0000000..3c37ce3 --- /dev/null +++ b/test/features/auth/data/models/response/resetpassword_response_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/auth/data/models/response/resetpassword_response.dart'; + +void main() { + group("ResetpasswordResponse", () { + test("fromJson should parse correctly", () { + // Arrange + final json = { + "message": "Password reset successful", + "token": "abc123token", + }; + + // Act + final model = ResetpasswordResponse.fromJson(json); + + // Assert + expect(model.message, "Password reset successful"); + expect(model.token, "abc123token"); + }); + + test("toJson should return correct map", () { + // Arrange + final model = ResetpasswordResponse( + message: "Password reset successful", + token: "abc123token", + ); + + // Act + final json = model.toJson(); + + // Assert + expect(json["message"], "Password reset successful"); + expect(json["token"], "abc123token"); + }); + + test("copyWith should override only provided fields", () { + // Arrange + final model = ResetpasswordResponse( + message: "Old message", + token: "oldToken", + ); + + // Act + final updated = model.copyWith(message: "New message"); + + // Assert + expect(updated.message, "New message"); + expect(updated.token, "oldToken"); // unchanged + }); + + test("should handle null values", () { + final model = ResetpasswordResponse(); + + expect(model.message, null); + expect(model.token, null); + + final json = model.toJson(); + expect(json.containsKey("message"), true); + expect(json.containsKey("token"), true); + }); + }); +} diff --git a/test/features/auth/data/models/response/verifyreset_response_test.dart b/test/features/auth/data/models/response/verifyreset_response_test.dart new file mode 100644 index 0000000..4eb8623 --- /dev/null +++ b/test/features/auth/data/models/response/verifyreset_response_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/auth/data/models/response/verifyreset_response.dart'; + +void main() { + group("VerifyresetResponse", () { + test("fromJson should parse correctly", () { + // Arrange + final json = {"status": "verified"}; + + // Act + final model = VerifyresetResponse.fromJson(json); + + // Assert + expect(model.status, "verified"); + }); + + test("toJson should return correct map", () { + // Arrange + final model = VerifyresetResponse(status: "verified"); + + // Act + final json = model.toJson(); + + // Assert + expect(json["status"], "verified"); + }); + + test("copyWith should override provided field", () { + // Arrange + final model = VerifyresetResponse(status: "pending"); + + // Act + final updated = model.copyWith(status: "verified"); + + // Assert + expect(updated.status, "verified"); + }); + + test("should handle null values", () { + final model = VerifyresetResponse(); + + expect(model.status, null); + + final json = model.toJson(); + expect(json.containsKey("status"), true); + }); + }); +} diff --git a/test/features/auth/data/repos/auth_repo_impl_test.dart b/test/features/auth/data/repos/auth_repo_impl_test.dart new file mode 100644 index 0000000..98ecf25 --- /dev/null +++ b/test/features/auth/data/repos/auth_repo_impl_test.dart @@ -0,0 +1,311 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/datasource/auth_remote_datasource.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:tracking_app/features/auth/data/model/response/change_password_dto.dart'; +import 'package:tracking_app/features/auth/data/models/request/resetpassword_request.dart'; +import 'package:tracking_app/features/auth/data/models/response/forgetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/resetpassword_response.dart'; +import 'package:tracking_app/features/auth/data/models/response/verifyreset_response.dart'; +import 'package:tracking_app/features/auth/data/repos/auth_repo_impl.dart'; +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; +import 'package:tracking_app/features/auth/domain/models/forgetpassword_entitiy.dart'; +import 'package:tracking_app/features/auth/domain/models/resetpassword_entity.dart'; +import 'package:tracking_app/features/auth/domain/models/verifyreset_entity.dart'; + +import 'auth_repo_impl_test.mocks.dart'; + +@GenerateMocks([AuthRemoteDataSource]) +void main() { + late MockAuthRemoteDataSource datasource; + late AuthRepoImpl repo; + + late MockAuthRemoteDataSource + mockDataSource; // for login/changePassword tests + late AuthRepoImpl repoImp; + + setUpAll(() { + // Provide dummy data for generics + provideDummy>( + SuccessApiResult( + data: ForgetpasswordResponse(message: '', info: ''), + ), + ); + provideDummy>( + SuccessApiResult( + data: VerifyresetResponse(status: ''), + ), + ); + provideDummy>( + SuccessApiResult( + data: ResetpasswordResponse(message: '', token: ''), + ), + ); + provideDummy>( + SuccessApiResult( + data: LoginResponse(token: 'dummy', message: 'dummy'), + ), + ); + provideDummy>( + SuccessApiResult(data: ChangePasswordDto()), + ); + }); + + setUp(() { + datasource = MockAuthRemoteDataSource(); + repo = AuthRepoImpl(datasource); + + mockDataSource = MockAuthRemoteDataSource(); + repoImp = AuthRepoImpl(mockDataSource); + }); + + // ============================================================ + // forgetPassword + // ============================================================ + group("forgetPassword", () { + const email = "test@mail.com"; + + test("should return SuccessApiResult when datasource succeeds", () async { + final fakeDto = ForgetpasswordResponse( + message: "Email sent", + info: "Check inbox", + ); + + when(datasource.forgetPassword(any)).thenAnswer( + (_) async => SuccessApiResult(data: fakeDto), + ); + + final result = await repo.forgetPassword(email); + + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.message, "Email sent"); + expect(data.info, "Check inbox"); + + verify(datasource.forgetPassword(any)).called(1); + }); + + test("should return ErrorApiResult when datasource fails", () async { + when(datasource.forgetPassword(any)).thenAnswer( + (_) async => + ErrorApiResult(error: "Network error"), + ); + + final result = await repo.forgetPassword(email); + + expect(result, isA>()); + expect((result as ErrorApiResult).error, "Network error"); + + verify(datasource.forgetPassword(any)).called(1); + }); + }); + + // ============================================================ + // verifyResetCode + // ============================================================ + group("verifyResetCode", () { + const code = "123456"; + + test("should return SuccessApiResult when datasource succeeds", () async { + final fakeDto = VerifyresetResponse(status: "verified"); + + when(datasource.verifyResetCode(any)).thenAnswer( + (_) async => SuccessApiResult(data: fakeDto), + ); + + final result = await repo.verifyResetCode(code); + + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.status, "verified"); + + verify(datasource.verifyResetCode(any)).called(1); + }); + + test("should return ErrorApiResult when datasource fails", () async { + when(datasource.verifyResetCode(any)).thenAnswer( + (_) async => ErrorApiResult(error: "Invalid code"), + ); + + final result = await repo.verifyResetCode(code); + + expect(result, isA>()); + expect((result as ErrorApiResult).error, "Invalid code"); + + verify(datasource.verifyResetCode(any)).called(1); + }); + }); + + // ============================================================ + // resetPassword + // ============================================================ + group("resetPassword", () { + final request = ResetPasswordRequest( + email: "test@mail.com", + newPassword: "12345678", + ); + + test("should return SuccessApiResult when datasource succeeds", () async { + final fakeDto = ResetpasswordResponse( + message: "Password reset", + token: "abc123", + ); + + when(datasource.resetPassword(request)).thenAnswer( + (_) async => SuccessApiResult(data: fakeDto), + ); + + final result = await repo.resetPassword(request); + + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.message, "Password reset"); + expect(data.token, "abc123"); + + verify(datasource.resetPassword(request)).called(1); + }); + + test("should return ErrorApiResult when datasource fails", () async { + when(datasource.resetPassword(request)).thenAnswer( + (_) async => + ErrorApiResult(error: "Server error"), + ); + + final result = await repo.resetPassword(request); + + expect(result, isA>()); + expect((result as ErrorApiResult).error, "Server error"); + + verify(datasource.resetPassword(request)).called(1); + }); + }); + + // ============================================================ + // login + // ============================================================ + const tEmail = 'test@example.com'; + const tPassword = 'password123'; + final tLoginResponse = LoginResponse(token: 'token123', message: 'Success'); + + group('AuthRepoImpl.login', () { + test( + 'should return SuccessApiResult when remote data source call is successful', + () async { + when( + mockDataSource.login(any), + ).thenAnswer((_) async => SuccessApiResult(data: tLoginResponse)); + + final result = await repoImp.login(tEmail, tPassword); + + expect(result, isA>()); + expect( + (result as SuccessApiResult).data, + tLoginResponse, + ); + + verify(mockDataSource.login(any)).called(1); + verifyNoMoreInteractions(mockDataSource); + }, + ); + + test( + 'should return ErrorApiResult when remote data source call fails', + () async { + const tErrorMessage = 'An error occurred'; + when( + mockDataSource.login(any), + ).thenAnswer((_) async => ErrorApiResult(error: tErrorMessage)); + + final result = await repoImp.login(tEmail, tPassword); + + expect(result, isA>()); + expect((result as ErrorApiResult).error, tErrorMessage); + + verify(mockDataSource.login(any)).called(1); + verifyNoMoreInteractions(mockDataSource); + }, + ); + }); + + // ============================================================ + // changePassword + // ============================================================ + group("AuthRepoImpl.changePassword()", () { + test( + 'should return ApiSuccess when changePassword datasource succeeds', + () async { + final fakeDto = ChangePasswordDto( + message: 'Success', + token: 'fake_token', + error: null, + ); + + when( + mockDataSource.changePassword( + token: ('fake_token'), + password: anyNamed('password'), + newPassword: anyNamed('newPassword'), + ), + ).thenAnswer( + (_) async => SuccessApiResult(data: fakeDto), + ); + + final result = + await repoImp.changePassword( + token: 'fake_token', + password: 'Mm@123456', + newPassword: 'Mmmm@123', + ) + as SuccessApiResult; + + expect(result, isA>()); + expect(result.data.token, fakeDto.token); + expect(result.data.message, fakeDto.message); + verify( + mockDataSource.changePassword( + token: ('fake_token'), + password: anyNamed('password'), + newPassword: anyNamed('newPassword'), + ), + ).called(1); + }, + ); + + test( + 'should return ApiFailure when changePassword datasource throws exception', + () async { + when( + mockDataSource.changePassword( + token: ('fake_token'), + password: anyNamed('password'), + newPassword: anyNamed('newPassword'), + ), + ).thenAnswer( + (_) async => + ErrorApiResult(error: 'Network error'), + ); + + final result = + await repoImp.changePassword( + token: 'fake_token', + password: 'Mm@123456', + newPassword: 'Mmmm@123', + ) + as ErrorApiResult; + + expect(result, isA>()); + expect(result.error.toString(), contains("Network error")); + verify( + mockDataSource.changePassword( + token: ('fake_token'), + password: anyNamed('password'), + newPassword: anyNamed('newPassword'), + ), + ).called(1); + }, + ); + }); +} diff --git a/test/features/auth/domain/models/change_password_model_test.dart b/test/features/auth/domain/models/change_password_model_test.dart new file mode 100644 index 0000000..8f39d23 --- /dev/null +++ b/test/features/auth/domain/models/change_password_model_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; + +void main() { + group('ChangePasswordModel', () { + test('should create instance with correct values', () { + final model = ChangePasswordModel( + message: 'Change password successfully', + error: null, + token: '', + ); + + expect(model.message, 'Change password successfully'); + expect(model.error, null); + }); + }); +} diff --git a/test/features/auth/domain/usecase/apply_usecase_test.dart b/test/features/auth/domain/usecase/apply_usecase_test.dart new file mode 100644 index 0000000..fbd5f52 --- /dev/null +++ b/test/features/auth/domain/usecase/apply_usecase_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/models/request/apply_request_model.dart'; +import 'package:tracking_app/features/auth/data/models/response/apply_response_model.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; +import 'package:tracking_app/features/auth/domain/usecase/apply_usecase.dart'; + +// Mock class +class MockAuthRepo extends Mock implements AuthRepo {} + +void main() { + late ApplyUseCase applyUseCase; + late MockAuthRepo mockAuthRepo; + + setUp(() { + mockAuthRepo = MockAuthRepo(); + applyUseCase = ApplyUseCase(mockAuthRepo); + }); + + group('ApplyUseCase -', () { + final testApplyRequest = ApplyRequestModel( + country: 'EG', + firstName: 'John', + lastName: 'Doe', + vehicleType: '1', + vehicleNumber: 'ABC123', + email: 'john@example.com', + phone: '+201234567890', + NID: '12345678901234', + password: 'Password123!', + rePassword: 'Password123!', + gender: 'male', + vehicleLicense: null, + NIDimg: null, + ); + + final testApplyResponse = ApplyResponseModel( + message: 'Application submitted successfully', + token: 'test_token', + id: '123', + email: 'john@example.com', + firstName: 'John', + lastName: 'Doe', + ); + + setUpAll(() { + registerFallbackValue(testApplyRequest); + }); + + test('should return SuccessApiResult when apply succeeds', () async { + // Arrange + when( + () => mockAuthRepo.apply(any()), + ).thenAnswer((_) async => SuccessApiResult(data: testApplyResponse)); + + // Act + final result = await applyUseCase(testApplyRequest); + + // Assert + expect(result, isA>()); + expect((result as SuccessApiResult).data, testApplyResponse); + verify(() => mockAuthRepo.apply(testApplyRequest)).called(1); + }); + + test('should return ErrorApiResult when apply fails', () async { + // Arrange + const errorMessage = 'Network error'; + when( + () => mockAuthRepo.apply(any()), + ).thenAnswer((_) async => ErrorApiResult(error: errorMessage)); + + // Act + final result = await applyUseCase(testApplyRequest); + + // Assert + expect(result, isA>()); + expect((result as ErrorApiResult).error, errorMessage); + verify(() => mockAuthRepo.apply(testApplyRequest)).called(1); + }); + + test( + 'should call repository apply method with correct parameters', + () async { + // Arrange + when( + () => mockAuthRepo.apply(any()), + ).thenAnswer((_) async => SuccessApiResult(data: testApplyResponse)); + + // Act + await applyUseCase(testApplyRequest); + + // Assert + final captured = verify( + () => mockAuthRepo.apply(captureAny()), + ).captured; + expect(captured.length, 1); + final capturedRequest = captured.first as ApplyRequestModel; + expect(capturedRequest.email, testApplyRequest.email); + expect(capturedRequest.firstName, testApplyRequest.firstName); + expect(capturedRequest.lastName, testApplyRequest.lastName); + expect(capturedRequest.phone, testApplyRequest.phone); + }, + ); + }); +} diff --git a/test/features/auth/domain/usecase/change_password_usecase_test.dart b/test/features/auth/domain/usecase/change_password_usecase_test.dart new file mode 100644 index 0000000..095c00b --- /dev/null +++ b/test/features/auth/domain/usecase/change_password_usecase_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; +import 'package:tracking_app/features/auth/domain/usecase/change_password_usecase.dart'; + +import 'change_password_usecase_test.mocks.dart'; + +@GenerateMocks([AuthRepo]) +void main() { + late MockAuthRepo mockRepo; + late ChangePasswordUsecase useCase; + + setUpAll(() { + mockRepo = MockAuthRepo(); + useCase = ChangePasswordUsecase(mockRepo); + provideDummy>( + SuccessApiResult(data: ChangePasswordModel()), + ); + }); + + group("ChangePasswordUseCase", () { + final fakeData = ChangePasswordModel( + message: 'Success', + token: 'fake_token', + error: null, + ); + test("returns SuccessApiResult when repos returns success", () async { + when( + mockRepo.changePassword( + token: ('fake_token'), + password: anyNamed('password'), + newPassword: anyNamed('newPassword'), + ), + ).thenAnswer( + (_) async => SuccessApiResult(data: fakeData), + ); + + final result = + await useCase.call( + token: 'fake_token', + password: 'Test@123', + newPassword: 'Test@1234', + ) + as SuccessApiResult; + + expect(result, isA>()); + expect(result.data.token, fakeData.token); + expect(result.data.message, fakeData.message); + verify( + mockRepo.changePassword( + token: 'fake_token', + password: 'Test@123', + newPassword: 'Test@1234', + ), + ).called(1); + }); + + test("returns ErrorApiResult when repos returns error", () async { + when( + mockRepo.changePassword( + token: ('fake_token'), + password: anyNamed('password'), + newPassword: anyNamed('newPassword'), + ), + ).thenAnswer( + (_) async => ErrorApiResult( + error: 'change password failed', + ), + ); + + final result = + await useCase.call( + token: 'fake_token', + password: 'Test@123', + newPassword: 'Test@1234', + ) + as ErrorApiResult; + + expect(result, isA>()); + expect(result.error, 'change password failed'); + verify( + mockRepo.changePassword( + token: 'fake_token', + password: 'Test@123', + newPassword: 'Test@1234', + ), + ).called(1); + }); + }); +} diff --git a/test/features/auth/domain/usecase/forgetpassword_usecase_test.dart b/test/features/auth/domain/usecase/forgetpassword_usecase_test.dart new file mode 100644 index 0000000..b221227 --- /dev/null +++ b/test/features/auth/domain/usecase/forgetpassword_usecase_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/domain/models/forgetpassword_entitiy.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; +import 'package:tracking_app/features/auth/domain/usecase/forgetpassword_usecase.dart'; + +import 'forgetpassword_usecase_test.mocks.dart'; + +@GenerateMocks([AuthRepo]) +void main() { + late MockAuthRepo mockRepo; + late ForgetPasswordUsecase usecase; + + setUpAll(() { + provideDummy>( + SuccessApiResult( + data: ForgetPasswordEntitiy(message: '', info: ''), + ), + ); + }); + + setUp(() { + mockRepo = MockAuthRepo(); + usecase = ForgetPasswordUsecase(mockRepo); + }); + + group("ForgetPasswordUsecase", () { + const email = "test@mail.com"; + + test("returns SuccessApiResult when repo succeeds", () async { + final entity = ForgetPasswordEntitiy( + message: "Email sent", + info: "Check inbox", + ); + + when(mockRepo.forgetPassword(email)).thenAnswer( + (_) async => SuccessApiResult(data: entity), + ); + + final result = await usecase.call(email); + + expect(result, isA>()); + expect((result as SuccessApiResult).data.message, "Email sent"); + + verify(mockRepo.forgetPassword(email)).called(1); + }); + + test("returns ErrorApiResult when repo fails", () async { + when(mockRepo.forgetPassword(email)).thenAnswer( + (_) async => + ErrorApiResult(error: "Network error"), + ); + + final result = await usecase.call(email); + + expect(result, isA>()); + expect((result as ErrorApiResult).error, "Network error"); + + verify(mockRepo.forgetPassword(email)).called(1); + }); + }); +} diff --git a/test/features/auth/domain/usecase/get_all_vehicles_usecase_test.dart b/test/features/auth/domain/usecase/get_all_vehicles_usecase_test.dart new file mode 100644 index 0000000..17f1985 --- /dev/null +++ b/test/features/auth/domain/usecase/get_all_vehicles_usecase_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/models/response/vehicle_model.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; +import 'package:tracking_app/features/auth/domain/usecase/get_all_vehicles_usecase.dart'; + +// Mock class +class MockAuthRepo extends Mock implements AuthRepo {} + +void main() { + late GetAllVehiclesUseCase getAllVehiclesUseCase; + late MockAuthRepo mockAuthRepo; + + setUp(() { + mockAuthRepo = MockAuthRepo(); + getAllVehiclesUseCase = GetAllVehiclesUseCase(mockAuthRepo); + }); + + group('GetAllVehiclesUseCase -', () { + final testVehicles = [ + VehicleModel(id: '1', type: 'Car'), + VehicleModel(id: '2', type: 'Motorcycle'), + VehicleModel(id: '3', type: 'Bicycle'), + VehicleModel(id: '4', type: 'Van'), + ]; + + test( + 'should return SuccessApiResult when getAllVehicles succeeds', + () async { + // Arrange + when( + () => mockAuthRepo.getAllVehicles(), + ).thenAnswer((_) async => SuccessApiResult(data: testVehicles)); + + // Act + final result = await getAllVehiclesUseCase(); + + // Assert + expect(result, isA>>()); + final successResult = result as SuccessApiResult>; + expect(successResult.data, testVehicles); + expect(successResult.data.length, 4); + expect(successResult.data.first.type, 'Car'); + verify(() => mockAuthRepo.getAllVehicles()).called(1); + }, + ); + + test('should return ErrorApiResult when getAllVehicles fails', () async { + // Arrange + const errorMessage = 'Failed to load vehicles'; + when( + () => mockAuthRepo.getAllVehicles(), + ).thenAnswer((_) async => ErrorApiResult(error: errorMessage)); + + // Act + final result = await getAllVehiclesUseCase(); + + // Assert + expect(result, isA>>()); + expect((result as ErrorApiResult).error, errorMessage); + verify(() => mockAuthRepo.getAllVehicles()).called(1); + }); + + test('should call repository getAllVehicles method', () async { + // Arrange + when( + () => mockAuthRepo.getAllVehicles(), + ).thenAnswer((_) async => SuccessApiResult(data: testVehicles)); + + // Act + await getAllVehiclesUseCase(); + + // Assert + verify(() => mockAuthRepo.getAllVehicles()).called(1); + verifyNoMoreInteractions(mockAuthRepo); + }); + + test('should return empty list when no vehicles available', () async { + // Arrange + when( + () => mockAuthRepo.getAllVehicles(), + ).thenAnswer((_) async => SuccessApiResult(data: const [])); + + // Act + final result = await getAllVehiclesUseCase(); + + // Assert + expect(result, isA>>()); + expect((result as SuccessApiResult).data, isEmpty); + }); + + test('should handle vehicles with null ids', () async { + // Arrange + final vehiclesWithNullId = [ + VehicleModel(id: null, type: 'Car'), + VehicleModel(id: '2', type: 'Motorcycle'), + ]; + when( + () => mockAuthRepo.getAllVehicles(), + ).thenAnswer((_) async => SuccessApiResult(data: vehiclesWithNullId)); + + // Act + final result = await getAllVehiclesUseCase(); + + // Assert + expect(result, isA>>()); + final successResult = result as SuccessApiResult>; + expect(successResult.data.first.id, isNull); + expect(successResult.data.last.id, '2'); + }); + }); +} diff --git a/test/features/auth/domain/usecase/get_countries_usecase_test.dart b/test/features/auth/domain/usecase/get_countries_usecase_test.dart new file mode 100644 index 0000000..ac59fe2 --- /dev/null +++ b/test/features/auth/domain/usecase/get_countries_usecase_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/domain/entities/country_entity.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; +import 'package:tracking_app/features/auth/domain/usecase/get_countries_usecase.dart'; + +// Mock class +class MockAuthRepo extends Mock implements AuthRepo {} + +void main() { + late GetCountriesUseCase getCountriesUseCase; + late MockAuthRepo mockAuthRepo; + + setUp(() { + mockAuthRepo = MockAuthRepo(); + getCountriesUseCase = GetCountriesUseCase(mockAuthRepo); + }); + + group('GetCountriesUseCase -', () { + final testCountries = [ + const CountryEntity( + name: 'Egypt', + isoCode: 'EG', + flag: '🇪🇬', + phoneCode: '20', + ), + const CountryEntity( + name: 'United States', + isoCode: 'US', + flag: '🇺🇸', + phoneCode: '1', + ), + const CountryEntity( + name: 'United Kingdom', + isoCode: 'GB', + flag: '🇬🇧', + phoneCode: '44', + ), + ]; + + test('should return SuccessApiResult when getCountries succeeds', () async { + // Arrange + when( + () => mockAuthRepo.getCountries(), + ).thenAnswer((_) async => SuccessApiResult(data: testCountries)); + + // Act + final result = await getCountriesUseCase(); + + // Assert + expect(result, isA>>()); + final successResult = result as SuccessApiResult>; + expect(successResult.data, testCountries); + expect(successResult.data.length, 3); + expect(successResult.data.first.name, 'Egypt'); + verify(() => mockAuthRepo.getCountries()).called(1); + }); + + test('should return ErrorApiResult when getCountries fails', () async { + // Arrange + const errorMessage = 'Failed to fetch countries'; + when( + () => mockAuthRepo.getCountries(), + ).thenAnswer((_) async => ErrorApiResult(error: errorMessage)); + + // Act + final result = await getCountriesUseCase(); + + // Assert + expect(result, isA>>()); + expect((result as ErrorApiResult).error, errorMessage); + verify(() => mockAuthRepo.getCountries()).called(1); + }); + + test('should call repository getCountries method', () async { + // Arrange + when( + () => mockAuthRepo.getCountries(), + ).thenAnswer((_) async => SuccessApiResult(data: testCountries)); + + // Act + await getCountriesUseCase(); + + // Assert + verify(() => mockAuthRepo.getCountries()).called(1); + verifyNoMoreInteractions(mockAuthRepo); + }); + + test('should return empty list when no countries available', () async { + // Arrange + when( + () => mockAuthRepo.getCountries(), + ).thenAnswer((_) async => SuccessApiResult(data: const [])); + + // Act + final result = await getCountriesUseCase(); + + // Assert + expect(result, isA>>()); + expect((result as SuccessApiResult).data, isEmpty); + }); + }); +} diff --git a/test/features/auth/domain/usecase/login_usecase_test.dart b/test/features/auth/domain/usecase/login_usecase_test.dart new file mode 100644 index 0000000..db64289 --- /dev/null +++ b/test/features/auth/domain/usecase/login_usecase_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; +import 'package:tracking_app/features/auth/domain/usecase/login_usecase.dart'; + +import 'login_usecase_test.mocks.dart'; + +@GenerateMocks([AuthRepo]) +void main() { + late LoginUseCase loginUseCase; + late MockAuthRepo mockAuthRepo; + + setUpAll(() { + provideDummy>( + SuccessApiResult( + data: LoginResponse(token: 'dummy', message: 'dummy'), + ), + ); + }); + + setUp(() { + mockAuthRepo = MockAuthRepo(); + loginUseCase = LoginUseCase(mockAuthRepo); + }); + + const tEmail = 'test@example.com'; + const tPassword = 'password123'; + final tLoginResponse = LoginResponse(token: 'token123', message: 'Success'); + + group('LoginUseCase', () { + test( + 'should return SuccessApiResult when repo call is successful', + () async { + // Arrange + when( + mockAuthRepo.login(tEmail, tPassword), + ).thenAnswer((_) async => SuccessApiResult(data: tLoginResponse)); + + // Act + final result = await loginUseCase(tEmail, tPassword); + + // Assert + expect(result, isA>()); + expect( + (result as SuccessApiResult).data, + tLoginResponse, + ); + verify(mockAuthRepo.login(tEmail, tPassword)).called(1); + verifyNoMoreInteractions(mockAuthRepo); + }, + ); + + test('should return ErrorApiResult when repo call fails', () async { + // Arrange + const tErrorMessage = 'An error occurred'; + when( + mockAuthRepo.login(tEmail, tPassword), + ).thenAnswer((_) async => ErrorApiResult(error: tErrorMessage)); + + // Act + final result = await loginUseCase(tEmail, tPassword); + + // Assert + expect(result, isA>()); + expect((result as ErrorApiResult).error, tErrorMessage); + verify(mockAuthRepo.login(tEmail, tPassword)).called(1); + verifyNoMoreInteractions(mockAuthRepo); + }); + }); +} diff --git a/test/features/auth/domain/usecase/resertpassword_usecase_test.dart b/test/features/auth/domain/usecase/resertpassword_usecase_test.dart new file mode 100644 index 0000000..0f1630f --- /dev/null +++ b/test/features/auth/domain/usecase/resertpassword_usecase_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/models/request/resetpassword_request.dart'; +import 'package:tracking_app/features/auth/domain/models/resetpassword_entity.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; +import 'package:tracking_app/features/auth/domain/usecase/resertpassword_usecase.dart'; + +import 'forgetpassword_usecase_test.mocks.dart'; + +@GenerateMocks([AuthRepo]) +void main() { + late MockAuthRepo mockRepo; + late ResetPasswordUsecase usecase; + + setUpAll(() { + provideDummy>( + SuccessApiResult( + data: ResetPasswordEntity(token: '', message: ''), + ), + ); + }); + + setUp(() { + mockRepo = MockAuthRepo(); + usecase = ResetPasswordUsecase(mockRepo); + }); + + group("ResetPasswordUsecase", () { + final request = ResetPasswordRequest( + email: "test@mail.com", + newPassword: "12345678", + ); + + test("returns SuccessApiResult when repo succeeds", () async { + final entity = ResetPasswordEntity( + token: "abc123", + message: "Password reset", + ); + + when(mockRepo.resetPassword(request)).thenAnswer( + (_) async => SuccessApiResult(data: entity), + ); + + final result = await usecase.call(request); + + expect(result, isA>()); + expect((result as SuccessApiResult).data.token, "abc123"); + + verify(mockRepo.resetPassword(request)).called(1); + }); + + test("returns ErrorApiResult when repo fails", () async { + when(mockRepo.resetPassword(request)).thenAnswer( + (_) async => ErrorApiResult(error: "Server error"), + ); + + final result = await usecase.call(request); + + expect(result, isA>()); + expect((result as ErrorApiResult).error, "Server error"); + + verify(mockRepo.resetPassword(request)).called(1); + }); + }); +} diff --git a/test/features/auth/domain/usecase/verifyreaset_usecase_test.dart b/test/features/auth/domain/usecase/verifyreaset_usecase_test.dart new file mode 100644 index 0000000..6831cf0 --- /dev/null +++ b/test/features/auth/domain/usecase/verifyreaset_usecase_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/domain/models/verifyreset_entity.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; +import 'package:tracking_app/features/auth/domain/usecase/verifyreaset_usecase.dart'; + +import 'forgetpassword_usecase_test.mocks.dart'; + +@GenerateMocks([AuthRepo]) +void main() { + late MockAuthRepo mockRepo; + late VerifyResetCodeUsecase usecase; + + setUpAll(() { + provideDummy>( + SuccessApiResult( + data: VerifyResetCodeEntity(status: ''), + ), + ); + }); + + setUp(() { + mockRepo = MockAuthRepo(); + usecase = VerifyResetCodeUsecase(mockRepo); + }); + + group("VerifyResetCodeUsecase", () { + const code = "123456"; + + test("returns SuccessApiResult when repo succeeds", () async { + final entity = VerifyResetCodeEntity(status: "verified"); + + when(mockRepo.verifyResetCode(code)).thenAnswer( + (_) async => SuccessApiResult(data: entity), + ); + + final result = await usecase.call(code); + + expect(result, isA>()); + expect((result as SuccessApiResult).data.status, "verified"); + + verify(mockRepo.verifyResetCode(code)).called(1); + }); + + test("returns ErrorApiResult when repo fails", () async { + when(mockRepo.verifyResetCode(code)).thenAnswer( + (_) async => + ErrorApiResult(error: "Invalid code"), + ); + + final result = await usecase.call(code); + + expect(result, isA>()); + expect((result as ErrorApiResult).error, "Invalid code"); + + verify(mockRepo.verifyResetCode(code)).called(1); + }); + }); +} diff --git a/test/features/auth/presentation/apply/manager/apply_cubit_test.dart b/test/features/auth/presentation/apply/manager/apply_cubit_test.dart new file mode 100644 index 0000000..7438ce5 --- /dev/null +++ b/test/features/auth/presentation/apply/manager/apply_cubit_test.dart @@ -0,0 +1,231 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/data/models/request/apply_request_model.dart'; +import 'package:tracking_app/features/auth/data/models/response/apply_response_model.dart'; +import 'package:tracking_app/features/auth/data/models/response/vehicle_model.dart'; +import 'package:tracking_app/features/auth/domain/entities/country_entity.dart'; +import 'package:tracking_app/features/auth/domain/usecase/apply_usecase.dart'; +import 'package:tracking_app/features/auth/domain/usecase/get_all_vehicles_usecase.dart'; +import 'package:tracking_app/features/auth/domain/usecase/get_countries_usecase.dart'; +import 'package:tracking_app/features/auth/presentation/apply/manager/apply_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/apply/manager/apply_intent.dart'; +import 'package:tracking_app/features/auth/presentation/apply/manager/apply_state.dart'; + +// Mock classes +class MockGetCountriesUseCase extends Mock implements GetCountriesUseCase {} + +class MockGetAllVehiclesUseCase extends Mock implements GetAllVehiclesUseCase {} + +class MockApplyUseCase extends Mock implements ApplyUseCase {} + +void main() { + late ApplyCubit applyCubit; + late MockGetCountriesUseCase mockGetCountriesUseCase; + late MockGetAllVehiclesUseCase mockGetAllVehiclesUseCase; + late MockApplyUseCase mockApplyUseCase; + + setUp(() { + mockGetCountriesUseCase = MockGetCountriesUseCase(); + mockGetAllVehiclesUseCase = MockGetAllVehiclesUseCase(); + mockApplyUseCase = MockApplyUseCase(); + + applyCubit = ApplyCubit( + mockGetCountriesUseCase, + mockGetAllVehiclesUseCase, + mockApplyUseCase, + ); + }); + + tearDown(() { + applyCubit.close(); + }); + + group('ApplyCubit -', () { + group('GetCountriesIntent', () { + final testCountries = [ + const CountryEntity( + name: 'Egypt', + isoCode: 'EG', + flag: '🇪🇬', + phoneCode: '20', + ), + const CountryEntity( + name: 'United States', + isoCode: 'US', + flag: '🇺🇸', + phoneCode: '1', + ), + ]; + + blocTest( + 'emits [loading, success] when GetCountriesIntent succeeds', + build: () { + when( + () => mockGetCountriesUseCase(), + ).thenAnswer((_) async => SuccessApiResult(data: testCountries)); + return applyCubit; + }, + act: (cubit) => cubit.onIntent(GetCountriesIntent()), + expect: () => [ + const ApplyState(status: ApplyStatus.loading), + ApplyState(status: ApplyStatus.success, countries: testCountries), + ], + verify: (_) { + verify(() => mockGetCountriesUseCase()).called(1); + }, + ); + + blocTest( + 'emits [loading, failure] when GetCountriesIntent fails', + build: () { + when( + () => mockGetCountriesUseCase(), + ).thenAnswer((_) async => ErrorApiResult(error: 'Network error')); + return applyCubit; + }, + act: (cubit) => cubit.onIntent(GetCountriesIntent()), + expect: () => [ + const ApplyState(status: ApplyStatus.loading), + const ApplyState( + status: ApplyStatus.failure, + errorMessage: 'Network error', + ), + ], + verify: (_) { + verify(() => mockGetCountriesUseCase()).called(1); + }, + ); + }); + + group('GetVehiclesIntent', () { + final testVehicles = [ + VehicleModel(id: '1', type: 'Car'), + VehicleModel(id: '2', type: 'Motorcycle'), + ]; + + blocTest( + 'emits [loading, success] when GetVehiclesIntent succeeds', + build: () { + when( + () => mockGetAllVehiclesUseCase(), + ).thenAnswer((_) async => SuccessApiResult(data: testVehicles)); + return applyCubit; + }, + act: (cubit) => cubit.onIntent(GetVehiclesIntent()), + expect: () => [ + const ApplyState(vehiclesStatus: ApplyStatus.loading), + ApplyState( + vehiclesStatus: ApplyStatus.success, + vehicles: testVehicles, + ), + ], + verify: (_) { + verify(() => mockGetAllVehiclesUseCase()).called(1); + }, + ); + + blocTest( + 'emits [loading, failure] when GetVehiclesIntent fails', + build: () { + when(() => mockGetAllVehiclesUseCase()).thenAnswer( + (_) async => ErrorApiResult(error: 'Failed to load vehicles'), + ); + return applyCubit; + }, + act: (cubit) => cubit.onIntent(GetVehiclesIntent()), + expect: () => [ + const ApplyState(vehiclesStatus: ApplyStatus.loading), + const ApplyState( + vehiclesStatus: ApplyStatus.failure, + vehiclesErrorMessage: 'Failed to load vehicles', + ), + ], + verify: (_) { + verify(() => mockGetAllVehiclesUseCase()).called(1); + }, + ); + }); + + group('SubmitApplyIntent', () { + final testApplyRequest = ApplyRequestModel( + country: 'EG', + firstName: 'John', + lastName: 'Doe', + vehicleType: '1', + vehicleNumber: 'ABC123', + email: 'john@example.com', + phone: '+201234567890', + NID: '12345678901234', + password: 'Password123!', + rePassword: 'Password123!', + gender: 'male', + vehicleLicense: null, + NIDimg: null, + ); + + final testApplyResponse = ApplyResponseModel( + message: 'Application submitted successfully', + token: 'test_token', + id: '123', + ); + + setUpAll(() { + registerFallbackValue(testApplyRequest); + }); + + blocTest( + 'emits [loading, success] when SubmitApplyIntent succeeds', + build: () { + when( + () => mockApplyUseCase(any()), + ).thenAnswer((_) async => SuccessApiResult(data: testApplyResponse)); + return applyCubit; + }, + act: (cubit) => cubit.onIntent(SubmitApplyIntent(testApplyRequest)), + expect: () => [ + const ApplyState(applyStatus: ApplyStatus.loading), + const ApplyState(applyStatus: ApplyStatus.success), + ], + verify: (_) { + verify(() => mockApplyUseCase(testApplyRequest)).called(1); + }, + ); + + blocTest( + 'emits [loading, failure] when SubmitApplyIntent fails', + build: () { + when( + () => mockApplyUseCase(any()), + ).thenAnswer((_) async => ErrorApiResult(error: 'Submission failed')); + return applyCubit; + }, + act: (cubit) => cubit.onIntent(SubmitApplyIntent(testApplyRequest)), + expect: () => [ + const ApplyState(applyStatus: ApplyStatus.loading), + const ApplyState( + applyStatus: ApplyStatus.failure, + applyErrorMessage: 'Submission failed', + ), + ], + verify: (_) { + verify(() => mockApplyUseCase(testApplyRequest)).called(1); + }, + ); + }); + + test('initial state is correct', () { + expect( + applyCubit.state, + const ApplyState( + status: ApplyStatus.initial, + countries: [], + vehiclesStatus: ApplyStatus.initial, + vehicles: [], + applyStatus: ApplyStatus.initial, + ), + ); + }); + }); +} diff --git a/test/features/auth/presentation/apply/view/apply_screen_test.dart b/test/features/auth/presentation/apply/view/apply_screen_test.dart new file mode 100644 index 0000000..9f95f7a --- /dev/null +++ b/test/features/auth/presentation/apply/view/apply_screen_test.dart @@ -0,0 +1,261 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:tracking_app/features/auth/data/models/request/apply_request_model.dart'; +import 'package:tracking_app/features/auth/domain/entities/country_entity.dart'; +import 'package:tracking_app/features/auth/presentation/apply/manager/apply_intent.dart'; +import 'package:tracking_app/features/auth/presentation/apply/manager/apply_state.dart'; +import 'package:tracking_app/features/auth/presentation/apply/view/apply_success_view.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +void main() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + + group('ApplySuccessScreen Widget Tests -', () { + testWidgets('should display success message', (tester) async { + // Act + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en'), Locale('ar')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: const MaterialApp(home: ApplySuccessScreen()), + ), + ); + await tester.pumpAndSettle(); + debugPrint( + "Found texts: ${tester.widgetList(find.byType(Text)).map((e) => (e as Text).data).toList()}", + ); + + // Assert + expect(find.text(LocaleKeys.applicationSubmitted), findsOneWidget); + expect(find.text(LocaleKeys.congratulationsMessage), findsOneWidget); + }); + + testWidgets('should display back to login button', (tester) async { + // Act + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en'), Locale('ar')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: const MaterialApp(home: ApplySuccessScreen()), + ), + ); + await tester.pumpAndSettle(); + + // Assert + expect(find.text(LocaleKeys.backToLogin), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + }); + + testWidgets('should display success icon', (tester) async { + // Act + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en'), Locale('ar')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: const MaterialApp(home: ApplySuccessScreen()), + ), + ); + await tester.pumpAndSettle(); + + // Assert - Check for circular container with success decoration + final container = tester.widget( + find + .descendant( + of: find.byType(Column).first, + matching: find.byType(Container), + ) + .first, + ); + + final decoration = container.decoration as BoxDecoration?; + expect(decoration?.shape, BoxShape.circle); + }); + + testWidgets('should navigate when back button is tapped', (tester) async { + // Arrange + final router = GoRouter( + initialLocation: '/start', + routes: [ + GoRoute( + path: '/start', + builder: (context, state) => Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + context.push('/success'); + }, + child: const Text('Go to Success'), + ); + }, + ), + ), + ), + GoRoute( + path: '/success', + builder: (context, state) => const ApplySuccessScreen(), + ), + GoRoute( + path: RouteNames.login, + builder: (context, state) => + const Scaffold(body: Text('Login Screen')), + ), + ], + ); + + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en'), Locale('ar')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: MaterialApp.router(routerConfig: router), + ), + ); + + // Navigate to success screen + await tester.tap(find.text('Go to Success')); + await tester.pumpAndSettle(); + + // Verify we're on success screen + expect(find.text(LocaleKeys.applicationSubmitted), findsOneWidget); + + // Tap back to login button + await tester.tap(find.text(LocaleKeys.backToLogin)); + await tester.pumpAndSettle(); + + // Assert - Should navigate to login screen + expect(find.text('Login Screen'), findsOneWidget); + }); + }); + + group('ApplyState Tests -', () { + test('initial state should have correct default values', () { + // Act + const state = ApplyState(); + + // Assert + expect(state.status, ApplyStatus.initial); + expect(state.countries, isEmpty); + expect(state.errorMessage, isNull); + expect(state.vehiclesStatus, ApplyStatus.initial); + expect(state.vehicles, isEmpty); + expect(state.vehiclesErrorMessage, isNull); + expect(state.applyStatus, ApplyStatus.initial); + expect(state.applyErrorMessage, isNull); + }); + + test('copyWith should update only specified fields', () { + // Arrange + const initialState = ApplyState(); + final countries = [ + const CountryEntity( + name: 'Egypt', + isoCode: 'EG', + flag: '🇪🇬', + phoneCode: '20', + ), + ]; + + // Act + final newState = initialState.copyWith( + status: ApplyStatus.success, + countries: countries, + ); + + // Assert + expect(newState.status, ApplyStatus.success); + expect(newState.countries, countries); + expect(newState.vehiclesStatus, ApplyStatus.initial); // Unchanged + expect(newState.applyStatus, ApplyStatus.initial); // Unchanged + }); + + test('state should support equality comparison', () { + // Arrange + const state1 = ApplyState(); + const state2 = ApplyState(); + + // Assert + expect(state1, equals(state2)); + }); + + test('different states should not be equal', () { + // Arrange + const state1 = ApplyState(status: ApplyStatus.initial); + const state2 = ApplyState(status: ApplyStatus.loading); + + // Assert + expect(state1, isNot(equals(state2))); + }); + }); + + group('ApplyIntent Tests -', () { + test('GetCountriesIntent should be created', () { + // Act + final intent = GetCountriesIntent(); + + // Assert + expect(intent, isA()); + expect(intent, isA()); + }); + + test('GetVehiclesIntent should be created', () { + // Act + final intent = GetVehiclesIntent(); + + // Assert + expect(intent, isA()); + expect(intent, isA()); + }); + + test('SubmitApplyIntent should be created with request model', () { + // Arrange + final requestModel = ApplyRequestModel( + country: 'EG', + firstName: 'John', + lastName: 'Doe', + vehicleType: '1', + vehicleNumber: 'ABC123', + email: 'john@example.com', + phone: '+201234567890', + NID: '12345678901234', + password: 'Password123!', + rePassword: 'Password123!', + gender: 'male', + vehicleLicense: null, + NIDimg: null, + ); + + // Act + final intent = SubmitApplyIntent(requestModel); + + // Assert + expect(intent, isA()); + expect(intent, isA()); + expect(intent.applyRequestModel, requestModel); + }); + }); +} + +class TestAssetLoader extends AssetLoader { + const TestAssetLoader(); + + @override + Future> load(String path, Locale locale) async { + return { + "applicationSubmitted": "Application Submitted!", + "congratulationsMessage": + "Congratulations! Your application has been submitted successfully.", + "backToLogin": "Back to Login", + "reviewMessage": + "We will review your application and get back to you soon via email.", + }; + } +} diff --git a/test/features/auth/presentation/login/manager/login_cubit_test.dart b/test/features/auth/presentation/login/manager/login_cubit_test.dart new file mode 100644 index 0000000..032db05 --- /dev/null +++ b/test/features/auth/presentation/login/manager/login_cubit_test.dart @@ -0,0 +1,106 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/auth/data/model/response/LoginResponse.dart'; +import 'package:tracking_app/features/auth/domain/usecase/login_usecase.dart'; +import 'package:tracking_app/features/auth/presentation/login/manager/login_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/login/manager/login_intent.dart'; +import 'package:tracking_app/features/auth/presentation/login/manager/login_states.dart'; + +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; + +class MockLoginUseCase extends Mock implements LoginUseCase {} + +class MockAuthStorage extends Mock implements AuthStorage {} + +void main() { + late LoginUseCase loginUseCase; + late AuthStorage authStorage; + late LoginCubit loginCubit; + + setUp(() { + loginUseCase = MockLoginUseCase(); + authStorage = MockAuthStorage(); + loginCubit = LoginCubit(loginUseCase, authStorage); + + // Default mocks for authStorage to avoid null errors during tests + when(() => authStorage.saveToken(any())).thenAnswer((_) async {}); + when(() => authStorage.setRememberMe(any())).thenAnswer((_) async {}); + }); + + group('LoginCubit', () { + test('initial state is correct', () { + expect(loginCubit.state.loginResource.status, Status.initial); + expect(loginCubit.state.rememberMe, false); + }); + + blocTest( + 'emits [loading, success] when PerformLogin succeeds', + build: () { + when(() => loginUseCase.call(any(), any())).thenAnswer( + (_) async => SuccessApiResult(data: LoginResponse(token: 'token')), + ); + return loginCubit; + }, + act: (cubit) => cubit.doAction( + PerformLogin( + email: 'test@test.com', + password: 'pass', + rememberMe: false, + ), + ), + expect: () => [ + isA().having( + (s) => s.loginResource.status, + 'status', + Status.loading, + ), + isA().having( + (s) => s.loginResource.status, + 'status', + Status.success, + ), + ], + ); + + blocTest( + 'emits [loading, error] when PerformLogin fails', + build: () { + when( + () => loginUseCase.call(any(), any()), + ).thenAnswer((_) async => ErrorApiResult(error: 'error')); + return loginCubit; + }, + act: (cubit) => cubit.doAction( + PerformLogin( + email: 'test@test.com', + password: 'pass', + rememberMe: false, + ), + ), + expect: () => [ + isA().having( + (s) => s.loginResource.status, + 'status', + Status.loading, + ), + isA().having( + (s) => s.loginResource.status, + 'status', + Status.error, + ), + ], + ); + + blocTest( + 'emits state with new rememberMe when ToggleRememberMe is called', + build: () => loginCubit, + act: (cubit) => cubit.doAction(ToggleRememberMe(true)), + expect: () => [ + isA().having((s) => s.rememberMe, 'rememberMe', true), + ], + ); + }); +} diff --git a/test/features/auth/presentation/login/pages/loginScreen_test.dart b/test/features/auth/presentation/login/pages/loginScreen_test.dart new file mode 100644 index 0000000..2550f9d --- /dev/null +++ b/test/features/auth/presentation/login/pages/loginScreen_test.dart @@ -0,0 +1,83 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mockito/annotations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/features/auth/domain/repos/auth_repo.dart'; +import 'package:tracking_app/features/auth/domain/usecase/login_usecase.dart'; +import 'package:tracking_app/features/auth/presentation/login/manager/login_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/login/pages/loginScreen.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +import 'loginScreen_test.mocks.dart'; + +@GenerateMocks([AuthRepo, AuthStorage]) +void main() { + late MockAuthRepo mockAuthRepo; + late MockAuthStorage mockAuthStorage; + late LoginUseCase loginUseCase; + late LoginCubit loginCubit; + late GetIt getIt; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + }); + + setUp(() { + getIt = GetIt.instance; + mockAuthRepo = MockAuthRepo(); + mockAuthStorage = MockAuthStorage(); + loginUseCase = LoginUseCase(mockAuthRepo); + loginCubit = LoginCubit(loginUseCase, mockAuthStorage); + + // Register LoginCubit in GetIt + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton(loginCubit); + }); + + tearDown(() { + loginCubit.close(); + getIt.reset(); + }); + + Widget createWidgetUnderTest() { + return EasyLocalization( + supportedLocales: const [Locale('en'), Locale('ar')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: const MaterialApp(home: LoginScreen()), + ); + } + + testWidgets('LoginScreen renders correctly', (WidgetTester tester) async { + // Act + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + // Assert + expect(find.text(LocaleKeys.email), findsOneWidget); + expect(find.text(LocaleKeys.password), findsOneWidget); + expect(find.text(LocaleKeys.login), findsNWidgets(2)); + }); + + testWidgets('Enters text into email and password fields', ( + WidgetTester tester, + ) async { + // Act + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField).first, 'test@test.com'); + await tester.enterText(find.byType(TextFormField).last, 'password123'); + await tester.pump(); + + // Assert + expect(find.text('test@test.com'), findsOneWidget); + expect(find.text('password123'), findsOneWidget); + }); +} diff --git a/test/features/auth/presentation/reset_password/manager/change_password_cubit_test.dart b/test/features/auth/presentation/reset_password/manager/change_password_cubit_test.dart new file mode 100644 index 0000000..27fd74e --- /dev/null +++ b/test/features/auth/presentation/reset_password/manager/change_password_cubit_test.dart @@ -0,0 +1,257 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/auth/domain/models/change_password_model.dart'; +import 'package:tracking_app/features/auth/domain/usecase/change_password_usecase.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_intent.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_states.dart'; +import 'change_password_cubit_test.mocks.dart'; + +@GenerateMocks([ChangePasswordUsecase, AuthStorage]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late MockChangePasswordUsecase mockUseCase; + late MockAuthStorage mockAuthStorage; + late ChangePasswordCubit cubit; + + setUpAll(() { + mockUseCase = MockChangePasswordUsecase(); + mockAuthStorage = MockAuthStorage(); + provideDummy>( + SuccessApiResult(data: ChangePasswordModel()), + ); + }); + setUp(() { + cubit = ChangePasswordCubit(mockUseCase, mockAuthStorage); + }); + tearDown(() async { + await cubit.close(); + }); + group("Change password intent", () { + blocTest( + 'emits loading then success when usecase returns SuccessApiResult', + build: () { + final fakeData = ChangePasswordModel( + message: 'Success', + token: 'fake_token', + error: null, + ); + when(mockAuthStorage.getToken()).thenAnswer((_) async => 'fake_token'); + when(mockAuthStorage.clearToken()).thenAnswer((_) async => isTrue); + when( + mockUseCase.call( + token: 'Bearer fake_token', + password: 'Test@123', + newPassword: 'Test@1234', + ), + ).thenAnswer( + (_) async => SuccessApiResult(data: fakeData), + ); + return cubit; + }, + + act: (cubit) { + cubit.doIntent(CurrentPasswordIntent(currentPass: 'Test@123')); + cubit.doIntent(NewPasswordIntent(newPass: 'Test@1234')); + cubit.doIntent(ConfirmPasswordIntent(confirmPass: 'Test@1234')); + return cubit.doIntent(SubmitChangePasswordIntent()); + }, + expect: () => [ + isA().having( + (s) => s.currentPassword, + "currentPass", + true, + ), + isA().having( + (s) => s.newPassword, + "newPass", + true, + ), + isA().having( + (s) => s.confirmPassword, + "confirmPass", + true, + ), + + isA().having( + (s) => s.data?.status, + "status", + Status.loading, + ), + isA() + .having((s) => s.data?.status, "status", Status.success) + .having((s) => s.data?.data?.token, "token", "fake_token") + .having((s) => s.data!.data!.message, "message", "Success"), + ], + verify: (_) { + verify( + mockUseCase.call( + token: 'Bearer fake_token', + password: 'Test@123', + newPassword: 'Test@1234', + ), + ).called(1); + verify(mockAuthStorage.clearToken()).called(1); + }, + ); + + blocTest( + 'emits loading then error when usecase returns ErrorApiResult', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => 'fake_token'); + when( + mockUseCase.call( + token: 'Bearer fake_token', + password: 'Test@123', + newPassword: 'Test@1234', + ), + ).thenAnswer( + (_) async => ErrorApiResult( + error: 'Change password failed', + ), + ); + return cubit; + }, + + act: (cubit) { + cubit.doIntent(CurrentPasswordIntent(currentPass: 'Test@123')); + cubit.doIntent(NewPasswordIntent(newPass: 'Test@1234')); + cubit.doIntent(ConfirmPasswordIntent(confirmPass: 'Test@1234')); + return cubit.doIntent(SubmitChangePasswordIntent()); + }, + expect: () => [ + isA().having( + (s) => s.currentPassword, + "currentPass", + true, + ), + isA().having( + (s) => s.newPassword, + "newPass", + true, + ), + isA().having( + (s) => s.confirmPassword, + "confirmPass", + true, + ), + + isA().having( + (s) => s.data!.status, + 'status', + Status.loading, + ), + isA().having( + (s) => s.data!.error, + 'error', + contains('Change password failed'), + ), + ], + + verify: (_) { + verify( + mockUseCase.call( + token: 'Bearer fake_token', + password: 'Test@123', + newPassword: 'Test@1234', + ), + ).called(1); + }, + ); + }); + + group('Text field changes', () { + blocTest( + 'emits state with currentPass=true', + build: () => cubit, + act: (cubit) => + cubit.doIntent(CurrentPasswordIntent(currentPass: 'Test@123')), + expect: () => [ + isA().having( + (s) => s.currentPassword, + 'currentPassword', + true, + ), + ], + verify: (_) { + expect(cubit.currentPass, 'Test@123'); + }, + ); + + blocTest( + 'emits state with newPassword=true', + build: () => cubit, + act: (cubit) => cubit.doIntent(NewPasswordIntent(newPass: 'Test@1234')), + expect: () => [ + isA().having( + (s) => s.newPassword, + 'newPassword', + true, + ), + ], + verify: (_) { + expect(cubit.newPass, 'Test@1234'); + }, + ); + + blocTest( + 'emits state with confirmPassword=true', + build: () => cubit, + act: (cubit) => + cubit.doIntent(ConfirmPasswordIntent(confirmPass: 'Test@1234')), + expect: () => [ + isA().having( + (s) => s.confirmPassword, + 'confirmPassword', + true, + ), + ], + verify: (_) { + expect(cubit.confirmPass, 'Test@1234'); + }, + ); + }); + + group('Form Validation', () { + blocTest( + 'emits isFormValid = false when confirm password does not match', + build: () { + cubit.currentPass = 'Test@123'; + cubit.newPass = 'Test@1234'; + cubit.confirmPass = 'Test@12345'; + return cubit; + }, + act: (cubit) => cubit.doIntent(FormValidIntent()), + expect: () => [ + isA().having( + (s) => s.isFormValid, + 'isFormValid', + false, + ), + ], + ); + + blocTest( + 'emits isFormValid = false when any password is invalid', + build: () { + cubit.currentPass = 'test'; + cubit.newPass = '123'; + cubit.confirmPass = '123'; + return cubit; + }, + act: (cubit) => cubit.doIntent(FormValidIntent()), + expect: () => [ + isA().having( + (s) => s.isFormValid, + 'isFormValid', + false, + ), + ], + ); + }); +} diff --git a/test/features/auth/presentation/reset_password/pages/change_password_page_test.dart b/test/features/auth/presentation/reset_password/pages/change_password_page_test.dart new file mode 100644 index 0000000..6606975 --- /dev/null +++ b/test/features/auth/presentation/reset_password/pages/change_password_page_test.dart @@ -0,0 +1,186 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/core/router/route_names.dart'; +import 'package:tracking_app/app/core/ui_helper/color/colors.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_cubit.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_intent.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/manager/change_password_states.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/pages/change_password_page.dart'; +import 'package:tracking_app/features/auth/presentation/reset_password/widgets/text_form_field_widget.dart'; +import 'package:tracking_app/generated/locale_keys.g.dart'; + +import 'change_password_page_test.mocks.dart'; + +@GenerateMocks([ChangePasswordCubit]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late MockChangePasswordCubit cubit; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + }); + + setUp(() { + cubit = MockChangePasswordCubit(); + GetIt.I.registerSingleton(cubit); + when(cubit.formKey).thenReturn(GlobalKey()); + }); + + tearDown(() { + GetIt.I.reset(); + }); + + Widget buildTestableWidget() { + return EasyLocalization( + supportedLocales: const [Locale('en')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + startLocale: const Locale('en'), + saveLocale: false, + child: Builder( + builder: (context) { + return MaterialApp(home: ChangePasswordPage()); + }, + ), + ); + } + + testWidgets('renders all password fields', (tester) async { + when(cubit.state).thenReturn(ChangePasswordStates()); + when(cubit.stream).thenAnswer((_) => Stream.value(ChangePasswordStates())); + + await tester.pumpWidget(buildTestableWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(TextFormField), findsNWidgets(3)); + expect( + find.byWidgetPredicate( + (widget) => + widget is Text && + widget.data == LocaleKeys.newPassword.tr() && + widget.style?.color == AppColors.grey2, + ), + findsOneWidget, + ); + expect(find.byType(Icon), findsNWidgets(4)); + expect(find.byIcon(Icons.visibility_off), findsNWidgets(3)); + expect(find.text(LocaleKeys.currentPassword), findsNWidgets(2)); + expect(find.bySemanticsLabel(LocaleKeys.newPassword.tr()), findsOneWidget); + expect( + find.widgetWithText(TextFormFieldWidget, LocaleKeys.newPassword), + findsNWidgets(2), + ); + expect( + find.widgetWithText(TextFormFieldWidget, LocaleKeys.confirmPassword), + findsNWidgets(2), + ); + expect(find.text(LocaleKeys.update), findsOneWidget); + }); + + testWidgets('Toggling visibility icon changes obscureText property', ( + tester, + ) async { + when(cubit.state).thenReturn(ChangePasswordStates()); + when(cubit.stream).thenAnswer( + (_) => Stream.value(ChangePasswordStates()), + ); + + await tester.pumpWidget(buildTestableWidget()); + await tester.pumpAndSettle(); + + final passwordFieldFinder = find.widgetWithText( + TextFormFieldWidget, + LocaleKeys.currentPassword, + ); + final textFieldFinder = find.descendant( + of: passwordFieldFinder, + matching: find.byType(TextField), + ); + expect(tester.widget(textFieldFinder).obscureText, isTrue); + + final visibilityIconFinder = find.descendant( + of: passwordFieldFinder, + matching: find.byIcon(Icons.visibility_off), + ); + + await tester.tap(visibilityIconFinder); + await tester.pump(); + + expect(tester.widget(textFieldFinder).obscureText, isFalse); + }); + + testWidgets('Typing in text fields triggers Cubit intents', (tester) async { + when(cubit.state).thenReturn(ChangePasswordStates()); + when(cubit.stream).thenAnswer((_) => Stream.value(ChangePasswordStates())); + + await tester.pumpWidget(buildTestableWidget()); + await tester.pumpAndSettle(); + + final currentPassField = find.widgetWithText( + TextFormFieldWidget, + LocaleKeys.currentPassword, + ); + await tester.enterText(currentPassField, 'Test@123'); + await tester.pump(); + + verify(cubit.doIntent(argThat(isA()))).called(1); + verify(cubit.doIntent(argThat(isA()))).called(1); + }); + + testWidgets('Shows SnackBar on Status.success', (tester) async { + final initialState = ChangePasswordStates(data: Resource.loading()); + final successState = ChangePasswordStates( + data: Resource.success(null), + isFormValid: true, + ); + + when(cubit.state).thenReturn(initialState); + when(cubit.stream).thenAnswer((_) => Stream.value(successState)); + + final testRouter = GoRouter( + initialLocation: '/change_password', + routes: [ + GoRoute( + path: '/change_password', + builder: (context, state) => ChangePasswordPage(), + ), + GoRoute( + path: RouteNames.login, + builder: (context, state) => const Scaffold(body: Text('Login Page')), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router(routerConfig: testRouter)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text(LocaleKeys.passwordUpdated.tr()), findsOneWidget); + expect(find.text('Login Page'), findsOneWidget); + }); + + testWidgets('Shows Error Dialog on Status.error', (tester) async { + final initialState = ChangePasswordStates(); + final errorState = ChangePasswordStates( + data: Resource.error('Wrong Password'), + isFormValid: true, + ); + + when(cubit.state).thenReturn(initialState); + when(cubit.stream).thenAnswer((_) => Stream.value(errorState)); + + await tester.pumpWidget(buildTestableWidget()); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Wrong Password'), findsOneWidget); + }); +} diff --git a/test/features/driver_orders_details/api/datasource/order_details_remote_datasource_impl_test.dart b/test/features/driver_orders_details/api/datasource/order_details_remote_datasource_impl_test.dart new file mode 100644 index 0000000..be1433a --- /dev/null +++ b/test/features/driver_orders_details/api/datasource/order_details_remote_datasource_impl_test.dart @@ -0,0 +1,183 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/api/datasource/order_details_remote_datasource_impl.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/drivers_dto.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/orders_dto.dart'; +import 'order_details_remote_datasource_impl_test.mocks.dart'; + +@GenerateMocks([ + FirebaseFirestore, + CollectionReference, + DocumentReference, + DocumentSnapshot, + Dio, +]) +void main() { + late OrderDetailsRemoteDatasourceImpl dataSource; + late MockFirebaseFirestore mockFirestore; + late MockDio mockDio; + late MockCollectionReference> mockCollection; + late MockDocumentReference> mockDocument; + late MockDocumentSnapshot> mockSnapshot; + + const String tOrderId = 'pxkMaEmWYVuvV5jkW0JK'; + const String driverId = '6989f35de364ef61405211a0'; + + setUp(() { + mockFirestore = MockFirebaseFirestore(); + mockDio = MockDio(); + mockCollection = MockCollectionReference(); + mockDocument = MockDocumentReference(); + mockSnapshot = MockDocumentSnapshot(); + mockDio = MockDio(); + + dataSource = OrderDetailsRemoteDatasourceImpl( + firestore: mockFirestore, + dio: mockDio, + ); + }); + group('getOrderStream', () { + final tOrderJson = { + 'driver_id': '1', + 'user_id': 'U11', + 'userAddress': {'name': 'mariam', 'address': 'alex', 'userId': 'U11'}, + 'oder_dt': { + 'items': [], + 'status': 'accepted', + 'totalPrice': 500.0, + 'orderId': tOrderId, + 'userAddress': 'alex', + 'pickupAddress': {'name': 'mariam', 'address': 'alex'}, + }, + }; + + test('should return SuccessApiResult with Stream of OrderDto', () async { + when(mockFirestore.collection('orders')).thenReturn(mockCollection); + when(mockCollection.doc(tOrderId)).thenReturn(mockDocument); + + when(mockSnapshot.exists).thenReturn(true); + when(mockSnapshot.data()).thenReturn(tOrderJson); + when(mockSnapshot.id).thenReturn(tOrderId); + + when( + mockDocument.snapshots(), + ).thenAnswer((_) => Stream.value(mockSnapshot)); + + final result = dataSource.getOrderStream(tOrderId); + + expect(result, isA>>()); + final stream = (result as SuccessApiResult>).data; + await expectLater( + stream, + emits( + isA() + .having((o) => o.orderId, 'orderId', tOrderId) + .having((o) => o.orderDetails.status, 'status', 'accepted'), + ), + ); + }); + }); + + group('getDriversData', () { + final driverData = { + 'id': '6989f35de364ef61405211a0', + 'currentLocation': {'lat': 31.251555, 'lng': 29.9843417}, + 'name': "mariam", + 'phone': '01205708282', + 'deviceToken': '', + }; + + test('should return SuccessApiResult with Stream of DriverDto', () async { + when(mockFirestore.collection('drivers')).thenReturn(mockCollection); + when(mockCollection.doc(driverId)).thenReturn(mockDocument); + + when(mockSnapshot.exists).thenReturn(true); + when(mockSnapshot.data()).thenReturn(driverData); + when(mockSnapshot.id).thenReturn(driverId); + + when( + mockDocument.snapshots(), + ).thenAnswer((_) => Stream.value(mockSnapshot)); + + final result = dataSource.getDriverData(driverId); + + expect(result, isA>>()); + final stream = (result as SuccessApiResult>).data; + await expectLater( + stream, + emits( + isA() + .having((o) => o.name, 'name', 'mariam') + .having((o) => o.id, 'id', driverId), + ), + ); + }); + }); + + group('getLatLngFromAddress', () { + test('should return LatLng when API responds with valid data', () async { + final responseData = [ + {"lat": "30.0444", "lon": "31.2357"}, + ]; + + when( + mockDio.get( + any, + queryParameters: anyNamed('queryParameters'), + options: anyNamed('options'), + ), + ).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getLatLngFromAddress("Cairo"); + + expect(result, isA>()); + final success = result as SuccessApiResult; + expect(success.data!.latitude, 30.0444); + expect(success.data!.longitude, 31.2357); + }); + }); + + group('getRealRoute', () { + test( + 'should return List when API responds with valid route', + () async { + final responseData = { + "code": "Ok", + "routes": [ + {"geometry": "}_ilFjk~uO??"}, + ], + }; + + when( + mockDio.get(any, queryParameters: anyNamed('queryParameters')), + ).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getRealRoute( + const LatLng(30.0444, 31.2357), + const LatLng(30.0500, 31.2400), + ); + + expect(result, isA>>()); + final success = result as SuccessApiResult>; + expect(success.data, isNotEmpty); + }, + ); + }); +} diff --git a/test/features/driver_orders_details/data/mapper/drivers_dto_mapper_test.dart b/test/features/driver_orders_details/data/mapper/drivers_dto_mapper_test.dart new file mode 100644 index 0000000..e4460b4 --- /dev/null +++ b/test/features/driver_orders_details/data/mapper/drivers_dto_mapper_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/driver_orders_details/data/mapper/drivers_dto_mapper.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/drivers_dto.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; + +void main() { + group('DriverDataDtoMapper', () { + test('Convert DriverDataDto to DriverDataModel correctly', () { + final dto = DriverDataDto( + deviceToken: 'token', + id: '111', + phone: '0', + currentLocation: DriverLocationDto(lat: 31, lng: 29), + name: 'Mariam', + ); + + final result = dto.toDriversModel(); + + expect(result, isA()); + expect(result.deviceToken, dto.deviceToken); + expect(result.name, dto.name); + expect(result.phone, dto.phone); + expect(result.currentLocation.lat, dto.currentLocation.lat); + }); + }); + + group('DriverLocationDtoMapper', () { + test('Convert DriverLocationDto to DriverLocationModel correctly', () { + final dto = DriverLocationDto(lat: 30, lng: 29); + + final result = dto.toDriverLocationModel(); + + expect(result, isA()); + expect(result.lat, dto.lat); + expect(result.lng, dto.lng); + }); + }); +} diff --git a/test/features/driver_orders_details/data/mapper/order_dto_mapper_test.dart b/test/features/driver_orders_details/data/mapper/order_dto_mapper_test.dart new file mode 100644 index 0000000..d11f68f --- /dev/null +++ b/test/features/driver_orders_details/data/mapper/order_dto_mapper_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/driver_orders_details/data/mapper/order_dto_mapper.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/orders_dto.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; + +void main() { + group('OrderDtoMapper', () { + test('Convert OrderDto to OrderModel correctly', () { + final tUserAddressDto = UserAddressDto( + address: 'Alex', + name: 'Mariam', + userId: 'U123', + ); + + final tOrderDto = OrderDto( + driverId: 'D123', + userAddress: tUserAddressDto, + userId: 'U789', + orderId: '22', + orderDetails: OrderDetailsDto( + items: [], + status: 'pending', + totalPrice: 500, + pickupAddress: PickedAddressDto( + name: 'Store', + address: '123 Main St', + ), + orderId: '22', + userAddress: 'alex', + ), + ); + + final result = tOrderDto.toOrderModel(); + + expect(result, isA()); + expect(result.driverId, tOrderDto.driverId); + expect(result.userAddress.name, tOrderDto.userAddress.name); + expect(result.userAddress.address, tOrderDto.userAddress.address); + expect(result.userAddress.userId, tOrderDto.userAddress.userId); + expect(result.userId, tOrderDto.userId); + }); + }); + + group('OrderDetailsDtoMapper', () { + test('Convert OrderDetailsDto to OrderDetailsModel correctly', () { + final tpickupAddressDto = PickedAddressDto( + name: 'Store', + address: '123 Main St', + ); + final tDto = OrderDetailsDto( + items: [], + status: 'pending', + totalPrice: 500, + pickupAddress: tpickupAddressDto, + orderId: '1', + userAddress: 'alex', + ); + + final result = tDto.toOrderDetailsModel(); + + expect(result, isA()); + expect(result.items, tDto.items); + expect(result.status, tDto.status); + expect(result.totalPrice, tDto.totalPrice); + expect(result.pickupAddress.name, tDto.pickupAddress.name); + expect(result.orderId, tDto.orderId); + }); + }); + + group('OrderItemDtoMapper', () { + test('Convert OrderItemDto to OrderItemModel correctly', () { + final tDto = OrderItemDto( + productId: '1', + title: 'Item 1', + price: 100, + quantity: 2, + image: 'image_url', + ); + + final result = tDto.toOrderItemModel(); + + expect(result.productId, tDto.productId); + expect(result.title, tDto.title); + expect(result.price, tDto.price); + expect(result.quantity, tDto.quantity); + expect(result.image, tDto.image); + }); + }); + + group('PickedAddressDtoMapper', () { + test('Convert PickedAddressDto to PickedAddressModel correctly', () { + final tDto = PickedAddressDto(name: 'Store', address: '123 Main St'); + + final result = tDto.toPickedAddressModel(); + + expect(result.name, tDto.name); + expect(result.address, tDto.address); + }); + }); + + group('UserAddressDtoMapper', () { + test('Convert UserAddressDto to UserAddressModel correctly', () { + final tDto = UserAddressDto( + name: 'Store', + address: '123 Main St', + userId: 'U123', + ); + + final result = tDto.toUserAddressModel(); + + expect(result.name, tDto.name); + expect(result.address, tDto.address); + }); + }); +} diff --git a/test/features/driver_orders_details/data/models/drivers_dto_test.dart b/test/features/driver_orders_details/data/models/drivers_dto_test.dart new file mode 100644 index 0000000..006150e --- /dev/null +++ b/test/features/driver_orders_details/data/models/drivers_dto_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/drivers_dto.dart'; + +void main() { + group('DriverDataDto Tests', () { + test('should return a valid DriverDataDto from JSON', () { + final Map json = { + 'id': '6989f35de364ef61405211a0', + 'currentLocation': {'lat': 31.251555, 'lng': 29.9843417}, + 'name': "mariam", + 'phone': '01205708282', + 'deviceToken': '', + }; + + final result = DriverDataDto.fromJson(json); + + expect(result.phone, '01205708282'); + expect(result.name, 'mariam'); + expect(result.id, '6989f35de364ef61405211a0'); + expect(result.currentLocation.lat, 31.251555); + }); + + test('should return a valid JSON map from DriverDataDto', () { + final dto = DriverDataDto( + currentLocation: DriverLocationDto(lat: 30, lng: 29), + deviceToken: 'token', + id: '123', + phone: '01205708282', + name: 'Mariam', + ); + + final result = dto.toJson(); + + expect(result['deviceToken'], 'token'); + expect(result['name'], 'Mariam'); + expect(result['id'], '123'); + }); + }); + + group('DriverLocationDto Tests', () { + test('should return a valid DriverLocationDto from JSON', () { + final Map json = {'lat': 30, 'lng': 29}; + + final result = DriverLocationDto.fromJson(json); + + expect(result.lat, 30); + expect(result.lng, 29); + }); + + test('should return a valid JSON map from DriverLocationDto', () { + final dto = DriverLocationDto(lat: 30, lng: 29); + + final result = dto.toJson(); + + expect(result['lat'], 30); + expect(result['lng'], 29); + }); + }); +} diff --git a/test/features/driver_orders_details/data/models/orders_dto_test.dart b/test/features/driver_orders_details/data/models/orders_dto_test.dart new file mode 100644 index 0000000..6206376 --- /dev/null +++ b/test/features/driver_orders_details/data/models/orders_dto_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/orders_dto.dart'; + +void main() { + group('UserAddressDto Tests', () { + test('should return a valid UserAddressDto from JSON', () { + final Map json = { + 'adress': 'Alex', + 'name': 'Mariam', + 'user_id': 'U123', + }; + + final result = UserAddressDto.fromJson(json); + + expect(result.address, 'Alex'); + expect(result.name, 'Mariam'); + expect(result.userId, 'U123'); + }); + + test('should return a valid JSON map from UserAddressDto', () { + final dto = UserAddressDto( + address: 'Alex', + name: 'Mariam', + userId: 'U123', + ); + + final result = dto.toJson(); + + expect(result['adress'], 'Alex'); + expect(result['name'], 'Mariam'); + expect(result['user_id'], 'U123'); + }); + }); + + group('PickedAddressDto Tests', () { + test('should return a valid PickedAddressDto from JSON', () { + final Map json = {'address': 'Alex', 'name': 'Mariam'}; + + final result = PickedAddressDto.fromJson(json); + + expect(result.address, 'Alex'); + expect(result.name, 'Mariam'); + }); + + test('should return a valid JSON map from PickedAddressDto', () { + final dto = PickedAddressDto(address: 'Alex', name: 'Mariam'); + + final result = dto.toJson(); + + expect(result['address'], 'Alex'); + expect(result['name'], 'Mariam'); + }); + }); + + group('OrderItemDto Tests', () { + test('should return a valid OrderItemDto from JSON', () { + final Map json = { + 'productId': '1', + 'title': 'red flower', + 'image': 'url', + 'quantity': 1, + 'price': 100, + }; + + final result = OrderItemDto.fromJson(json); + + expect(result.image, 'url'); + expect(result.title, 'red flower'); + expect(result.quantity, 1); + expect(result.price, 100); + }); + + test('should return a valid JSON map from OrderItemDto', () { + final dto = OrderItemDto( + image: 'Alex', + productId: '1', + title: 'red flower', + quantity: 1, + price: 100, + ); + + final result = dto.toJson(); + + expect(result['image'], 'Alex'); + expect(result['title'], 'red flower'); + expect(result['quantity'], 1); + expect(result['price'], 100); + }); + }); + + group('OrderDetailsDto Tests', () { + test('should return a valid OrderDetailsDto from JSON', () { + final Map json = { + 'items': [], + 'status': 'accepted', + 'totalPrice': 100.0, + 'pickupAddress': {'name': 'Mariam', 'address': 'Alex'}, + 'orderId': 'O456', + 'userAddress': 'alex', + }; + + final result = OrderDetailsDto.fromJson(json); + + expect(result.status, 'accepted'); + expect(result.totalPrice, 100.0); + expect(result.orderId, 'O456'); + }); + + test('should return a valid JSON map from OrderDetailsDto', () { + final dto = OrderDetailsDto( + items: [ + OrderItemDto( + image: 'url', + productId: '1', + title: 'red flower', + quantity: 1, + price: 100, + ), + ], + status: 'accepted', + totalPrice: 100.0, + pickupAddress: PickedAddressDto(address: 'Alex', name: 'Mariam'), + orderId: 'O456', + userAddress: 'alex', + ); + + final result = dto.toJson(); + + expect(result['status'], 'accepted'); + expect(result['totalPrice'], 100.0); + final firstItem = result['items'][0]; + expect(firstItem['image'], 'url'); + expect(firstItem['title'], 'red flower'); + expect(firstItem['price'], 100.0); + expect(result['pickupAddress']['name'], 'Mariam'); + }); + }); + + group('OrderDto Tests', () { + final Map tOrderJson = { + 'driver_id': 'D123', + 'user_id': 'U789', + 'userAddress': { + 'name': 'Home', + 'address': 'Cairo, Egypt', + 'userId': 'U789', + }, + 'oder_dt': { + 'status': 'processing', + 'totalPrice': 250.0, + 'orderId': 'O100', + 'userAddress': 'Cairo, Egypt', + 'pickupAddress': {'name': 'Pharmacy', 'address': 'Downtown'}, + 'items': [ + { + 'productId': 'p1', + 'title': 'Panadol', + 'image': 'panadol.png', + 'quantity': 2, + 'price': 125.0, + }, + ], + }, + }; + + const String tOrderId = 'O100'; + + test('should return a valid OrderDto from JSON and ID', () { + final result = OrderDto.fromJson(tOrderJson, tOrderId); + + expect(result.orderId, tOrderId); + expect(result.driverId, 'D123'); + expect(result.userId, 'U789'); + expect(result.userAddress, isA()); + expect(result.userAddress.name, 'Home'); + + expect(result.orderDetails, isA()); + expect(result.orderDetails.status, 'processing'); + expect(result.orderDetails.items.length, 1); + expect(result.orderDetails.items[0].title, 'Panadol'); + }); + + test('should return a valid JSON map from OrderDto', () { + final dto = OrderDto( + orderId: tOrderId, + driverId: 'D123', + userId: 'U789', + userAddress: UserAddressDto( + name: 'Home', + address: 'Cairo', + userId: 'U789', + ), + orderDetails: OrderDetailsDto( + items: [], + status: 'pending', + totalPrice: 0.0, + pickupAddress: PickedAddressDto(name: 'Store', address: 'Street'), + orderId: tOrderId, + userAddress: 'Cairo', + ), + ); + + final result = dto.toJson(); + + expect(result['driver_id'], 'D123'); + expect(result['user_id'], 'U789'); + + expect(result['userAddress'], isA>()); + expect(result['oder_dt'], isA>()); + expect(result['oder_dt']['status'], 'pending'); + }); + }); +} diff --git a/test/features/driver_orders_details/data/repos/order_details_repo_impl_test.dart b/test/features/driver_orders_details/data/repos/order_details_repo_impl_test.dart new file mode 100644 index 0000000..b21d092 --- /dev/null +++ b/test/features/driver_orders_details/data/repos/order_details_repo_impl_test.dart @@ -0,0 +1,223 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/data/datasource/order_details_remote_datasource.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/drivers_dto.dart'; +import 'package:tracking_app/features/driver_orders_details/data/models/orders_dto.dart'; +import 'package:tracking_app/features/driver_orders_details/data/repos/order_details_repo_impl.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; +import 'order_details_repo_impl_test.mocks.dart'; + +@GenerateMocks([OrderDetailsRemoteDatasource, DocumentSnapshot, AuthStorage]) +void main() { + late OrderDetailsRepoImpl repository; + late MockAuthStorage authStorage; + late MockOrderDetailsRemoteDatasource mockRemoteDataSource; + + setUp(() { + mockRemoteDataSource = MockOrderDetailsRemoteDatasource(); + authStorage = MockAuthStorage(); + repository = OrderDetailsRepoImpl(mockRemoteDataSource, authStorage); + provideDummy>>( + ErrorApiResult(error: 'dummy_error'), + ); + provideDummy>>( + ErrorApiResult(error: 'dummy_error'), + ); + provideDummy>(ErrorApiResult(error: 'dummy_error')); + provideDummy>>(ErrorApiResult(error: 'dummy_error')); + }); + + const tOrderId = 'pxkMaEmWYVuvV5jkW0JK'; + const driverId = '6989f35de364ef61405211a0'; + + final tOrderDto = OrderDto( + driverId: 'D123', + userAddress: UserAddressDto( + address: 'Alex', + name: 'Mariam', + userId: 'U123', + ), + userId: 'U789', + orderId: tOrderId, + orderDetails: OrderDetailsDto( + items: [], + status: 'accepted', + totalPrice: 150.0, + pickupAddress: PickedAddressDto(name: 'Pharmacy', address: 'Downtown'), + orderId: tOrderId, + userAddress: 'Alex', + ), + ); + + final driverDto = DriverDataDto( + deviceToken: 'token', + id: '6989f35de364ef61405211a0', + name: 'mariam', + phone: '01205708282', + currentLocation: DriverLocationDto(lat: 30, lng: 29), + ); + + group('getOrderDetails', () { + test( + 'should emit OrderModel when the remote data source returns SuccessApiResult with Stream', + () async { + when(authStorage.getOrderId()).thenAnswer((_) async => tOrderId); + + when( + mockRemoteDataSource.getOrderStream(tOrderId), + ).thenReturn(SuccessApiResult(data: Stream.value(tOrderDto))); + + final result = await repository.getOrderDetails(); + + expect(result, isA>>()); + final stream = (result as SuccessApiResult>).data; + await expectLater( + stream, + emits( + isA() + .having((o) => o.orderId, 'order id', tOrderId) + .having((o) => o.userAddress.name, 'user name', 'Mariam') + .having( + (o) => o.orderDetails.status, + 'order status', + 'accepted', + ) + .having((o) => o.orderDetails.totalPrice, 'total price', 150.0), + ), + ); + }, + ); + + test( + 'should throw an Exception when the document does not exist', + () async { + const errorMessage = "Network Error"; + when(authStorage.getOrderId()).thenAnswer((_) async => tOrderId); + + when( + mockRemoteDataSource.getOrderStream(tOrderId), + ).thenReturn(ErrorApiResult(error: errorMessage)); + + final result = await repository.getOrderDetails(); + + expect(result, isA>>()); + expect((result as ErrorApiResult).error, errorMessage); + }, + ); + }); + + group('getDriverData', () { + test( + 'should emit DriverDataModel when the remote data source returns SuccessApiResult with Stream', + () async { + when( + mockRemoteDataSource.getDriverData(driverId), + ).thenReturn(SuccessApiResult(data: Stream.value(driverDto))); + + final result = repository.getDriverData(driverId); + + expect(result, isA>>()); + final stream = + (result as SuccessApiResult>).data; + await expectLater( + stream, + emits( + isA() + .having((o) => o.id, 'driver id', driverId) + .having((o) => o.name, 'user name', driverDto.name) + .having((o) => o.currentLocation.lat, 'lat', 30), + ), + ); + }, + ); + + test( + 'should throw an Exception when the document does not exist', + () async { + const errorMessage = "Network Error"; + when( + mockRemoteDataSource.getDriverData(driverId), + ).thenReturn(ErrorApiResult(error: errorMessage)); + + final result = repository.getDriverData(driverId); + + expect(result, isA>>()); + expect((result as ErrorApiResult).error, errorMessage); + }, + ); + }); + group('getLatLngFromAddress', () { + final tAddress = "Cairo"; + final tLatLng = LatLng(30.0, 31.0); + + test( + 'should return SuccessApiResult when remote data source succeeds', + () async { + when( + mockRemoteDataSource.getLatLngFromAddress(tAddress), + ).thenAnswer((_) async => SuccessApiResult(data: tLatLng)); + + final result = await repository.getLatLngFromAddress(tAddress); + + expect(result, isA>()); + expect((result as SuccessApiResult).data, tLatLng); + }, + ); + + test( + 'should return ErrorApiResult when remote data source fails', + () async { + when(mockRemoteDataSource.getLatLngFromAddress(tAddress)).thenAnswer( + (_) async => ErrorApiResult(error: "Network Error"), + ); + + final result = await repository.getLatLngFromAddress(tAddress); + + expect(result, isA>()); + expect((result as ErrorApiResult).error, "Network Error"); + }, + ); + }); + + group('getRealRoute', () { + final tMyLocation = LatLng(30.0, 31.0); + final tDestination = LatLng(30.5, 31.5); + final tRoute = [LatLng(30.0, 31.0), LatLng(30.5, 31.5)]; + + test( + 'should return SuccessApiResult when remote data source succeeds', + () async { + when( + mockRemoteDataSource.getRealRoute(tMyLocation, tDestination), + ).thenAnswer((_) async => SuccessApiResult>(data: tRoute)); + + final result = await repository.getRealRoute(tMyLocation, tDestination); + + expect(result, isA>>()); + expect((result as SuccessApiResult).data, tRoute); + }, + ); + + test( + 'should return ErrorApiResult when remote data source fails', + () async { + when( + mockRemoteDataSource.getRealRoute(tMyLocation, tDestination), + ).thenAnswer( + (_) async => ErrorApiResult>(error: "Routing Error"), + ); + + final result = await repository.getRealRoute(tMyLocation, tDestination); + + expect(result, isA>>()); + expect((result as ErrorApiResult).error, "Routing Error"); + }, + ); + }); +} diff --git a/test/features/driver_orders_details/domain/models/drivers_model_test.dart b/test/features/driver_orders_details/domain/models/drivers_model_test.dart new file mode 100644 index 0000000..4d2baaf --- /dev/null +++ b/test/features/driver_orders_details/domain/models/drivers_model_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; + +void main() { + group('DriverDataModel Tests', () { + test('should correctly initialize DriverDataModel with given values', () { + final dataModel = DriverDataModel( + name: 'mariam', + id: '1', + phone: '01205708282', + deviceToken: 'token', + currentLocation: DriverLocationModel(lat: 30, lng: 29), + ); + + expect(dataModel.name, 'mariam'); + expect(dataModel.currentLocation.lat, 30); + expect(dataModel.id, '1'); + }); + + test( + 'should correctly initialize DriverLocationModel with given values', + () { + final location = DriverLocationModel(lat: 30, lng: 29); + + expect(location.lat, 30); + expect(location.lng, 29); + }, + ); + }); +} diff --git a/test/features/driver_orders_details/domain/models/orders_model_test.dart b/test/features/driver_orders_details/domain/models/orders_model_test.dart new file mode 100644 index 0000000..b4f986d --- /dev/null +++ b/test/features/driver_orders_details/domain/models/orders_model_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; + +void main() { + group('OrderModel & UserAddressModel Tests', () { + test('should correctly initialize UserAddressModel with given values', () { + final tAddress = UserAddressModel( + address: 'Cairo', + name: 'Mohamed', + userId: '1', + ); + + expect(tAddress.address, 'Cairo'); + expect(tAddress.name, 'Mohamed'); + expect(tAddress.userId, '1'); + }); + + test('should correctly initialize OrderModel with given values', () { + final tUserAddress = UserAddressModel( + address: 'Cairo', + name: 'Mohamed', + userId: 'USR-555', + ); + + final tOrder = OrderModel( + driverId: 'DRV-101', + userAddress: tUserAddress, + userId: 'USR-555', + orderId: 'ORD-999', + orderDetails: OrderDetailsModel( + items: [], + status: 'picked_up', + totalPrice: 250, + pickupAddress: PickedAddressModel( + name: 'Pharmacy', + address: 'Downtown', + ), + orderId: 'ORD-999', + userAddress: 'Cairo', + ), + ); + + expect(tOrder.driverId, 'DRV-101'); + expect(tOrder.orderId, 'ORD-999'); + expect(tOrder.orderDetails.status, 'picked_up'); + expect(tOrder.orderDetails.totalPrice, 250); + expect(tOrder.userId, 'USR-555'); + + expect(tOrder.userAddress, isA()); + expect(tOrder.userAddress.name, 'Mohamed'); + }); + + test('should support equality check if needed (Optional)', () { + final address1 = UserAddressModel( + address: 'A', + name: 'B', + userId: 'USR-123', + ); + final address2 = UserAddressModel( + address: 'A', + name: 'B', + userId: 'USR-456', + ); + + expect(address1 == address2, isFalse); + }); + }); +} diff --git a/test/features/driver_orders_details/domain/usecases/get_driver_data_usecase_test.dart b/test/features/driver_orders_details/domain/usecases/get_driver_data_usecase_test.dart new file mode 100644 index 0000000..aeb2464 --- /dev/null +++ b/test/features/driver_orders_details/domain/usecases/get_driver_data_usecase_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/usecases/get_driver_data_usecase.dart'; +import 'get_order_details_usecase_test.mocks.dart'; + +@GenerateMocks([OrderDetailsRepo]) +void main() { + late GetDriverDataUsecase usecase; + late MockOrderDetailsRepo mockRepo; + + setUp(() { + mockRepo = MockOrderDetailsRepo(); + usecase = GetDriverDataUsecase(repo: mockRepo); + provideDummy>>( + ErrorApiResult(error: 'dummy'), + ); + }); + + const driverId = 'pxkMaEmWYVuvV5jkW0JK'; + + final driverModel = DriverDataModel( + id: 'id', + name: 'name', + phone: 'phone', + deviceToken: 'deviceToken', + currentLocation: DriverLocationModel(lat: 30, lng: 29), + ); + + group('GetDriverDataUsecase test', () { + test( + 'should return SuccessApiResult containing the Stream from the repository', + () async { + when( + mockRepo.getDriverData(driverId), + ).thenAnswer((_) => SuccessApiResult(data: Stream.value(driverModel))); + + final result = usecase.call(driverId); + + expect(result, isA>>()); + final stream = + (result as SuccessApiResult>).data; + await expectLater(stream, emits(driverModel)); + verify(mockRepo.getDriverData(driverId)).called(1); + }, + ); + + test('should return ErrorApiResult when the repository fails', () async { + when(mockRepo.getDriverData(driverId)).thenAnswer( + (_) => ErrorApiResult>( + error: 'Error from Repository', + ), + ); + + final result = await usecase.call(driverId); + + expect(result, isA>>()); + expect((result as ErrorApiResult).error, 'Error from Repository'); + }); + }); +} diff --git a/test/features/driver_orders_details/domain/usecases/get_order_details_usecase_test.dart b/test/features/driver_orders_details/domain/usecases/get_order_details_usecase_test.dart new file mode 100644 index 0000000..c5cba7e --- /dev/null +++ b/test/features/driver_orders_details/domain/usecases/get_order_details_usecase_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/repos/order_details_repo.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/usecases/get_order_details_usecase.dart'; + +import 'get_order_details_usecase_test.mocks.dart'; + +@GenerateMocks([OrderDetailsRepo]) +void main() { + late GetOrderDetailsUsecase usecase; + late MockOrderDetailsRepo mockRepo; + + setUp(() { + mockRepo = MockOrderDetailsRepo(); + usecase = GetOrderDetailsUsecase(repo: mockRepo); + provideDummy>>(ErrorApiResult(error: 'dummy')); + }); + + const tOrderId = 'pxkMaEmWYVuvV5jkW0JK'; + + final tOrderModel = OrderModel( + driverId: 'D1', + userAddress: UserAddressModel(address: 'Shebin', name: 'Ali', userId: 'U1'), + userId: 'U1', + orderId: tOrderId, + orderDetails: OrderDetailsModel( + items: [], + status: 'accepted', + totalPrice: 500, + pickupAddress: PickedAddressModel(name: 'Pharmacy', address: 'Downtown'), + orderId: tOrderId, + userAddress: 'Shebin', + ), + ); + + group('GetOrderDetailsUsecase test', () { + test( + 'should return SuccessApiResult containing the Stream from the repository', + () async { + when(mockRepo.getOrderDetails()).thenAnswer( + (_) async => SuccessApiResult(data: Stream.value(tOrderModel)), + ); + + final result = await usecase.call(); + + expect(result, isA>>()); + final stream = (result as SuccessApiResult>).data; + await expectLater(stream, emits(tOrderModel)); + verify(mockRepo.getOrderDetails()).called(1); + }, + ); + + test('should return ErrorApiResult when the repository fails', () async { + when( + mockRepo.getOrderDetails(), + ).thenAnswer((_) async => ErrorApiResult(error: 'Error from Repository')); + + final result = await usecase.call(); + + expect(result, isA>>()); + expect((result as ErrorApiResult).error, 'Error from Repository'); + }); + }); +} diff --git a/test/features/driver_orders_details/presentation/pages/drivers_orders_details_page_test.dart b/test/features/driver_orders_details/presentation/pages/drivers_orders_details_page_test.dart new file mode 100644 index 0000000..18f5d20 --- /dev/null +++ b/test/features/driver_orders_details/presentation/pages/drivers_orders_details_page_test.dart @@ -0,0 +1,123 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/manager/order_details_cubit.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/manager/order_details_states.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/pages/drivers_orders_details_page.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/widgets/address_card.dart'; +import 'drivers_orders_details_page_test.mocks.dart'; + +@GenerateMocks([OrderDetailsCubit]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late MockOrderDetailsCubit mockCubit; + + setUp(() async { + await getIt.reset(); + mockCubit = MockOrderDetailsCubit(); + getIt.registerFactory(() => mockCubit); + when(mockCubit.state).thenReturn(OrderDetailsStates()); + when(mockCubit.stream).thenAnswer((_) => const Stream.empty()); + }); + + Widget buildTestableWidget() { + return EasyLocalization( + supportedLocales: const [Locale('en')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + startLocale: const Locale('en'), + saveLocale: false, + child: Builder( + builder: (context) { + return MaterialApp( + home: BlocProvider.value( + value: mockCubit, + child: const DriversOrdersDetailsPage(), + ), + ); + }, + ), + ); + } + + final tOrderModel = OrderModel( + driverId: 'D1', + userAddress: UserAddressModel(address: 'Shebin', name: 'Ali', userId: 'U1'), + userId: 'U1', + orderId: 'N123', + orderDetails: OrderDetailsModel( + items: [], + status: 'accepted', + totalPrice: 500, + pickupAddress: PickedAddressModel(name: 'Pharmacy', address: 'Downtown'), + orderId: 'N123', + userAddress: 'Shebin', + ), + ); + + group('DriversOrdersDetailsPage Widget Tests', () { + testWidgets('should show CircularProgressIndicator when state is loading', ( + tester, + ) async { + when( + mockCubit.state, + ).thenReturn(OrderDetailsStates(data: Resource.loading())); + when(mockCubit.stream).thenAnswer( + (_) => Stream.value(OrderDetailsStates(data: Resource.loading())), + ); + + await tester.pumpWidget(buildTestableWidget()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets( + 'should display order details correctly when state is success', + (tester) async { + when( + mockCubit.state, + ).thenReturn(OrderDetailsStates(data: Resource.success(tOrderModel))); + when(mockCubit.stream).thenAnswer( + (_) => Stream.value( + OrderDetailsStates(data: Resource.success(tOrderModel)), + ), + ); + + await tester.pumpWidget(buildTestableWidget()); + await tester.pump(); + + expect(find.textContaining('N123'), findsOneWidget); + expect(find.text('Ali'), findsOneWidget); + expect(find.text('Shebin'), findsAtLeastNWidgets(1)); + expect(find.textContaining('500'), findsOneWidget); + expect(find.byType(AddressCard), findsAtLeastNWidgets(2)); + }, + ); + + testWidgets('should display error message when state is error', ( + tester, + ) async { + const errorMessage = 'Failed to load order'; + when( + mockCubit.state, + ).thenReturn(OrderDetailsStates(data: Resource.error(errorMessage))); + when(mockCubit.stream).thenAnswer( + (_) => Stream.value( + OrderDetailsStates(data: Resource.error(errorMessage)), + ), + ); + + await tester.pumpWidget(buildTestableWidget()); + await tester.pump(); + + expect(find.text(errorMessage), findsOneWidget); + }); + }); +} diff --git a/test/features/driver_orders_details/presentation/pages/location_page_test.dart b/test/features/driver_orders_details/presentation/pages/location_page_test.dart new file mode 100644 index 0000000..a373b24 --- /dev/null +++ b/test/features/driver_orders_details/presentation/pages/location_page_test.dart @@ -0,0 +1,192 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/drivers_model.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/location_type.dart'; +import 'package:tracking_app/features/driver_orders_details/domain/models/orders_model.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/manager/order_details_cubit.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/manager/order_details_states.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/pages/location_page.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/widgets/address_card.dart'; +import 'package:tracking_app/features/driver_orders_details/presentation/widgets/section_title.dart'; + +import 'drivers_orders_details_page_test.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late MockOrderDetailsCubit mockCubit; + final driverData = DriverDataModel( + deviceToken: '', + currentLocation: DriverLocationModel(lat: 30.0, lng: 31.0), + id: '', + name: '', + phone: '', + ); + setUp(() async { + await getIt.reset(); + mockCubit = MockOrderDetailsCubit(); + getIt.registerFactory(() => mockCubit); + when(mockCubit.state).thenReturn(OrderDetailsStates()); + when(mockCubit.stream).thenAnswer((_) => const Stream.empty()); + }); + + Widget buildTestableWidget() { + return EasyLocalization( + supportedLocales: const [Locale('en')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + startLocale: const Locale('en'), + saveLocale: false, + child: Builder( + builder: (context) { + return MaterialApp( + home: BlocProvider.value( + value: mockCubit, + child: const LocationPage(locationType: LocationType.pickup), + ), + ); + }, + ), + ); + } + + group('Location Page widget test', () { + testWidgets('LocationPage shows loading indicator when driver is null', ( + WidgetTester tester, + ) async { + when( + mockCubit.state, + ).thenReturn(OrderDetailsStates(driverData: Resource.loading())); + when(mockCubit.stream).thenAnswer( + (_) => Stream.value(OrderDetailsStates(driverData: Resource.loading())), + ); + + await tester.pumpWidget(buildTestableWidget()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + testWidgets('Full LocationPage interaction and listener coverage', ( + tester, + ) async { + final orderData = OrderModel( + userAddress: UserAddressModel(name: '', address: '', userId: ''), + orderId: '', + driverId: '', + userId: '', + orderDetails: OrderDetailsModel( + items: [], + status: '', + totalPrice: 500, + pickupAddress: PickedAddressModel(name: '', address: ''), + orderId: '', + userAddress: '', + ), + ); + + final fullState = OrderDetailsStates( + driverData: Resource.success(driverData), + data: Resource.success(orderData), + polylines: [LatLng(30.0, 31.0), LatLng(30.1, 31.1)], + destination: LatLng(30.1, 31.1), + ); + + when(mockCubit.state).thenReturn(fullState); + when(mockCubit.stream).thenAnswer((_) => Stream.value(fullState)); + when( + mockCubit.setDestinationFromAddress(any, any), + ).thenAnswer((_) async {}); + await tester.pumpWidget(buildTestableWidget()); + await tester.pumpAndSettle(); + + final map = tester.widget(find.byType(GoogleMap)); + expect(map.mapType, MapType.normal); + expect(map.initialCameraPosition.zoom, 18); + + expect(fullState.polylines, isNotEmpty); + expect(fullState.destination, isNotNull); + + verify(mockCubit.setDestinationFromAddress(any, any)).called(1); + }); + + testWidgets('LocationPage shows GoogleMap when driver exists', ( + WidgetTester tester, + ) async { + when(mockCubit.state).thenReturn( + OrderDetailsStates(driverData: Resource.success(driverData)), + ); + when(mockCubit.stream).thenAnswer( + (_) => Stream.value( + OrderDetailsStates( + driverData: Resource.success(driverData), + data: Resource.success(null), + ), + ), + ); + + await tester.pumpWidget(buildTestableWidget()); + await tester.pump(); + + expect(find.byType(Padding), findsWidgets); + expect(find.byType(Scaffold), findsOneWidget); + expect(find.byType(Column), findsWidgets); + expect(find.byType(SizedBox), findsWidgets); + expect(find.byType(GoogleMap), findsOneWidget); + expect( + find.descendant( + of: find.byType(Expanded), + matching: find.byType(GoogleMap), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(Stack), + matching: find.byType(GoogleMap), + ), + findsOneWidget, + ); + expect(find.byType(Positioned), findsWidgets); + expect(find.byType(InkWell), findsAtLeast(1)); + expect(find.byType(CircleAvatar), findsNWidgets(3)); + expect(find.byType(AddressCard), findsWidgets); + expect( + find.descendant( + of: find.byType(Column), + matching: find.byType(AddressCard), + ), + findsWidgets, + ); + expect(find.byType(SectionTitle), findsWidgets); + expect( + find.descendant( + of: find.byType(Column), + matching: find.byType(SectionTitle), + ), + findsWidgets, + ); + }); + + testWidgets('Back button is displayed', (WidgetTester tester) async { + when(mockCubit.state).thenReturn( + OrderDetailsStates(driverData: Resource.success(driverData)), + ); + when(mockCubit.stream).thenAnswer( + (_) => Stream.value( + OrderDetailsStates(driverData: Resource.success(driverData)), + ), + ); + + await tester.pumpWidget(buildTestableWidget()); + await tester.pump(); + + expect(find.byIcon(Icons.arrow_back_ios_new), findsOneWidget); + await tester.pump(); + }); + }); +} diff --git a/test/features/home/api/driverOrderDataS_imp_test.dart b/test/features/home/api/driverOrderDataS_imp_test.dart new file mode 100644 index 0000000..9071216 --- /dev/null +++ b/test/features/home/api/driverOrderDataS_imp_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:retrofit/retrofit.dart'; +import 'package:tracking_app/app/core/api_manger/api_client.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/home/api/driverOrderDataS_imp.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:dio/dio.dart'; + +import 'driverOrderDataS_imp_test.mocks.dart'; + +@GenerateMocks([ApiClient]) +void main() { + late DriverOrderDataSourceImpl dataSource; + late MockApiClient mockApiClient; + + setUp(() { + mockApiClient = MockApiClient(); + dataSource = DriverOrderDataSourceImpl(mockApiClient); + }); + + group('DriverOrderDataSourceImpl', () { + const tToken = 'test_token'; + final tOrderResponse = OrderResponse(message: 'Success', orders: []); + + test( + 'should return SuccessApiResult when the call to ApiClient is successful', + () async { + // Arrange + final httpResponse = HttpResponse( + tOrderResponse, + Response( + data: tOrderResponse, + requestOptions: RequestOptions(path: ''), + statusCode: 200, + ), + ); + when( + mockApiClient.getPendingOrders(any), + ).thenAnswer((_) async => httpResponse); + + // Act + final result = await dataSource.getPendingOrders(tToken); + + // Assert + expect(result, isA>()); + verify(mockApiClient.getPendingOrders(tToken)); + verifyNoMoreInteractions(mockApiClient); + }, + ); + + test( + 'should return ErrorApiResult when the call to ApiClient throws an exception', + () async { + // Arrange + when(mockApiClient.getPendingOrders(any)).thenThrow( + DioException( + requestOptions: RequestOptions(path: ''), + error: 'Error', + type: DioExceptionType.unknown, + ), + ); + + // Act + final result = await dataSource.getPendingOrders(tToken); + + // Assert + expect(result, isA>()); + verify(mockApiClient.getPendingOrders(tToken)); + verifyNoMoreInteractions(mockApiClient); + }, + ); + }); +} diff --git a/test/features/home/data/model/response/orderRespons_test.dart b/test/features/home/data/model/response/orderRespons_test.dart new file mode 100644 index 0000000..60b946c --- /dev/null +++ b/test/features/home/data/model/response/orderRespons_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; + +void main() { + group('OrderResponse', () { + final tOrderResponse = OrderResponse(message: 'Success'); + + test('should work with copyWith', () { + final result = tOrderResponse.copyWith(message: 'New Success'); + expect(result.message, 'New Success'); + }); + + test('fromJson should return a valid model', () { + final Map jsonMap = {"message": "Success", "orders": []}; + final result = OrderResponse.fromJson(jsonMap); + expect(result, isA()); + expect(result.message, "Success"); + }); + + test('toJson should return a JSON map containing proper data', () { + final result = tOrderResponse.toJson(); + final expectedMap = { + "message": "Success", + "metadata": null, + "orders": null, + }; + expect(result, expectedMap); + }); + }); +} diff --git a/test/features/home/data/repo/driverOrderRepo_impl_test.dart b/test/features/home/data/repo/driverOrderRepo_impl_test.dart new file mode 100644 index 0000000..f1f142e --- /dev/null +++ b/test/features/home/data/repo/driverOrderRepo_impl_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/home/data/datascourse/driverOrderDatascource.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/home/data/repo/driverOrderRepo_impl.dart'; + +import 'driverOrderRepo_impl_test.mocks.dart'; + +@GenerateMocks([DriverOrderDataSource]) +void main() { + late DriverOrderRepositoryImpl repository; + late MockDriverOrderDataSource mockDataSource; + + setUp(() { + provideDummy>( + SuccessApiResult(data: OrderResponse()), + ); + mockDataSource = MockDriverOrderDataSource(); + repository = DriverOrderRepositoryImpl(mockDataSource); + }); + + group('DriverOrderRepositoryImpl', () { + const tToken = 'test_token'; + final tOrderResponse = OrderResponse(message: 'Success', orders: []); + + test( + 'should return data when the call to remote data source is successful', + () async { + // Arrange + when( + mockDataSource.getPendingOrders(any), + ).thenAnswer((_) async => SuccessApiResult(data: tOrderResponse)); + + // Act + final result = await repository.getPendingOrders(tToken); + + // Assert + expect(result, isA>()); + verify(mockDataSource.getPendingOrders(tToken)); + verifyNoMoreInteractions(mockDataSource); + }, + ); + + test( + 'should return error when the call to remote data source is unsuccessful', + () async { + // Arrange + when( + mockDataSource.getPendingOrders(any), + ).thenAnswer((_) async => ErrorApiResult(error: 'Error')); + + // Act + final result = await repository.getPendingOrders(tToken); + + // Assert + expect(result, isA>()); + verify(mockDataSource.getPendingOrders(tToken)); + verifyNoMoreInteractions(mockDataSource); + }, + ); + }); +} diff --git a/test/features/home/domain/usecases/getdriverOrderUsecase_test.dart b/test/features/home/domain/usecases/getdriverOrderUsecase_test.dart new file mode 100644 index 0000000..2d21ae9 --- /dev/null +++ b/test/features/home/domain/usecases/getdriverOrderUsecase_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/home/domain/repo/driverOrderRepo.dart'; +import 'package:tracking_app/features/home/domain/usecase/getdriverOrderUsecase.dart'; + +import 'getdriverOrderUsecase_test.mocks.dart'; + +@GenerateMocks([DriverOrderRepo]) +void main() { + late GetDriverOrdersUseCase useCase; + late MockDriverOrderRepo mockRepository; + + setUp(() { + provideDummy>( + SuccessApiResult(data: OrderResponse()), + ); + mockRepository = MockDriverOrderRepo(); + useCase = GetDriverOrdersUseCase(mockRepository); + }); + + const tToken = 'test_token'; + final tOrderResponse = OrderResponse(message: 'Success', orders: []); + + group('GetDriverOrdersUseCase', () { + test('should get pending orders from the repository', () async { + // Arrange + when( + mockRepository.getPendingOrders(any), + ).thenAnswer((_) async => SuccessApiResult(data: tOrderResponse)); + + // Act + final result = await useCase(tToken); + + // Assert + expect(result, isA>()); + verify(mockRepository.getPendingOrders(tToken)); + verifyNoMoreInteractions(mockRepository); + }); + + test('should return error value from the repository', () async { + // Arrange + when( + mockRepository.getPendingOrders(any), + ).thenAnswer((_) async => ErrorApiResult(error: 'Error')); + + // Act + final result = await useCase(tToken); + + // Assert + expect(result, isA>()); + verify(mockRepository.getPendingOrders(tToken)); + verifyNoMoreInteractions(mockRepository); + }); + }); +} diff --git a/test/features/home/presentation/manger/driverorderCubit_test.dart b/test/features/home/presentation/manger/driverorderCubit_test.dart new file mode 100644 index 0000000..41faf4b --- /dev/null +++ b/test/features/home/presentation/manger/driverorderCubit_test.dart @@ -0,0 +1,189 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/home/domain/repo/driverOrderRepo.dart'; +import 'package:tracking_app/features/home/domain/usecase/getdriverOrderUsecase.dart'; +import 'package:tracking_app/features/home/domain/usecase/getdriverOrderUsecase.dart'; +import 'package:tracking_app/features/home/domain/usecase/upload_driver_fire_data_use_case.dart'; +import 'package:tracking_app/features/home/domain/usecase/upload_order_fire_data_use_case.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderCubit.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderIntent.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderStates.dart'; + +import 'driverorderCubit_test.mocks.dart'; + +@GenerateMocks([ + DriverOrderRepo, + AuthStorage, + UploadDriverFireDataUseCase, + UploadOrderFireDataUseCase, +]) +void main() { + late DriverOrderCubit driverOrderCubit; + late MockDriverOrderRepo mockDriverOrderRepo; + late MockUploadDriverFireDataUseCase mockUploadDriverFireDataUseCase; + late MockUploadOrderFireDataUseCase mockUploadOrderFireDataUseCase; + late GetDriverOrdersUseCase getDriverOrdersUseCase; + late MockAuthStorage mockAuthStorage; + + setUp(() { + provideDummy>( + SuccessApiResult(data: OrderResponse()), + ); + mockDriverOrderRepo = MockDriverOrderRepo(); + mockAuthStorage = MockAuthStorage(); + mockUploadDriverFireDataUseCase = MockUploadDriverFireDataUseCase(); + mockUploadOrderFireDataUseCase = MockUploadOrderFireDataUseCase(); + getDriverOrdersUseCase = GetDriverOrdersUseCase(mockDriverOrderRepo); + driverOrderCubit = DriverOrderCubit( + getDriverOrdersUseCase, + mockAuthStorage, + mockUploadDriverFireDataUseCase, + mockUploadOrderFireDataUseCase, + mockDriverOrderRepo, + ); + }); + + tearDown(() { + driverOrderCubit.close(); + }); + + group('DriverOrderCubit', () { + test('initial state is DriverOrderState with Resource.initial', () { + expect(driverOrderCubit.state.orderResource.status, Status.initial); + }); + + final tOrderResponse = OrderResponse( + message: 'Success', + orders: [ + Order(id: '1', state: 'pending'), + Order(id: '2', state: 'pending'), + ], + ); + + group('GetPendingOrders', () { + blocTest( + 'emits [loading, success] when GetPendingOrders is added and token exists and api call is successful', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => 'token'); + when( + mockDriverOrderRepo.getPendingOrders('token'), + ).thenAnswer((_) async => SuccessApiResult(data: tOrderResponse)); + return driverOrderCubit; + }, + act: (cubit) => cubit.onIntent(GetPendingOrders()), + expect: () => [ + isA().having( + (state) => state.orderResource.status, + 'status', + Status.loading, + ), + isA() + .having( + (state) => state.orderResource.status, + 'status', + Status.success, + ) + .having( + (state) => state.orderResource.data, + 'data', + tOrderResponse, + ), + ], + ); + + blocTest( + 'emits [loading, error] when GetPendingOrders is added and token is null', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => null); + return driverOrderCubit; + }, + act: (cubit) => cubit.onIntent(GetPendingOrders()), + expect: () => [ + isA().having( + (state) => state.orderResource.status, + 'status', + Status.loading, + ), + isA() + .having( + (state) => state.orderResource.status, + 'status', + Status.error, + ) + .having( + (state) => state.orderResource.error, + 'error', + 'User not authenticated', + ), + ], + ); + + blocTest( + 'emits [loading, error] when GetPendingOrders is added and api call fails', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => 'token'); + when( + mockDriverOrderRepo.getPendingOrders('token'), + ).thenAnswer((_) async => ErrorApiResult(error: 'API Error')); + return driverOrderCubit; + }, + act: (cubit) => cubit.onIntent(GetPendingOrders()), + expect: () => [ + isA().having( + (state) => state.orderResource.status, + 'status', + Status.loading, + ), + isA() + .having( + (state) => state.orderResource.status, + 'status', + Status.error, + ) + .having( + (state) => state.orderResource.error, + 'error', + 'API Error', + ), + ], + ); + }); + + group('RemoveOrder', () { + final orderToRemove = Order(id: '1', state: 'pending'); + final orderToKeep = Order(id: '2', state: 'pending'); + final initialOrders = [orderToRemove, orderToKeep]; + final initialOrderResponse = OrderResponse(orders: initialOrders); + + blocTest( + 'emits [success] with updated orders when RemoveOrder is added', + build: () => driverOrderCubit, + seed: () => DriverOrderState( + orderResource: Resource.success(initialOrderResponse), + ), + act: (cubit) => cubit.onIntent(RemoveOrder(orderToRemove)), + expect: () => [ + isA().having( + (state) => state.orderResource.data?.orders, + 'orders', + [orderToKeep], + ), + ], + ); + + blocTest( + 'does nothing when RemoveOrder is added but current state is not success', + build: () => driverOrderCubit, + seed: () => DriverOrderState(orderResource: Resource.loading()), + act: (cubit) => cubit.onIntent(RemoveOrder(orderToRemove)), + expect: () => [], + ); + }); + }); +} diff --git a/test/features/home/presentation/pages/driverOrderScreen_test.dart b/test/features/home/presentation/pages/driverOrderScreen_test.dart new file mode 100644 index 0000000..ec577d3 --- /dev/null +++ b/test/features/home/presentation/pages/driverOrderScreen_test.dart @@ -0,0 +1,126 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/home/domain/repo/driverOrderRepo.dart'; +import 'package:tracking_app/features/home/domain/usecase/getdriverOrderUsecase.dart'; +import 'package:tracking_app/features/home/domain/usecase/getdriverOrderUsecase.dart'; +import 'package:tracking_app/features/home/domain/usecase/upload_driver_fire_data_use_case.dart'; +import 'package:tracking_app/features/home/domain/usecase/upload_order_fire_data_use_case.dart'; +import 'package:tracking_app/features/home/presentation/manger/driverorderCubit.dart'; +import 'package:tracking_app/features/home/presentation/pages/driverOrderScreen.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverOrderItem.dart'; + +import 'driverOrderScreen_test.mocks.dart'; + +@GenerateMocks([ + DriverOrderRepo, + AuthStorage, + UploadDriverFireDataUseCase, + UploadOrderFireDataUseCase, +]) +void main() { + late MockDriverOrderRepo mockDriverOrderRepo; + late MockAuthStorage mockAuthStorage; + late MockUploadDriverFireDataUseCase mockUploadDriverFireDataUseCase; + late MockUploadOrderFireDataUseCase mockUploadOrderFireDataUseCase; + late GetDriverOrdersUseCase getDriverOrdersUseCase; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + }); + + setUp(() async { + mockDriverOrderRepo = MockDriverOrderRepo(); + mockAuthStorage = MockAuthStorage(); + mockUploadDriverFireDataUseCase = MockUploadDriverFireDataUseCase(); + mockUploadOrderFireDataUseCase = MockUploadOrderFireDataUseCase(); + getDriverOrdersUseCase = GetDriverOrdersUseCase(mockDriverOrderRepo); + + provideDummy>( + SuccessApiResult(data: OrderResponse()), + ); + + await GetIt.I.reset(); + GetIt.I.registerFactory( + () => DriverOrderCubit( + getDriverOrdersUseCase, + mockAuthStorage, + mockUploadDriverFireDataUseCase, + mockUploadOrderFireDataUseCase, + mockDriverOrderRepo, + ), + ); + }); + + Widget createWidgetUnderTest() { + return EasyLocalization( + supportedLocales: const [Locale('en')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: const MaterialApp(home: DriverOrderScreen()), + ); + } + + group('DriverOrderScreen Integration Tests', () { + testWidgets('displays CircularProgressIndicator when loading', ( + tester, + ) async { + // Arrange + when(mockAuthStorage.getToken()).thenAnswer((_) async => 'token'); + + when(mockDriverOrderRepo.getPendingOrders(any)).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + return SuccessApiResult(data: OrderResponse(orders: [])); + }); + + // Act + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + + // Assert + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pumpAndSettle(); + }); + + testWidgets('displays error message when error occurs', (tester) async { + // Arrange + const errorMessage = 'Network Error'; + when(mockAuthStorage.getToken()).thenAnswer((_) async => 'token'); + when( + mockDriverOrderRepo.getPendingOrders(any), + ).thenAnswer((_) async => ErrorApiResult(error: errorMessage)); + + // Act + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + // Assert + expect(find.text(errorMessage), findsOneWidget); + }); + + testWidgets('displays "noPendingOrders" when success but empty list', ( + tester, + ) async { + // Arrange + when(mockAuthStorage.getToken()).thenAnswer((_) async => 'token'); + when(mockDriverOrderRepo.getPendingOrders(any)).thenAnswer( + (_) async => SuccessApiResult(data: OrderResponse(orders: [])), + ); + + // Act + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + // Assert + expect(find.text('noPendingOrders'), findsOneWidget); + }); + }); +} diff --git a/test/features/home/presentation/widgets/driverOrderButton_test.dart b/test/features/home/presentation/widgets/driverOrderButton_test.dart new file mode 100644 index 0000000..59fe9a8 --- /dev/null +++ b/test/features/home/presentation/widgets/driverOrderButton_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverOrderButton.dart'; + +void main() { + group('DriverOrderButton Widget Tests', () { + testWidgets('renders button with correct text', (tester) async { + // Arrange + const buttonText = 'Accept'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DriverOrderButton( + text: buttonText, + onTap: () {}, + isPrimary: true, + ), + ), + ), + ); + + // Assert + expect(find.text(buttonText), findsOneWidget); + }); + + testWidgets('calls onTap when tapped', (tester) async { + // Arrange + var isTapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DriverOrderButton( + text: 'Tap Me', + onTap: () { + isTapped = true; + }, + isPrimary: true, + ), + ), + ), + ); + + // Act + await tester.tap(find.byType(DriverOrderButton)); + await tester.pumpAndSettle(); + + // Assert + expect(isTapped, isTrue); + }); + + testWidgets('renders primary style correctly', (tester) async { + // Arrange + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DriverOrderButton( + text: 'Primary', + onTap: () {}, + isPrimary: true, + ), + ), + ), + ); + + // Verify Container decoration + final container = tester.widget( + find.ancestor( + of: find.text('Primary'), + matching: find.byType(Container), + ), + ); + final decoration = container.decoration as BoxDecoration; + + // Assert + expect(decoration.color, const Color(0xFFE91E63)); // Primary color + expect(decoration.border, isNull); + + // Verify Text style + final text = tester.widget(find.text('Primary')); + expect(text.style?.color, Colors.white); + }); + + testWidgets('renders secondary style correctly', (tester) async { + // Arrange + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DriverOrderButton( + text: 'Secondary', + onTap: () {}, + isPrimary: false, + ), + ), + ), + ); + + // Verify Container decoration + final container = tester.widget( + find.ancestor( + of: find.text('Secondary'), + matching: find.byType(Container), + ), + ); + final decoration = container.decoration as BoxDecoration; + + // Assert + expect(decoration.color, Colors.white); + expect(decoration.border, isNotNull); + // We can check border color if needed, but existence is good for now + + // Verify Text style + final text = tester.widget(find.text('Secondary')); + expect(text.style?.color, const Color(0xFFE91E63)); + }); + }); +} diff --git a/test/features/home/presentation/widgets/driverOrderInfoCard_test.dart b/test/features/home/presentation/widgets/driverOrderInfoCard_test.dart new file mode 100644 index 0000000..303e263 --- /dev/null +++ b/test/features/home/presentation/widgets/driverOrderInfoCard_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverOrderInfoCard.dart'; + +void main() { + group('DriverOrderInfoCard Widget Tests', () { + testWidgets('renders correct title and subtitle', (tester) async { + const title = 'Test Title'; + const subtitle = 'Test Subtitle'; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DriverOrderInfoCard( + image: null, + title: title, + subtitle: subtitle, + isStore: false, + ), + ), + ), + ); + + expect(find.text(title), findsOneWidget); + expect(find.text(subtitle), findsOneWidget); + }); + + testWidgets('renders store icon when isStore is true and image is null', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DriverOrderInfoCard( + image: null, + title: 'Store', + subtitle: 'Address', + isStore: true, + ), + ), + ), + ); + + expect(find.byIcon(Icons.store), findsOneWidget); + expect(find.byIcon(Icons.person), findsNothing); + }); + + testWidgets('renders person icon when isStore is false and image is null', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DriverOrderInfoCard( + image: null, + title: 'User', + subtitle: 'Address', + isStore: false, + ), + ), + ), + ); + + expect(find.byIcon(Icons.person), findsOneWidget); + expect(find.byIcon(Icons.store), findsNothing); + }); + + testWidgets('renders NetworkImage when image is provided', (tester) async { + const imageUrl = 'https://example.com/image.jpg'; + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DriverOrderInfoCard( + image: imageUrl, + title: 'With Image', + subtitle: 'Address', + isStore: false, + ), + ), + ), + ); + }); + + // We need to find the specific container with the image. + // The hierarchy is Container > Row > [Container(image), SizedBox, Expanded(...)] + // So let's look for a Container with a BoxDecoration that has an image. + + final imageContainer = find.byWidgetPredicate((widget) { + if (widget is Container && widget.decoration is BoxDecoration) { + final decoration = widget.decoration as BoxDecoration; + return decoration.image != null && + decoration.image!.image is NetworkImage && + (decoration.image!.image as NetworkImage).url == imageUrl; + } + return false; + }); + + expect(imageContainer, findsOneWidget); + + // Verify no fallback icon is shown + expect(find.byIcon(Icons.person), findsNothing); + expect(find.byIcon(Icons.store), findsNothing); + }); + }); +} diff --git a/test/features/home/presentation/widgets/driverOrderItem_test.dart b/test/features/home/presentation/widgets/driverOrderItem_test.dart new file mode 100644 index 0000000..1489f84 --- /dev/null +++ b/test/features/home/presentation/widgets/driverOrderItem_test.dart @@ -0,0 +1,110 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tracking_app/features/home/data/model/response/orderRespons.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverOrderButton.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverOrderInfoCard.dart'; +import 'package:tracking_app/features/home/presentation/widgets/driverOrderItem.dart'; + +void main() { + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + }); + + Widget createWidgetUnderTest( + Order order, { + VoidCallback? onAccept, + VoidCallback? onReject, + }) { + return EasyLocalization( + supportedLocales: const [Locale('en')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: MaterialApp( + home: Scaffold( + body: DriverOrderItem( + order: order, + onAccept: onAccept ?? () {}, + onReject: onReject ?? () {}, + ), + ), + ), + ); + } + + group('DriverOrderItem Widget Tests', () { + final testOrder = Order( + id: '1', + totalPrice: 100, + store: Store( + name: 'Test Store', + address: 'Store Address', + image: 'store_image.jpg', + ), + user: User(firstName: 'John', lastName: 'Doe', photo: 'user_photo.jpg'), + shippingAddress: ShippingAddress(street: 'User Street'), + ); + + testWidgets('renders order details correctly', (tester) async { + await mockNetworkImagesFor(() async { + await tester.pumpWidget(createWidgetUnderTest(testOrder)); + await tester.pumpAndSettle(); + }); + + // Verify Store Info + expect(find.text('Test Store'), findsOneWidget); + expect(find.text('Store Address'), findsOneWidget); + + // Verify User Info + expect(find.text('John Doe'), findsOneWidget); + expect(find.text('User Street'), findsOneWidget); + + // Verify Price + expect(find.text('100 egp'), findsOneWidget); + + // Verify sub-widgets are present + expect(find.byType(DriverOrderInfoCard), findsNWidgets(2)); + expect(find.byType(DriverOrderButton), findsNWidgets(2)); + }); + + testWidgets('calls onAccept when accept button is tapped', (tester) async { + var isAccepted = false; + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + createWidgetUnderTest(testOrder, onAccept: () => isAccepted = true), + ); + await tester.pumpAndSettle(); + }); + + final acceptButtonFinder = find.descendant( + of: find.byType(DriverOrderButton), + matching: find.text('accept'), + ); + + await tester.tap(acceptButtonFinder); + expect(isAccepted, isTrue); + }); + + testWidgets('calls onReject when reject button is tapped', (tester) async { + var isRejected = false; + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + createWidgetUnderTest(testOrder, onReject: () => isRejected = true), + ); + await tester.pumpAndSettle(); + }); + + // Find reject button + final rejectButtonFinder = find.descendant( + of: find.byType(DriverOrderButton), + matching: find.text('reject'), + ); + + await tester.tap(rejectButtonFinder); + expect(isRejected, isTrue); + }); + }); +} diff --git a/test/features/my_orders/api/datasource/my_orders_remote_data_source_imp_test.dart b/test/features/my_orders/api/datasource/my_orders_remote_data_source_imp_test.dart new file mode 100644 index 0000000..55ecd3e --- /dev/null +++ b/test/features/my_orders/api/datasource/my_orders_remote_data_source_imp_test.dart @@ -0,0 +1,85 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:retrofit/retrofit.dart'; +import 'package:tracking_app/app/core/api_manger/api_client.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/my_orders/api/datasource/my_orders_remote_data_source_imp.dart'; +import 'package:tracking_app/features/my_orders/data/models/response/my_order_response.dart'; + +import 'my_orders_remote_data_source_imp_test.mocks.dart'; + +@GenerateMocks([ApiClient]) +void main() { + late MyOrdersRemoteDataSourceImp dataSource; + late MockApiClient mockApiClient; + + setUp(() { + mockApiClient = MockApiClient(); + dataSource = MyOrdersRemoteDataSourceImp(mockApiClient); + }); + + const tToken = 'token123'; + const tLimit = 10; + const tPage = 1; + final tOrderResponse = MyOrderResponse(orders: []); + + group('MyOrdersRemoteDataSourceImp', () { + test( + 'should return SuccessApiResult when apiClient call is successful', + () async { + // Arrange + final httpResponse = HttpResponse( + tOrderResponse, + Response(requestOptions: RequestOptions(path: ''), statusCode: 200), + ); + when( + mockApiClient.getAllOrders( + token: anyNamed('token'), + limit: anyNamed('limit'), + page: anyNamed('page'), + ), + ).thenAnswer((_) async => httpResponse); + + // Act + final result = await dataSource.getAllOrders( + token: tToken, + limit: tLimit, + page: tPage, + ); + + // Assert + expect(result, isA>()); + expect( + (result as SuccessApiResult).data, + tOrderResponse, + ); + verify( + mockApiClient.getAllOrders(token: tToken, limit: tLimit, page: tPage), + ).called(1); + }, + ); + + test('should return ErrorApiResult when apiClient call fails', () async { + // Arrange + when( + mockApiClient.getAllOrders( + token: anyNamed('token'), + limit: anyNamed('limit'), + page: anyNamed('page'), + ), + ).thenThrow(DioException(requestOptions: RequestOptions(path: ''))); + + // Act + final result = await dataSource.getAllOrders( + token: tToken, + limit: tLimit, + page: tPage, + ); + + // Assert + expect(result, isA>()); + }); + }); +} diff --git a/test/features/my_orders/data/mappers/metadata_mapper_test.dart b/test/features/my_orders/data/mappers/metadata_mapper_test.dart new file mode 100644 index 0000000..b7a9da7 --- /dev/null +++ b/test/features/my_orders/data/mappers/metadata_mapper_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/my_orders/data/mappers/metadata_mapper.dart'; +import 'package:tracking_app/features/my_orders/data/models/meta_data_dto.dart'; +import 'package:tracking_app/features/my_orders/domain/models/meta_data_entity.dart'; + +void main() { + group('MetadataMapper', () { + test('should map Metadata DTO to MetadataEntity correctly', () { + final dto = Metadata( + currentPage: 1, + totalPages: 10, + totalItems: 100, + limit: 10, + cancelledCount: 5, + completedCount: 95, + ); + + final result = dto.toEntity(); + + expect(result, isA()); + expect(result.currentPage, 1); + expect(result.totalPages, 10); + expect(result.totalItems, 100); + expect(result.limit, 10); + expect(result.cancelledCount, 5); + expect(result.completedCount, 95); + }); + + test( + 'should map Metadata DTO with null fields to MetadataEntity with default values', + () { + final dto = Metadata( + currentPage: null, + totalPages: null, + totalItems: null, + limit: null, + cancelledCount: null, + completedCount: null, + ); + + final result = dto.toEntity(); + + expect(result.currentPage, 0); + expect(result.totalPages, 0); + expect(result.totalItems, 0); + expect(result.limit, 10); + expect(result.cancelledCount, 0); + expect(result.completedCount, 0); + }, + ); + }); +} diff --git a/test/features/my_orders/data/mappers/order_item_mapper_test.dart b/test/features/my_orders/data/mappers/order_item_mapper_test.dart new file mode 100644 index 0000000..76dbe6f --- /dev/null +++ b/test/features/my_orders/data/mappers/order_item_mapper_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/my_orders/data/mappers/order_item_mapper.dart'; +import 'package:tracking_app/features/my_orders/data/models/order_item_model.dart'; +import 'package:tracking_app/features/my_orders/data/models/product_model.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_item_entity.dart'; + +void main() { + group('OrderItemMapper', () { + test('should map OrderItem model to OrderItemEntity correctly', () { + final model = OrderItem( + id: 'i1', + product: Product(id: 'p1', price: 100), + price: 100, + quantity: 2, + ); + + final result = model.toEntity(); + + expect(result, isA()); + expect(result.product.id, 'p1'); + expect(result.price, 100); + expect(result.quantity, 2); + }); + + test( + 'should map OrderItem model with null fields to OrderItemEntity with default values', + () { + final model = OrderItem( + id: null, + product: null, + price: null, + quantity: null, + ); + + final result = model.toEntity(); + + expect(result.product.id, ''); + expect(result.price, 0); + expect(result.quantity, 0); + }, + ); + }); +} diff --git a/test/features/my_orders/data/mappers/order_mapper_test.dart b/test/features/my_orders/data/mappers/order_mapper_test.dart new file mode 100644 index 0000000..6480014 --- /dev/null +++ b/test/features/my_orders/data/mappers/order_mapper_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/my_orders/data/mappers/order_mapper.dart'; +import 'package:tracking_app/features/my_orders/data/models/order_model.dart'; +import 'package:tracking_app/features/my_orders/data/models/user_model.dart'; +import 'package:tracking_app/features/my_orders/data/models/store_model.dart'; +import 'package:tracking_app/features/my_orders/data/models/order_item_model.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; + +void main() { + group('OrderMapper', () { + test('should map Order model to OrderEntity correctly', () { + final model = Order( + id: 'o1', + user: User(id: 'u1', firstName: 'Noor', lastName: 'Mohamed'), + store: Store(name: 'Store Name'), + address: 'User Address', + orderItems: [OrderItem(price: 100, quantity: 1)], + totalPrice: 100, + paymentType: 'Cash', + isPaid: true, + isDelivered: true, + state: 'Delivered', + createdAt: '2023-01-01', + orderNumber: 'ORD123', + ); + + final result = model.toEntity(); + + expect(result, isA()); + expect(result.id, 'o1'); + expect(result.user.id, 'u1'); + expect(result.store?.name, 'Store Name'); + expect(result.address, 'User Address'); + expect(result.items.length, 1); + expect(result.totalPrice, 100); + expect(result.paymentType, 'Cash'); + expect(result.isPaid, true); + expect(result.isDelivered, true); + expect(result.state, 'Delivered'); + expect(result.createdAt, '2023-01-01'); + expect(result.orderNumber, 'ORD123'); + }); + + test( + 'should map Order model with null fields to OrderEntity with default values', + () { + final model = Order( + id: null, + user: User(id: null), + store: null, + address: null, + orderItems: null, + totalPrice: null, + paymentType: null, + isPaid: null, + isDelivered: null, + state: null, + createdAt: null, + orderNumber: null, + ); + + final result = model.toEntity(); + + expect(result.id, ''); + expect(result.user.id, ''); + expect(result.store, isNull); + expect(result.address, ''); + expect(result.items, isEmpty); + expect(result.totalPrice, 0); + expect(result.paymentType, ''); + expect(result.isPaid, false); + expect(result.isDelivered, false); + expect(result.state, ''); + expect(result.createdAt, ''); + expect(result.orderNumber, ''); + }, + ); + }); +} diff --git a/test/features/my_orders/data/mappers/orders_list_mapper_test.dart b/test/features/my_orders/data/mappers/orders_list_mapper_test.dart new file mode 100644 index 0000000..32d0a13 --- /dev/null +++ b/test/features/my_orders/data/mappers/orders_list_mapper_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/my_orders/data/mappers/orders_list_mapper.dart'; +import 'package:tracking_app/features/my_orders/data/models/order_model.dart'; +import 'package:tracking_app/features/my_orders/data/models/user_model.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; + +void main() { + group('OrdersListMapper', () { + test('should map List to List correctly', () { + final list = [ + Order( + id: 'o1', + user: User(id: 'u1'), + ), + Order( + id: 'o2', + user: User(id: 'u2'), + ), + ]; + + final result = list.toEntityList(); + + expect(result, isA>()); + expect(result.length, 2); + expect(result[0].id, 'o1'); + expect(result[1].id, 'o2'); + }); + + test('should map empty List to empty List', () { + final list = []; + + final result = list.toEntityList(); + + expect(result, isEmpty); + }); + }); +} diff --git a/test/features/my_orders/data/mappers/product_mapper_test.dart b/test/features/my_orders/data/mappers/product_mapper_test.dart new file mode 100644 index 0000000..510cc0e --- /dev/null +++ b/test/features/my_orders/data/mappers/product_mapper_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/my_orders/data/mappers/product_mapper.dart'; +import 'package:tracking_app/features/my_orders/data/models/product_model.dart'; +import 'package:tracking_app/features/my_orders/domain/models/product_entity.dart'; + +void main() { + group('ProductMapper', () { + test('should map Product model to ProductEntity correctly', () { + final model = Product(id: 'p1', price: 100); + + final result = model.toEntity(); + + expect(result, isA()); + expect(result.id, 'p1'); + expect(result.price, 100); + }); + + test( + 'should map Product model with null fields to ProductEntity with default values', + () { + final model = Product(id: null, price: null); + + final result = model.toEntity(); + + expect(result.id, ''); + expect(result.price, 0); + }, + ); + }); +} diff --git a/test/features/my_orders/data/mappers/store_mapper_test.dart b/test/features/my_orders/data/mappers/store_mapper_test.dart new file mode 100644 index 0000000..3cac0f7 --- /dev/null +++ b/test/features/my_orders/data/mappers/store_mapper_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/my_orders/data/mappers/store_mapper.dart'; +import 'package:tracking_app/features/my_orders/data/models/store_model.dart'; +import 'package:tracking_app/features/my_orders/domain/models/store_entity.dart'; + +void main() { + group('StoreMapper', () { + test('should map Store model to StoreEntity correctly', () { + final model = Store( + name: 'Store Name', + image: 'image_url', + address: 'Store Address', + phoneNumber: '01012345678', + ); + + final result = model.toEntity(); + + expect(result, isA()); + expect(result.name, 'Store Name'); + expect(result.image, 'image_url'); + expect(result.address, 'Store Address'); + expect(result.phoneNumber, '01012345678'); + }); + + test( + 'should map Store model with null fields to StoreEntity with default values', + () { + final model = Store( + name: null, + image: null, + address: null, + phoneNumber: null, + ); + + final result = model.toEntity(); + + expect(result.name, ''); + expect(result.image, ''); + expect(result.address, ''); + expect(result.phoneNumber, ''); + }, + ); + }); +} diff --git a/test/features/my_orders/data/mappers/user_mapper_test.dart b/test/features/my_orders/data/mappers/user_mapper_test.dart new file mode 100644 index 0000000..93e4502 --- /dev/null +++ b/test/features/my_orders/data/mappers/user_mapper_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/my_orders/data/mappers/user_mapper.dart'; +import 'package:tracking_app/features/my_orders/data/models/user_model.dart'; +import 'package:tracking_app/features/my_orders/domain/models/user_entity.dart'; + +void main() { + group('UserMapper', () { + test('should map User model to UserEntity correctly', () { + final model = User( + id: 'u1', + firstName: 'Noor', + lastName: 'Mohamed', + phone: '01012345678', + photo: 'photo_url', + ); + + final result = model.toEntity(); + + expect(result, isA()); + expect(result.id, 'u1'); + expect(result.firstName, 'Noor'); + expect(result.lastName, 'Mohamed'); + expect(result.phone, '01012345678'); + expect(result.photo, 'photo_url'); + }); + + test( + 'should map User model with null fields to UserEntity with default values', + () { + final model = User( + id: null, + firstName: null, + lastName: null, + phone: null, + photo: null, + ); + + final result = model.toEntity(); + + expect(result.id, ''); + expect(result.firstName, ''); + expect(result.lastName, ''); + expect(result.phone, ''); + expect(result.photo, ''); + }, + ); + }); +} diff --git a/test/features/my_orders/data/repo/my_orders_repo_imp_test.dart b/test/features/my_orders/data/repo/my_orders_repo_imp_test.dart new file mode 100644 index 0000000..2d534b9 --- /dev/null +++ b/test/features/my_orders/data/repo/my_orders_repo_imp_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/my_orders/data/datasource/my_orders_remote_data_source.dart'; +import 'package:tracking_app/features/my_orders/data/models/response/my_order_response.dart'; +import 'package:tracking_app/features/my_orders/data/models/order_model.dart'; +import 'package:tracking_app/features/my_orders/data/models/user_model.dart'; +import 'package:tracking_app/features/my_orders/data/repo/my_orders_repo_imp.dart'; +import 'package:tracking_app/features/my_orders/domain/repo/my_orders_repo.dart'; + +import 'my_orders_repo_imp_test.mocks.dart'; + +@GenerateMocks([MyOrdersRemoteDataSource]) +void main() { + late MyOrdersRepoImpl repo; + late MockMyOrdersRemoteDataSource mockRemoteDataSource; + + setUpAll(() { + provideDummy>( + SuccessApiResult(data: MyOrderResponse(orders: [])), + ); + }); + + setUp(() { + mockRemoteDataSource = MockMyOrdersRemoteDataSource(); + repo = MyOrdersRepoImpl(mockRemoteDataSource); + }); + + const tToken = 'token123'; + final tOrderModel = Order( + id: 'o1', + user: User(id: 'u1'), + ); + final tOrderResponse = MyOrderResponse(orders: [tOrderModel], metadata: null); + + group('MyOrdersRepoImpl', () { + test( + 'should return SuccessApiResult with data from remote data source when it is successful and not empty', + () async { + // Arrange + when( + mockRemoteDataSource.getAllOrders( + token: anyNamed('token'), + limit: anyNamed('limit'), + page: anyNamed('page'), + ), + ).thenAnswer((_) async => SuccessApiResult(data: tOrderResponse)); + + // Act + final result = await repo.getAllOrders(token: tToken); + + // Assert + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.orders.length, 1); + expect(data.orders[0].id, 'o1'); + verify( + mockRemoteDataSource.getAllOrders(token: tToken, limit: 10, page: 1), + ).called(1); + }, + ); + + test( + 'should return SuccessApiResult with dummy data when remote data source returns empty list', + () async { + // Arrange + final emptyResponse = MyOrderResponse(orders: [], metadata: null); + when( + mockRemoteDataSource.getAllOrders( + token: anyNamed('token'), + limit: anyNamed('limit'), + page: anyNamed('page'), + ), + ).thenAnswer((_) async => SuccessApiResult(data: emptyResponse)); + + // Act + final result = await repo.getAllOrders(token: tToken); + + // Assert + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.orders.isNotEmpty, true); + expect(data.orders[0].id, '123456'); + verify( + mockRemoteDataSource.getAllOrders(token: tToken, limit: 10, page: 1), + ).called(1); + }, + ); + + test( + 'should return ErrorApiResult when remote data source call fails', + () async { + // Arrange + const tError = 'Server error'; + when( + mockRemoteDataSource.getAllOrders( + token: anyNamed('token'), + limit: anyNamed('limit'), + page: anyNamed('page'), + ), + ).thenAnswer((_) async => ErrorApiResult(error: tError)); + + // Act + final result = await repo.getAllOrders(token: tToken); + + // Assert + expect(result, isA>()); + expect((result as ErrorApiResult).error, tError); + }, + ); + }); +} diff --git a/test/features/my_orders/domain/usecase/get_order_use_case_test.dart b/test/features/my_orders/domain/usecase/get_order_use_case_test.dart new file mode 100644 index 0000000..6c0a580 --- /dev/null +++ b/test/features/my_orders/domain/usecase/get_order_use_case_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/my_orders/domain/repo/my_orders_repo.dart'; +import 'package:tracking_app/features/my_orders/domain/usecases/get_order_use_case.dart'; + +import 'get_order_use_case_test.mocks.dart'; + +@GenerateMocks([MyOrdersRepo]) +void main() { + late GetOrderUseCase getOrderUseCase; + late MockMyOrdersRepo mockMyOrdersRepo; + + setUpAll(() { + provideDummy>( + SuccessApiResult(data: MyOrdersResult(orders: [])), + ); + }); + + setUp(() { + mockMyOrdersRepo = MockMyOrdersRepo(); + getOrderUseCase = GetOrderUseCase(mockMyOrdersRepo); + }); + + const tToken = 'token123'; + const tPage = 1; + const tLimit = 10; + final tMyOrdersResult = MyOrdersResult(orders: []); + + group('GetOrderUseCase', () { + test( + 'should return SuccessApiResult when repo call is successful', + () async { + // Arrange + when( + mockMyOrdersRepo.getAllOrders( + token: anyNamed('token'), + page: anyNamed('page'), + limit: anyNamed('limit'), + ), + ).thenAnswer((_) async => SuccessApiResult(data: tMyOrdersResult)); + + // Act + final result = await getOrderUseCase.call( + token: tToken, + page: tPage, + limit: tLimit, + ); + + // Assert + expect(result, isA>()); + expect( + (result as SuccessApiResult).data, + tMyOrdersResult, + ); + verify( + mockMyOrdersRepo.getAllOrders( + token: tToken, + page: tPage, + limit: tLimit, + ), + ).called(1); + verifyNoMoreInteractions(mockMyOrdersRepo); + }, + ); + + test('should return ErrorApiResult when repo call fails', () async { + // Arrange + const tErrorMessage = 'An error occurred'; + when( + mockMyOrdersRepo.getAllOrders( + token: anyNamed('token'), + page: anyNamed('page'), + limit: anyNamed('limit'), + ), + ).thenAnswer((_) async => ErrorApiResult(error: tErrorMessage)); + + // Act + final result = await getOrderUseCase.call( + token: tToken, + page: tPage, + limit: tLimit, + ); + + // Assert + expect(result, isA>()); + expect((result as ErrorApiResult).error, tErrorMessage); + verify( + mockMyOrdersRepo.getAllOrders( + token: tToken, + page: tPage, + limit: tLimit, + ), + ).called(1); + verifyNoMoreInteractions(mockMyOrdersRepo); + }); + }); +} diff --git a/test/features/my_orders/presentation/manager/my_orders_cubit_test.dart b/test/features/my_orders/presentation/manager/my_orders_cubit_test.dart new file mode 100644 index 0000000..36d622b --- /dev/null +++ b/test/features/my_orders/presentation/manager/my_orders_cubit_test.dart @@ -0,0 +1,131 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/my_orders/domain/repo/my_orders_repo.dart'; +import 'package:tracking_app/features/my_orders/domain/usecases/get_order_use_case.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_cubit.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_intent.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_state.dart'; + +import 'my_orders_cubit_test.mocks.dart'; + +@GenerateMocks([GetOrderUseCase, AuthStorage]) +void main() { + late MyOrdersCubit cubit; + late MockGetOrderUseCase mockGetOrderUseCase; + late MockAuthStorage mockAuthStorage; + + setUpAll(() { + provideDummy>( + SuccessApiResult(data: MyOrdersResult(orders: [])), + ); + }); + + setUp(() { + mockGetOrderUseCase = MockGetOrderUseCase(); + mockAuthStorage = MockAuthStorage(); + cubit = MyOrdersCubit(mockGetOrderUseCase, mockAuthStorage); + }); + + tearDown(() { + cubit.close(); + }); + + const tToken = 'token123'; + final tOrdersResult = MyOrdersResult(orders: []); + + group('MyOrdersCubit', () { + test('initial state should be correct', () { + expect(cubit.state.ordersResource.status, Status.initial); + expect(cubit.state.orders, isEmpty); + expect(cubit.state.isLoadingMore, false); + }); + + blocTest( + 'emits [loading, success] when GetMyOrdersIntent is successful', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => tToken); + when( + mockGetOrderUseCase.call( + token: anyNamed('token'), + page: anyNamed('page'), + limit: anyNamed('limit'), + ), + ).thenAnswer((_) async => SuccessApiResult(data: tOrdersResult)); + return cubit; + }, + act: (cubit) => cubit.doIntent(GetMyOrdersIntent(page: 1, limit: 10)), + expect: () => [ + isA().having( + (s) => s.ordersResource.status, + 'status', + Status.loading, + ), + isA().having( + (s) => s.ordersResource.status, + 'status', + Status.success, + ), + ], + verify: (_) { + verify(mockAuthStorage.getToken()).called(1); + verify( + mockGetOrderUseCase.call(token: 'Bearer $tToken', page: 1, limit: 10), + ).called(1); + }, + ); + + blocTest( + 'emits [loading, error] when GetMyOrdersIntent fails', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => tToken); + when( + mockGetOrderUseCase.call( + token: anyNamed('token'), + page: anyNamed('page'), + limit: anyNamed('limit'), + ), + ).thenAnswer((_) async => ErrorApiResult(error: 'Server error')); + return cubit; + }, + act: (cubit) => cubit.doIntent(GetMyOrdersIntent(page: 1, limit: 10)), + expect: () => [ + isA().having( + (s) => s.ordersResource.status, + 'status', + Status.loading, + ), + isA().having( + (s) => s.ordersResource.status, + 'status', + Status.error, + ), + ], + ); + + blocTest( + 'emits [loading, error] when token is missing', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => null); + return cubit; + }, + act: (cubit) => cubit.doIntent(GetMyOrdersIntent(page: 1, limit: 10)), + expect: () => [ + isA().having( + (s) => s.ordersResource.status, + 'status', + Status.loading, + ), + isA().having( + (s) => s.ordersResource.status, + 'status', + Status.error, + ), + ], + ); + }); +} diff --git a/test/features/my_orders/presentation/pages/my_orders_page_test.dart b/test/features/my_orders/presentation/pages/my_orders_page_test.dart new file mode 100644 index 0000000..0ebee80 --- /dev/null +++ b/test/features/my_orders/presentation/pages/my_orders_page_test.dart @@ -0,0 +1,64 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_cubit.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_intent.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_state.dart'; +import 'package:tracking_app/features/my_orders/presentation/pages/my_orders_page.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; + +class MockMyOrdersCubit extends MockCubit + implements MyOrdersCubit {} + +void main() { + late MockMyOrdersCubit mockCubit; + late GetIt getIt; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + registerFallbackValue(GetMyOrdersIntent(page: 1, limit: 10)); + }); + + setUp(() { + getIt = GetIt.instance; + mockCubit = MockMyOrdersCubit(); + + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton(mockCubit); + + when(() => mockCubit.doIntent(any())).thenAnswer((_) async {}); + when(() => mockCubit.state).thenReturn(MyOrdersState()); + }); + + tearDown(() { + getIt.reset(); + }); + + Widget createWidgetUnderTest() { + return EasyLocalization( + supportedLocales: const [Locale('en'), Locale('ar')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: const MaterialApp(home: MyOrdersPage()), + ); + } + + testWidgets('MyOrdersPage renders correctly', (WidgetTester tester) async { + await mockNetworkImagesFor(() async { + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.text("My orders"), findsOneWidget); + expect(find.text("Recent orders"), findsOneWidget); + }); + }); +} diff --git a/test/features/my_orders/presentation/pages/order_details_page_test.dart b/test/features/my_orders/presentation/pages/order_details_page_test.dart new file mode 100644 index 0000000..dbe4e69 --- /dev/null +++ b/test/features/my_orders/presentation/pages/order_details_page_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_item_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/product_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/store_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/user_entity.dart'; +import 'package:tracking_app/features/my_orders/presentation/pages/order_details_page.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/order_item_tile.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/summary_row.dart'; + +void main() { + final tOrder = OrderEntity( + id: "123456", + user: UserEntity( + id: "u1", + firstName: "Noor", + lastName: "mohamed", + phone: "01012345678", + photo: "https://example.com/user.png", + ), + store: StoreEntity( + name: "Flowery store", + image: "https://example.com/store.png", + address: "20th st, Sheikh Zayed, Giza", + phoneNumber: "01012345678", + ), + address: "20th st, Sheikh Zayed, Giza", + items: [ + OrderItemEntity( + product: ProductEntity( + id: "p1", + title: "Red roses", + image: "https://example.com/item.png", + price: 600, + ), + price: 600, + quantity: 1, + ), + ], + totalPrice: 3000, + paymentType: "Cash on delivery", + isPaid: true, + isDelivered: true, + state: "Completed", + createdAt: "2023-01-01", + orderNumber: "123456", + ); + + testWidgets('OrderDetailsPage renders correctly with given order', ( + WidgetTester tester, + ) async { + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + MaterialApp(home: OrderDetailsPage(order: tOrder)), + ); + + expect(find.text("Order details"), findsWidgets); + expect(find.text("Completed"), findsOneWidget); + expect(find.text("# 123456"), findsOneWidget); + + expect(find.text("Pickup address"), findsOneWidget); + expect(find.text("Flowery store"), findsOneWidget); + + expect(find.text("User address"), findsOneWidget); + expect(find.text("Noor mohamed"), findsOneWidget); + + expect(find.byType(OrderItemTile), findsOneWidget); + expect(find.text("Red roses"), findsOneWidget); + + expect(find.byType(SummaryRow), findsNWidgets(2)); + expect(find.text("Egp 3000"), findsOneWidget); + expect(find.text("Cash on delivery"), findsOneWidget); + }); + }); +} diff --git a/test/features/my_orders/presentation/widgets/address_tile_test.dart b/test/features/my_orders/presentation/widgets/address_tile_test.dart new file mode 100644 index 0000000..d6b2994 --- /dev/null +++ b/test/features/my_orders/presentation/widgets/address_tile_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/address_title.dart'; + +void main() { + testWidgets('AddressTile renders correctly with given data', ( + WidgetTester tester, + ) async { + const title = 'Store Name'; + const address = '123 Street, City'; + const imageUrl = 'https://example.com/image.png'; + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AddressTile( + title: title, + address: address, + image: imageUrl, + isStore: true, + ), + ), + ), + ); + + expect(find.text(title), findsOneWidget); + expect(find.text(address), findsOneWidget); + expect( + find.byType(NetworkImage), + findsNothing, + ); // Image is in BoxDecoration, not as a widget + // We can check if the container with decoration exists + expect(find.byType(Container), findsWidgets); + }); + }); +} diff --git a/test/features/my_orders/presentation/widgets/my_orders_page_body_test.dart b/test/features/my_orders/presentation/widgets/my_orders_page_body_test.dart new file mode 100644 index 0000000..cce2f6b --- /dev/null +++ b/test/features/my_orders/presentation/widgets/my_orders_page_body_test.dart @@ -0,0 +1,42 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_cubit.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_state.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/my_orders_page_body.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/orders_filters_row.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/orders_list_view.dart'; + +class MockMyOrdersCubit extends MockCubit + implements MyOrdersCubit {} + +void main() { + late MockMyOrdersCubit mockCubit; + + setUp(() { + mockCubit = MockMyOrdersCubit(); + }); + + testWidgets('MyOrdersPageBody renders components correctly', ( + WidgetTester tester, + ) async { + when(() => mockCubit.state).thenReturn(MyOrdersState()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: mockCubit, + child: const MyOrdersPageBody(), + ), + ), + ), + ); + + expect(find.byType(OrdersFiltersRow), findsOneWidget); + expect(find.text("Recent orders"), findsOneWidget); + expect(find.byType(OrdersListView), findsOneWidget); + }); +} diff --git a/test/features/my_orders/presentation/widgets/order_card_test.dart b/test/features/my_orders/presentation/widgets/order_card_test.dart new file mode 100644 index 0000000..68d18b9 --- /dev/null +++ b/test/features/my_orders/presentation/widgets/order_card_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/store_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/user_entity.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/order_card.dart'; + +void main() { + final tOrder = OrderEntity( + id: 'o1', + user: UserEntity( + id: 'u1', + firstName: 'Noor', + lastName: 'Mohamed', + phone: '010', + photo: 'https://example.com/u1.png', + ), + store: StoreEntity( + name: 'Test Store', + image: 'https://example.com/s1.png', + address: 'Store Address', + phoneNumber: '011', + ), + address: 'User Address', + items: [], + totalPrice: 100, + paymentType: 'Cash', + isPaid: true, + isDelivered: true, + state: 'Delivered', + createdAt: '2023-01-01', + orderNumber: 'ORD123', + ); + + testWidgets('OrderCard renders correctly and handles tap', ( + WidgetTester tester, + ) async { + bool tapped = false; + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OrderCard(order: tOrder, onTap: () => tapped = true), + ), + ), + ); + + expect(find.text('Delivered'), findsOneWidget); + expect(find.text('# ORD123'), findsOneWidget); + expect(find.text('Test Store'), findsOneWidget); + expect(find.text('Store Address'), findsOneWidget); + expect(find.text('Noor Mohamed'), findsOneWidget); + expect(find.text('User Address'), findsOneWidget); + + await tester.tap(find.byType(OrderCard)); + expect(tapped, true); + }); + }); +} diff --git a/test/features/my_orders/presentation/widgets/order_item_tile_test.dart b/test/features/my_orders/presentation/widgets/order_item_tile_test.dart new file mode 100644 index 0000000..b764827 --- /dev/null +++ b/test/features/my_orders/presentation/widgets/order_item_tile_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_item_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/product_entity.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/order_item_tile.dart'; + +void main() { + final tOrderItem = OrderItemEntity( + product: ProductEntity( + id: "p1", + title: "Red roses, 15 Pink Rose Bouquet", + image: "https://example.com/image.png", + price: 600, + ), + price: 600, + quantity: 2, + ); + + testWidgets('OrderItemTile renders correctly with given data', ( + WidgetTester tester, + ) async { + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: OrderItemTile(item: tOrderItem)), + ), + ); + + expect(find.text("Red roses, 15 Pink Rose Bouquet"), findsOneWidget); + expect(find.text("EGP 600"), findsOneWidget); + expect(find.text("X2"), findsOneWidget); + expect(find.byType(Container), findsWidgets); + }); + }); +} diff --git a/test/features/my_orders/presentation/widgets/orders_filters_row_test.dart b/test/features/my_orders/presentation/widgets/orders_filters_row_test.dart new file mode 100644 index 0000000..bd46f06 --- /dev/null +++ b/test/features/my_orders/presentation/widgets/orders_filters_row_test.dart @@ -0,0 +1,96 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tracking_app/features/my_orders/domain/models/meta_data_entity.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_cubit.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_intent.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_state.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/orders_filters_row.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/summary_card.dart'; + +class MockMyOrdersCubit extends MockCubit + implements MyOrdersCubit {} + +void main() { + late MockMyOrdersCubit mockCubit; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + registerFallbackValue(FilterCancelledOrdersIntent()); + registerFallbackValue(FilterCompletedOrdersIntent()); + }); + + setUp(() { + mockCubit = MockMyOrdersCubit(); + when(() => mockCubit.doIntent(any())).thenAnswer((_) async {}); + }); + + Widget createWidgetUnderTest() { + return EasyLocalization( + supportedLocales: const [Locale('en'), Locale('ar')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: mockCubit, + child: const OrdersFiltersRow(), + ), + ), + ), + ); + } + + testWidgets('OrdersFiltersRow renders correct counts from metadata', ( + WidgetTester tester, + ) async { + final state = MyOrdersState( + metadata: const MetadataEntity( + currentPage: 1, + totalPages: 1, + totalItems: 10, + limit: 10, + cancelledCount: 3, + completedCount: 7, + ), + ); + + when(() => mockCubit.state).thenReturn(state); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.text('3'), findsOneWidget); + expect(find.text('7'), findsOneWidget); + expect(find.text('Cancelled'), findsOneWidget); + expect(find.text('Completed'), findsOneWidget); + }); + + testWidgets('OrdersFiltersRow triggers intents on tap', ( + WidgetTester tester, + ) async { + final state = MyOrdersState(); + when(() => mockCubit.state).thenReturn(state); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancelled')); + await tester.pump(); + verify( + () => mockCubit.doIntent(any(that: isA())), + ).called(1); + + await tester.tap(find.text('Completed')); + await tester.pump(); + verify( + () => mockCubit.doIntent(any(that: isA())), + ).called(1); + }); +} diff --git a/test/features/my_orders/presentation/widgets/orders_list_view_test.dart b/test/features/my_orders/presentation/widgets/orders_list_view_test.dart new file mode 100644 index 0000000..d0b47ec --- /dev/null +++ b/test/features/my_orders/presentation/widgets/orders_list_view_test.dart @@ -0,0 +1,107 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/my_orders/domain/models/order_entity.dart'; +import 'package:tracking_app/features/my_orders/domain/models/user_entity.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_cubit.dart'; +import 'package:tracking_app/features/my_orders/presentation/manager/my_orders_state.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/orders_list_view.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/order_card.dart'; + +class MockMyOrdersCubit extends MockCubit + implements MyOrdersCubit {} + +void main() { + late MockMyOrdersCubit mockCubit; + + setUp(() { + mockCubit = MockMyOrdersCubit(); + }); + + testWidgets('OrdersListView shows loading indicator when loading', ( + WidgetTester tester, + ) async { + when( + () => mockCubit.state, + ).thenReturn(MyOrdersState(ordersResource: Resource.loading())); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: mockCubit, + child: const OrdersListView(), + ), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('OrdersListView shows empty message when no orders', ( + WidgetTester tester, + ) async { + when(() => mockCubit.state).thenReturn( + MyOrdersState(ordersResource: Resource.success(null), orders: []), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: mockCubit, + child: const OrdersListView(), + ), + ), + ), + ); + + expect(find.text("No orders found"), findsOneWidget); + }); + + testWidgets('OrdersListView renders list of orders', ( + WidgetTester tester, + ) async { + final tOrder = OrderEntity( + id: 'o1', + user: UserEntity( + id: 'u1', + firstName: 'Noor', + lastName: 'Mohamed', + phone: '01', + photo: 'https://img.com', + ), + items: [], + totalPrice: 100, + paymentType: 'Cash', + isPaid: true, + isDelivered: true, + state: 'Delivered', + createdAt: '2023', + orderNumber: '1', + ); + + when(() => mockCubit.state).thenReturn(MyOrdersState(orders: [tOrder])); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: mockCubit, + child: const OrdersListView(), + ), + ), + ), + ); + + expect(find.byType(OrderCard), findsOneWidget); + expect(find.text('# 1'), findsOneWidget); + }); + }); +} diff --git a/test/features/my_orders/presentation/widgets/section_label_test.dart b/test/features/my_orders/presentation/widgets/section_label_test.dart new file mode 100644 index 0000000..60ff92f --- /dev/null +++ b/test/features/my_orders/presentation/widgets/section_label_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/section_lable.dart'; + +void main() { + testWidgets('SectionLabel renders correctly with given text', ( + WidgetTester tester, + ) async { + const testLabel = 'Test Label'; + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: SectionLabel(label: testLabel)), + ), + ); + + expect(find.text(testLabel), findsOneWidget); + }); +} diff --git a/test/features/my_orders/presentation/widgets/summary_card_test.dart b/test/features/my_orders/presentation/widgets/summary_card_test.dart new file mode 100644 index 0000000..7c3baa8 --- /dev/null +++ b/test/features/my_orders/presentation/widgets/summary_card_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/summary_card.dart'; + +void main() { + testWidgets('SummaryCard renders correctly and handles tap', ( + WidgetTester tester, + ) async { + bool tapped = false; + const title = 'Cancelled'; + const count = '5'; + const icon = Icons.cancel; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SummaryCard( + title: title, + count: count, + icon: icon, + color: Colors.red, + onTap: () => tapped = true, + ), + ), + ), + ); + + expect(find.text(title), findsOneWidget); + expect(find.text(count), findsOneWidget); + expect(find.byIcon(icon), findsOneWidget); + + await tester.tap(find.byType(SummaryCard)); + expect(tapped, true); + }); +} diff --git a/test/features/my_orders/presentation/widgets/summary_row_test.dart b/test/features/my_orders/presentation/widgets/summary_row_test.dart new file mode 100644 index 0000000..41c3d53 --- /dev/null +++ b/test/features/my_orders/presentation/widgets/summary_row_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tracking_app/features/my_orders/presentation/widgets/summary_row.dart'; + +void main() { + testWidgets('SummaryRow renders correctly with given label and value', ( + WidgetTester tester, + ) async { + const label = 'Total'; + const value = 'Egp 3000'; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SummaryRow(label: label, value: value), + ), + ), + ); + + expect(find.text(label), findsOneWidget); + expect(find.text(value), findsOneWidget); + expect(find.byType(Container), findsOneWidget); + }); +} diff --git a/test/features/profile/api/profile_remote_datasource_imp_test.dart b/test/features/profile/api/profile_remote_datasource_imp_test.dart new file mode 100644 index 0000000..aeddc5d --- /dev/null +++ b/test/features/profile/api/profile_remote_datasource_imp_test.dart @@ -0,0 +1,152 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:retrofit/retrofit.dart'; +import 'package:tracking_app/app/core/api_manger/api_client.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/profile/api/profile_remote_datasource_imp.dart'; +import 'package:tracking_app/features/profile/data/models/requests/edit_profile_request.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; + +import 'profile_remote_datasource_imp_test.mocks.dart'; + +@GenerateMocks([ApiClient]) +void main() { + late MockApiClient mockApiClient; + late ProfileRemoteDatasourceImp dataSource; + + setUp(() { + mockApiClient = MockApiClient(); + dataSource = ProfileRemoteDatasourceImp(mockApiClient); + }); + + group('ProfileRemoteDatasourceImp.editProfile()', () { + final token = "test_token"; + final request = EditProfileRequest(firstName: "Test"); + + test( + 'returns SuccessApiResult when apiClient returns valid response', + () async { + // ARRANGE + final fakeResponse = EditProfileResponse(message: "Success"); + final dioResponse = Response( + requestOptions: RequestOptions(path: '/edit-profile'), + data: fakeResponse, + statusCode: 200, + ); + final httpResponse = HttpResponse( + fakeResponse, + dioResponse, + ); + + when( + mockApiClient.editProfile( + token: anyNamed('token'), + request: anyNamed('request'), + ), + ).thenAnswer((_) async => httpResponse); + + // ACT + final result = await dataSource.editProfile( + token: token, + request: request, + ); + + // ASSERT + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.message, "Success"); + verify( + mockApiClient.editProfile(token: token, request: request), + ).called(1); + }, + ); + + test('returns ErrorApiResult when apiClient throws Exception', () async { + // ARRANGE + when( + mockApiClient.editProfile( + token: anyNamed('token'), + request: anyNamed('request'), + ), + ).thenThrow(Exception("network error")); + + // ACT + final result = await dataSource.editProfile( + token: token, + request: request, + ); + + // ASSERT + expect(result, isA>()); + expect( + (result as ErrorApiResult).error.toString(), + contains("network error"), + ); + verify( + mockApiClient.editProfile(token: token, request: request), + ).called(1); + }); + }); + + group('ProfileRemoteDatasourceImp.uploadPhoto()', () { + final token = "test_token"; + final file = File('test_path'); + + test( + 'returns SuccessApiResult when apiClient returns valid response', + () async { + // ARRANGE + final fakeResponse = EditProfileResponse(message: "Photo Uploaded"); + final dioResponse = Response( + requestOptions: RequestOptions(path: '/upload-photo'), + data: fakeResponse, + statusCode: 200, + ); + final httpResponse = HttpResponse( + fakeResponse, + dioResponse, + ); + + when( + mockApiClient.uploadPhoto( + token: anyNamed('token'), + photo: anyNamed('photo'), + ), + ).thenAnswer((_) async => httpResponse); + + // ACT + final result = await dataSource.uploadPhoto(token: token, photo: file); + + // ASSERT + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.message, "Photo Uploaded"); + verify(mockApiClient.uploadPhoto(token: token, photo: file)).called(1); + }, + ); + + test('returns ErrorApiResult when apiClient throws Exception', () async { + // ARRANGE + when( + mockApiClient.uploadPhoto( + token: anyNamed('token'), + photo: anyNamed('photo'), + ), + ).thenThrow(Exception("network error")); + + // ACT + final result = await dataSource.uploadPhoto(token: token, photo: file); + + // ASSERT + expect(result, isA>()); + expect( + (result as ErrorApiResult).error.toString(), + contains("network error"), + ); + verify(mockApiClient.uploadPhoto(token: token, photo: file)).called(1); + }); + }); +} diff --git a/test/features/profile/data/repo/profile_repo_imp_test.dart b/test/features/profile/data/repo/profile_repo_imp_test.dart new file mode 100644 index 0000000..e50f219 --- /dev/null +++ b/test/features/profile/data/repo/profile_repo_imp_test.dart @@ -0,0 +1,141 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/profile/data/datasorce/profile_lacal_datasource.dart'; +import 'package:tracking_app/features/profile/data/datasorce/profile_remote_datasource.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import 'package:tracking_app/features/profile/data/repo/profile_repo_imp.dart'; + +import 'profile_repo_imp_test.mocks.dart'; + +@GenerateMocks([ProfileRemoteDatasource, ProfileLocalDataSource]) +void main() { + provideDummy>( + SuccessApiResult(data: EditProfileResponse()), + ); + provideDummy>( + ErrorApiResult(error: 'dummy error'), + ); + provideDummy>( + SuccessApiResult(data: EditProfileResponse()), + ); + provideDummy(File('dummy_path')); + + late MockProfileRemoteDatasource mockRemote; + late MockProfileLocalDataSource mockLocal; + late ProfileRepoImpl repo; + + setUp(() { + mockRemote = MockProfileRemoteDatasource(); + mockLocal = MockProfileLocalDataSource(); + repo = ProfileRepoImpl(mockRemote, mockLocal); + }); + + group('ProfileRepoImpl.editProfile()', () { + final token = "test_token"; + final firstName = "Test"; + final lastName = "User"; + + test( + 'returns SuccessApiResult when datasource returns SuccessApiResult', + () async { + final fakeResponse = EditProfileResponse(message: "Success"); + when( + mockRemote.editProfile( + token: anyNamed('token'), + request: anyNamed('request'), + ), + ).thenAnswer((_) async => SuccessApiResult(data: fakeResponse)); + + final result = await repo.editProfile( + token: token, + firstName: firstName, + lastName: lastName, + ); + + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.message, "Success"); + + verify( + mockRemote.editProfile(token: token, request: anyNamed('request')), + ).called(1); + verify(mockLocal.saveUser(any)).called(1); + }, + ); + + test( + 'returns ErrorApiResult when datasource returns ErrorApiResult', + () async { + when( + mockRemote.editProfile( + token: anyNamed('token'), + request: anyNamed('request'), + ), + ).thenAnswer((_) async => ErrorApiResult(error: "Network Error")); + + final result = await repo.editProfile( + token: token, + firstName: firstName, + lastName: lastName, + ); + + expect(result, isA>()); + expect((result as ErrorApiResult).error, "Network Error"); + + verify( + mockRemote.editProfile(token: token, request: anyNamed('request')), + ).called(1); + }, + ); + }); + + group('ProfileRepoImpl.uploadPhoto()', () { + final token = "test_token"; + final file = File('test_path'); + + test( + 'returns SuccessApiResult when datasource returns SuccessApiResult', + () async { + final fakeResponse = EditProfileResponse(message: "Photo Uploaded"); + when( + mockRemote.uploadPhoto( + token: anyNamed('token'), + photo: anyNamed('photo'), + ), + ).thenAnswer((_) async => SuccessApiResult(data: fakeResponse)); + + final result = await repo.uploadPhoto(token: token, photo: file); + + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.message, "Photo Uploaded"); + + verify(mockRemote.uploadPhoto(token: token, photo: file)).called(1); + verify(mockLocal.saveUser(any)).called(1); + }, + ); + + test( + 'returns ErrorApiResult when datasource returns ErrorApiResult', + () async { + when( + mockRemote.uploadPhoto( + token: anyNamed('token'), + photo: anyNamed('photo'), + ), + ).thenAnswer((_) async => ErrorApiResult(error: "Upload Failed")); + + final result = await repo.uploadPhoto(token: token, photo: file); + + expect(result, isA>()); + expect((result as ErrorApiResult).error, "Upload Failed"); + + verify(mockRemote.uploadPhoto(token: token, photo: file)).called(1); + }, + ); + }); +} diff --git a/test/features/profile/domain/usecases/edit_profile_usecase_test.dart b/test/features/profile/domain/usecases/edit_profile_usecase_test.dart new file mode 100644 index 0000000..8cc60b5 --- /dev/null +++ b/test/features/profile/domain/usecases/edit_profile_usecase_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import 'package:tracking_app/features/profile/domain/repo/profile_repo.dart'; +import 'package:tracking_app/features/profile/domain/usecases/edit_profile_usecase.dart'; + +import 'edit_profile_usecase_test.mocks.dart'; + +@GenerateMocks([ProfileRepo]) +void main() { + late MockProfileRepo mockRepo; + late EditProfileUseCase useCase; + + setUp(() { + mockRepo = MockProfileRepo(); + useCase = EditProfileUseCase(mockRepo); + provideDummy>( + SuccessApiResult(data: EditProfileResponse()), + ); + }); + + group("EditProfileUseCase", () { + final fakeResponse = EditProfileResponse( + message: 'Success', + driver: DriverModel( + firstName: 'test', + lastName: 'test', + email: 'test@test.com', + ), + ); + + test("returns SuccessApiResult when repo returns success", () async { + when( + mockRepo.editProfile( + token: anyNamed('token'), + firstName: anyNamed('firstName'), + lastName: anyNamed('lastName'), + email: anyNamed('email'), + phone: anyNamed('phone'), + vehicleType: anyNamed('vehicleType'), + vehicleNumber: anyNamed('vehicleNumber'), + vehicleLicense: anyNamed('vehicleLicense'), + ), + ).thenAnswer( + (_) async => SuccessApiResult(data: fakeResponse), + ); + + final result = + await useCase.call( + token: 'fake_token', + firstName: 'test', + lastName: 'test', + email: 'test@test.com', + ) + as SuccessApiResult; + + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.message, fakeResponse.message); + expect(data.driver?.email, fakeResponse.driver?.email); + verify( + mockRepo.editProfile( + token: 'fake_token', + firstName: 'test', + lastName: 'test', + email: 'test@test.com', + ), + ).called(1); + }); + + test("returns ErrorApiResult when repo returns error", () async { + when( + mockRepo.editProfile( + token: anyNamed('token'), + firstName: anyNamed('firstName'), + lastName: anyNamed('lastName'), + email: anyNamed('email'), + phone: anyNamed('phone'), + vehicleType: anyNamed('vehicleType'), + vehicleNumber: anyNamed('vehicleNumber'), + vehicleLicense: anyNamed('vehicleLicense'), + ), + ).thenAnswer( + (_) async => + ErrorApiResult(error: 'Update failed'), + ); + + final result = + await useCase.call( + token: 'fake_token', + firstName: 'test', + lastName: 'test', + email: 'test@test.com', + ) + as ErrorApiResult; + + expect(result, isA>()); + final error = (result as ErrorApiResult).error; + expect(error, 'Update failed'); + verify( + mockRepo.editProfile( + token: 'fake_token', + firstName: 'test', + lastName: 'test', + email: 'test@test.com', + ), + ).called(1); + }); + }); +} diff --git a/test/features/profile/domain/usecases/upload_profile_photo_usecase_test.dart b/test/features/profile/domain/usecases/upload_profile_photo_usecase_test.dart new file mode 100644 index 0000000..a91a4ef --- /dev/null +++ b/test/features/profile/domain/usecases/upload_profile_photo_usecase_test.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import 'package:tracking_app/features/profile/domain/repo/profile_repo.dart'; +import 'package:tracking_app/features/profile/domain/usecases/upload_profile_photo_usecase.dart'; + +import 'upload_profile_photo_usecase_test.mocks.dart'; + +@GenerateMocks([ProfileRepo]) +void main() { + late MockProfileRepo mockRepo; + late UploadProfilePhotoUseCase useCase; + + setUp(() { + mockRepo = MockProfileRepo(); + useCase = UploadProfilePhotoUseCase(mockRepo); + provideDummy>( + SuccessApiResult(data: EditProfileResponse()), + ); + }); + + group("UploadProfilePhotoUseCase", () { + final token = "test_token"; + final file = File('test_path'); + final fakeResponse = EditProfileResponse( + message: 'Photo Uploaded', + driver: DriverModel( + firstName: 'test', + lastName: 'test', + email: 'test@test.com', + photo: 'uploaded_photo.jpg', + ), + ); + + test("returns SuccessApiResult when repo returns success", () async { + when( + mockRepo.uploadPhoto( + token: anyNamed('token'), + photo: anyNamed('photo'), + ), + ).thenAnswer( + (_) async => SuccessApiResult(data: fakeResponse), + ); + + final result = await useCase.call(token: token, photo: file); + + expect(result, isA>()); + final data = (result as SuccessApiResult).data; + expect(data.message, fakeResponse.message); + expect(data.driver?.photo, fakeResponse.driver?.photo); + verify(mockRepo.uploadPhoto(token: token, photo: file)).called(1); + }); + + test("returns ErrorApiResult when repo returns error", () async { + when( + mockRepo.uploadPhoto( + token: anyNamed('token'), + photo: anyNamed('photo'), + ), + ).thenAnswer( + (_) async => + ErrorApiResult(error: 'Upload failed'), + ); + + final result = await useCase.call(token: token, photo: file); + + expect(result, isA>()); + final error = (result as ErrorApiResult).error; + expect(error, 'Upload failed'); + verify(mockRepo.uploadPhoto(token: token, photo: file)).called(1); + }); + }); +} diff --git a/test/features/profile/presentation/managers/profile_cubit_test.dart b/test/features/profile/presentation/managers/profile_cubit_test.dart new file mode 100644 index 0000000..fb2d7f5 --- /dev/null +++ b/test/features/profile/presentation/managers/profile_cubit_test.dart @@ -0,0 +1,289 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/core/network/api_result.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/data/models/responses/edit_profile_response.dart'; +import 'package:tracking_app/features/profile/domain/usecases/edit_profile_usecase.dart'; +import 'package:tracking_app/features/profile/domain/usecases/upload_profile_photo_usecase.dart'; +import 'package:tracking_app/features/profile/domain/usecases/get_profile_usecase.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_intent.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_state.dart'; + +import 'profile_cubit_test.mocks.dart'; + +@GenerateMocks([ + EditProfileUseCase, + UploadProfilePhotoUseCase, + GetProfileUsecase, + AuthStorage, +]) +void main() { + provideDummy>( + SuccessApiResult(data: EditProfileResponse()), + ); + + provideDummy>( + ErrorApiResult(error: 'dummy error'), + ); + + provideDummy>( + SuccessApiResult(data: EditProfileResponse()), + ); + + late MockEditProfileUseCase mockEditProfileUseCase; + late MockUploadProfilePhotoUseCase mockUploadPhotoUseCase; + late MockGetProfileUsecase mockGetProfileUsecase; + late MockAuthStorage mockAuthStorage; + late ProfileCubit cubit; + + setUp(() { + mockEditProfileUseCase = MockEditProfileUseCase(); + mockUploadPhotoUseCase = MockUploadProfilePhotoUseCase(); + mockGetProfileUsecase = MockGetProfileUsecase(); + mockAuthStorage = MockAuthStorage(); + when(mockAuthStorage.getUserJson()).thenAnswer((_) async => null); + when(mockAuthStorage.getToken()).thenAnswer((_) async => 'test_token'); + when( + mockGetProfileUsecase.call(token: anyNamed('token')), + ).thenAnswer((_) async => SuccessApiResult(data: EditProfileResponse())); + + cubit = ProfileCubit( + mockEditProfileUseCase, + mockUploadPhotoUseCase, + mockGetProfileUsecase, + mockAuthStorage, + ); + }); + + tearDown(() { + cubit.close(); + }); + + group('GetProfileIntent', () { + final token = 'test_token'; + final response = EditProfileResponse( + message: 'Success', + driver: DriverModel(firstName: 'Ali', lastName: 'Besar'), + ); + + blocTest( + 'emits loading then success when usecase returns SuccessApiResult', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => token); + when( + mockGetProfileUsecase.call(token: 'Bearer $token'), + ).thenAnswer((_) async => SuccessApiResult(data: response)); + when(mockAuthStorage.saveUserJson(any)).thenAnswer((_) async => {}); + return cubit; + }, + act: (cubit) => cubit.doIntent(GetProfileIntent()), + expect: () => [ + isA().having( + (s) => s.getProfileResource.status, + 'status', + Status.loading, + ), + isA() + .having( + (s) => s.getProfileResource.status, + 'status', + Status.success, + ) + .having((s) => s.driver?.firstName, 'firstName', 'Ali'), + ], + ); + + blocTest( + 'emits error when token is missing', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => null); + return cubit; + }, + act: (cubit) => cubit.doIntent(GetProfileIntent()), + expect: () => [ + isA().having( + (s) => s.getProfileResource.status, + 'status', + Status.loading, + ), + isA().having( + (s) => s.getProfileResource.error, + 'error', + 'Token not found', + ), + ], + ); + }); + + group('PerformEditProfile Intent', () { + final intent = PerformEditProfile( + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + ); + final token = 'test_token'; + final response = EditProfileResponse( + message: 'Success', + driver: DriverModel(firstName: 'Test', lastName: 'User'), + ); + + blocTest( + 'emits loading then success when usecase returns SuccessApiResult', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => token); + when( + mockEditProfileUseCase.call( + token: 'Bearer $token', + firstName: intent.firstName, + lastName: intent.lastName, + email: intent.email, + phone: intent.phone, + vehicleType: intent.vehicleType, + vehicleNumber: intent.vehicleNumber, + vehicleLicense: intent.vehicleLicense, + ), + ).thenAnswer((_) async => SuccessApiResult(data: response)); + when(mockAuthStorage.saveUserJson(any)).thenAnswer((_) async => {}); + return cubit; + }, + act: (cubit) => cubit.doIntent(intent), + expect: () => [ + isA().having( + (s) => s.editProfileResource.status, + 'status', + Status.loading, + ), + isA() + .having( + (s) => s.editProfileResource.status, + 'status', + Status.success, + ) + .having((s) => s.editProfileResource.data, 'data', response), + ], + verify: (_) { + verify(mockAuthStorage.getToken()).called(2); + verify( + mockEditProfileUseCase.call( + token: 'Bearer $token', + firstName: intent.firstName, + lastName: intent.lastName, + email: intent.email, + phone: intent.phone, + vehicleType: intent.vehicleType, + vehicleNumber: intent.vehicleNumber, + vehicleLicense: intent.vehicleLicense, + ), + ).called(1); + verify(mockAuthStorage.saveUserJson(any)).called(1); + }, + ); + + blocTest( + 'emits loading then error when usecase returns ErrorApiResult', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => token); + when( + mockEditProfileUseCase.call( + token: 'Bearer $token', + firstName: intent.firstName, + lastName: intent.lastName, + email: intent.email, + phone: intent.phone, + vehicleType: intent.vehicleType, + vehicleNumber: intent.vehicleNumber, + vehicleLicense: intent.vehicleLicense, + ), + ).thenAnswer((_) async => ErrorApiResult(error: 'Update failed')); + return cubit; + }, + act: (cubit) => cubit.doIntent(intent), + expect: () => [ + isA().having( + (s) => s.editProfileResource.status, + 'status', + Status.loading, + ), + isA() + .having((s) => s.editProfileResource.status, 'status', Status.error) + .having( + (s) => s.editProfileResource.error, + 'error', + 'Update failed', + ), + ], + ); + + blocTest( + 'uploads photo then edits profile when photo is present', + build: () { + when(mockAuthStorage.getToken()).thenAnswer((_) async => token); + when( + mockUploadPhotoUseCase.call( + token: 'Bearer $token', + photo: anyNamed('photo'), + ), + ).thenAnswer((_) async => SuccessApiResult(data: response)); + + when( + mockEditProfileUseCase.call( + token: 'Bearer $token', + firstName: 'Test', + lastName: null, + email: null, + phone: null, + vehicleType: null, + vehicleNumber: null, + vehicleLicense: null, + ), + ).thenAnswer((_) async => SuccessApiResult(data: response)); + when(mockAuthStorage.saveUserJson(any)).thenAnswer((_) async => {}); + return cubit; + }, + act: (cubit) => cubit.doIntent( + PerformEditProfile(firstName: 'Test', photo: File('test_photo')), + ), + expect: () => [ + isA().having( + (s) => s.editProfileResource.status, + 'status', + Status.loading, + ), + isA() + .having( + (s) => s.editProfileResource.status, + 'status', + Status.success, + ) + .having((s) => s.selectedPhoto, 'selectedPhoto', isNull), + ], + verify: (_) { + verify( + mockUploadPhotoUseCase.call( + token: 'Bearer $token', + photo: anyNamed('photo'), + ), + ).called(1); + verify( + mockEditProfileUseCase.call( + token: 'Bearer $token', + firstName: 'Test', + lastName: null, + email: null, + phone: null, + vehicleType: null, + vehicleNumber: null, + vehicleLicense: null, + ), + ).called(1); + }, + ); + }); +} diff --git a/test/features/profile/presentation/widgets/edit_driver_profile_page_body_test.dart b/test/features/profile/presentation/widgets/edit_driver_profile_page_body_test.dart new file mode 100644 index 0000000..ffa92b1 --- /dev/null +++ b/test/features/profile/presentation/widgets/edit_driver_profile_page_body_test.dart @@ -0,0 +1,124 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/config/auth_storage/auth_storage.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/app/config/di/di.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_intent.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_state.dart'; +import 'package:tracking_app/features/profile/presentation/widgets/edit_driver_profile_page_body.dart'; + +@GenerateMocks([ProfileCubit, AuthStorage]) +import 'edit_driver_profile_page_body_test.mocks.dart'; + +void main() { + group('EditDriverProfilePageBody Tests', () { + late MockProfileCubit mockCubit; + late MockAuthStorage mockAuthStorage; + + final fakeUser = DriverModel( + firstName: 'Ali', + lastName: 'Besar', + email: 'ali@example.com', + phone: '0123456789', + ); + + setUp(() { + mockCubit = MockProfileCubit(); + mockAuthStorage = MockAuthStorage(); + + if (!getIt.isRegistered()) { + getIt.registerSingleton(mockAuthStorage); + } + }); + + tearDown(() { + if (getIt.isRegistered()) { + getIt.unregister(); + } + }); + + Widget createWidgetUnderTest() { + return MaterialApp( + home: BlocProvider.value( + value: mockCubit, + child: Scaffold(body: EditDriverProfilePageBody(user: fakeUser)), + ), + ); + } + + testWidgets('initializes form fields with user data', (tester) async { + when(mockCubit.state).thenReturn(ProfileState()); + when(mockCubit.stream).thenAnswer((_) => const Stream.empty()); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.text('Ali'), findsOneWidget); + expect(find.text('Besar'), findsOneWidget); + expect(find.text('ali@example.com'), findsOneWidget); + expect(find.text('0123456789'), findsOneWidget); + }); + + testWidgets( + 'shows loading indicator on update button when state is loading', + (tester) async { + when( + mockCubit.state, + ).thenReturn(ProfileState(editProfileResource: Resource.loading())); + when(mockCubit.stream).thenAnswer((_) => const Stream.empty()); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.text('loading'), findsOneWidget); + }, + ); + + testWidgets('shows success snackbar when profile update is successful', ( + tester, + ) async { + final state1 = ProfileState(); + final state2 = ProfileState(editProfileResource: Resource.success(null)); + + when(mockCubit.state).thenReturn(state1); + when(mockCubit.stream).thenAnswer((_) => Stream.fromIterable([state2])); + + await tester.pumpWidget(createWidgetUnderTest()); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Profile updated successfully'), findsOneWidget); + }); + + testWidgets( + 'calls PerformEditProfile intent when update button is pressed', + (tester) async { + when(mockCubit.state).thenReturn(ProfileState()); + when(mockCubit.stream).thenAnswer((_) => const Stream.empty()); + when(mockAuthStorage.getToken()).thenAnswer((_) async => 'test_token'); + + await tester.pumpWidget(createWidgetUnderTest()); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + verify( + mockCubit.doIntent( + argThat( + isA() + .having((i) => i.firstName, 'firstName', 'Ali') + .having((i) => i.lastName, 'lastName', 'Besar') + .having((i) => i.email, 'email', 'ali@example.com') + .having((i) => i.phone, 'phone', '0123456789'), + ), + ), + ).called(1); + }, + ); + }); +} diff --git a/test/features/profile/presentation/widgets/edit_vehicle_page_body_test.dart b/test/features/profile/presentation/widgets/edit_vehicle_page_body_test.dart new file mode 100644 index 0000000..e8fe317 --- /dev/null +++ b/test/features/profile/presentation/widgets/edit_vehicle_page_body_test.dart @@ -0,0 +1,113 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tracking_app/app/config/base_state/base_state.dart'; +import 'package:tracking_app/features/profile/data/models/driver_model.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_cubit.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_intent.dart'; +import 'package:tracking_app/features/profile/presentation/managers/profile_state.dart'; +import 'package:tracking_app/features/profile/presentation/widgets/edit_vehicle_page_body.dart'; +import 'package:tracking_app/features/profile/presentation/widgets/edit_vehicle_form.dart'; + +@GenerateMocks([ProfileCubit]) +import 'edit_vehicle_page_body_test.mocks.dart'; + +void main() { + group('EditVehiclePageBody Tests', () { + late MockProfileCubit mockCubit; + + final fakeDriver = DriverModel( + vehicleType: 'Car', + vehicleNumber: '123456', + vehicleLicense: 'some_license.png', + ); + + setUp(() { + mockCubit = MockProfileCubit(); + }); + + Widget createWidgetUnderTest() { + return MaterialApp( + home: BlocProvider.value( + value: mockCubit, + child: Scaffold(body: EditVehiclePageBody(driver: fakeDriver)), + ), + ); + } + + testWidgets('initializes form fields with driver data', (tester) async { + when(mockCubit.state).thenReturn(ProfileState()); + when(mockCubit.stream).thenAnswer((_) => const Stream.empty()); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.text('Car'), findsOneWidget); + expect(find.text('123456'), findsOneWidget); + expect(find.text('some_license.png'), findsOneWidget); + }); + + testWidgets( + 'shows loading indicator on update button when state is loading', + (tester) async { + when( + mockCubit.state, + ).thenReturn(ProfileState(editProfileResource: Resource.loading())); + when(mockCubit.stream).thenAnswer((_) => const Stream.empty()); + + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.text('loading'), findsOneWidget); + }, + ); + + testWidgets('shows success snackbar when update is successful', ( + tester, + ) async { + final state1 = ProfileState(); + final state2 = ProfileState(editProfileResource: Resource.success(null)); + + when(mockCubit.state).thenReturn(state1); + when(mockCubit.stream).thenAnswer((_) => Stream.fromIterable([state2])); + + await tester.pumpWidget(createWidgetUnderTest()); + + mockCubit.emit(state2); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Vehicle updated successfully'), findsOneWidget); + }); + + testWidgets( + 'calls PerformEditProfile intent when update button is pressed', + (tester) async { + when(mockCubit.state).thenReturn(ProfileState()); + when(mockCubit.stream).thenAnswer((_) => const Stream.empty()); + + await tester.pumpWidget(createWidgetUnderTest()); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + verify( + mockCubit.doIntent( + argThat( + isA() + .having((i) => i.vehicleType, 'vehicleType', 'Car') + .having((i) => i.vehicleNumber, 'vehicleNumber', '123456') + .having( + (i) => i.vehicleLicense, + 'vehicleLicense', + 'some_license.png', + ), + ), + ), + ).called(1); + }, + ); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 28ddb5c..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// 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:tracking_app/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // 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/web/firebase-messaging-sw.js b/web/firebase-messaging-sw.js new file mode 100644 index 0000000..32d89e8 --- /dev/null +++ b/web/firebase-messaging-sw.js @@ -0,0 +1,25 @@ +importScripts("https://www.gstatic.com/firebasejs/8.10.0/firebase-app.js"); +importScripts("https://www.gstatic.com/firebasejs/8.10.0/firebase-messaging.js"); + +firebase.initializeApp({ + apiKey: "AIzaSyDKWdkFjeKkEAfKFrMO2svs48t2d9OqRGw", + appId: "1:725835190067:web:86225b1572d53a90e53846", + messagingSenderId: "725835190067", + projectId: "elevate-flower-app", + authDomain: "elevate-flower-app.firebaseapp.com", + storageBucket: "elevate-flower-app.firebasestorage.app" +}); + +const messaging = firebase.messaging(); + +messaging.onBackgroundMessage(function(payload) { + console.log('[firebase-messaging-sw.js] Received background message ', payload); + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + icon: '/icons/Icon-192.png' + }; + + self.registration.showNotification(notificationTitle, + notificationOptions); +}); diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..8e904a1 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,21 @@ #include "generated_plugin_registrant.h" +#include +#include +#include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + CloudFirestorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("CloudFirestorePluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..8d3f745 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,15 @@ # list(APPEND FLUTTER_PLUGIN_LIST + cloud_firestore + file_selector_windows + firebase_core + geolocator_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES)