From 4ee6b709d2f4af2f8f9b6e4112ab9940070386f5 Mon Sep 17 00:00:00 2001 From: Kyaw Khant Win Date: Thu, 2 Apr 2026 00:28:59 +0630 Subject: [PATCH] done --- .gitignore | 90 +- .metadata | 66 +- README.md | 32 +- analysis_options.yaml | 56 +- android/.gitignore | 28 +- android/app/build.gradle.kts | 98 +- android/app/src/debug/AndroidManifest.xml | 14 +- android/app/src/main/AndroidManifest.xml | 93 +- .../example/e_receipt_mobile/MainActivity.kt | 10 +- .../res/drawable-v21/launch_background.xml | 24 +- .../main/res/drawable/launch_background.xml | 24 +- .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 544 -> 7196 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 442 -> 3839 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 721 -> 11918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 1031 -> 28727 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 1443 -> 51079 bytes .../app/src/main/res/values-night/styles.xml | 36 +- android/app/src/main/res/values/styles.xml | 36 +- android/app/src/profile/AndroidManifest.xml | 14 +- android/build.gradle.kts | 48 +- android/gradle.properties | 4 +- .../gradle/wrapper/gradle-wrapper.properties | 10 +- android/settings.gradle.kts | 52 +- ios/.gitignore | 68 +- ios/Flutter/AppFrameworkInfo.plist | 52 +- ios/Flutter/Debug.xcconfig | 3 +- ios/Flutter/Release.xcconfig | 3 +- ios/Podfile | 43 + ios/Runner.xcodeproj/project.pbxproj | 1232 +++++++------- .../contents.xcworkspacedata | 14 +- .../xcshareddata/IDEWorkspaceChecks.plist | 16 +- .../xcshareddata/WorkspaceSettings.xcsettings | 16 +- .../xcshareddata/xcschemes/Runner.xcscheme | 202 +-- .../contents.xcworkspacedata | 14 +- .../xcshareddata/IDEWorkspaceChecks.plist | 16 +- .../xcshareddata/WorkspaceSettings.xcsettings | 16 +- ios/Runner/AppDelegate.swift | 26 +- .../AppIcon.appiconset/Contents.json | 244 +-- .../LaunchImage.imageset/Contents.json | 46 +- .../LaunchImage.imageset/README.md | 8 +- ios/Runner/Base.lproj/LaunchScreen.storyboard | 74 +- ios/Runner/Base.lproj/Main.storyboard | 52 +- ios/Runner/Info.plist | 98 +- ios/Runner/Runner-Bridging-Header.h | 2 +- ios/RunnerTests/RunnerTests.swift | 24 +- lib/core/config/app_config.dart | 14 +- lib/core/network/api_key_http_client.dart | 48 +- lib/core/network/auth_http_client.dart | 186 +- lib/core/network/logging_http_client.dart | 236 +-- lib/core/theme/app_colors.dart | 14 +- .../repositories/api_auth_repository.dart | 484 +++--- .../repositories/api_merchant_repository.dart | 540 +++--- .../repositories/api_receipt_repository.dart | 278 +-- .../api_transaction_repository.dart | 296 ++-- .../repositories/mock_auth_repository.dart | 140 +- lib/domain/entities/login_user.dart | 34 +- lib/domain/entities/merchant.dart | 62 +- lib/domain/entities/receipt_content.dart | 36 +- lib/domain/entities/terminal.dart | 70 +- lib/domain/entities/transaction_record.dart | 38 +- lib/domain/exceptions/auth_exceptions.dart | 26 +- lib/domain/repositories/auth_repository.dart | 30 +- .../repositories/merchant_repository.dart | 32 +- .../repositories/receipt_repository.dart | 34 +- .../repositories/transaction_repository.dart | 26 +- lib/main.dart | 192 +-- lib/presentation/auth/logout_state.dart | 48 +- lib/presentation/auth/logout_view_model.dart | 108 +- lib/presentation/auth/session_controller.dart | 38 +- .../components/rounded_input.dart | 160 +- .../home/home_pagination_providers.dart | 14 +- lib/presentation/home/home_screen.dart | 478 +++--- lib/presentation/home/home_view_model.dart | 64 +- .../home/merchant_paging_state.dart | 98 +- .../home/merchant_paging_view_model.dart | 260 +-- .../home/widgets/home_drawer.dart | 280 ++- .../home/widgets/merchant_header.dart | 164 +- .../home/widgets/merchant_list_view.dart | 466 ++--- .../login/login_form_providers.dart | 80 +- lib/presentation/login/login_page.dart | 658 +++---- lib/presentation/login/login_state.dart | 104 +- lib/presentation/login/login_view_model.dart | 284 ++-- .../login/widgets/login_background.dart | 134 +- .../login/widgets/login_error_banner.dart | 88 +- .../reset_password_form_providers.dart | 80 +- .../reset_password/reset_password_page.dart | 668 ++++---- .../reset_password/reset_password_state.dart | 56 +- .../reset_password_view_model.dart | 108 +- .../settings/settings_screen.dart | 130 +- .../settings/theme_mode_provider.dart | 12 +- .../terminal/receipt_view_model.dart | 156 +- .../terminal/terminal_next_screen.dart | 1512 ++++++++--------- .../terminal/terminal_selection_screen.dart | 236 +-- .../transaction_pagination_providers.dart | 8 +- .../terminal/transaction_paging_state.dart | 90 +- .../transaction_paging_view_model.dart | 302 ++-- .../terminal/transaction_receipt_screen.dart | 806 ++++----- .../terminal/transaction_view_model.dart | 102 +- .../terminal/widgets/terminal_header.dart | 116 +- .../terminal/widgets/terminal_list_view.dart | 416 ++--- pubspec.lock | 1140 ++++++------- pubspec.yaml | 190 +-- test/widget_test.dart | 28 +- 103 files changed, 7738 insertions(+), 7764 deletions(-) create mode 100644 ios/Podfile diff --git a/.gitignore b/.gitignore index 3820a95..6f0d006 100644 --- a/.gitignore +++ b/.gitignore @@ -1,45 +1,45 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata index e8cf1e4..47ab4a0 100644 --- a/.metadata +++ b/.metadata @@ -1,33 +1,33 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - platform: android - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - platform: ios - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: android + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: ios + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md index 47bc83b..00846e6 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# e_receipt_mobile - -E-Receipt portal moble version - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +# e_receipt_mobile + +E-Receipt portal moble version + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..d4e0f0c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,28 +1,28 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore index be3943c..c908258 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,14 +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 +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/app/build.gradle.kts b/android/app/build.gradle.kts index b4cffbf..4c23f4b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,44 +1,54 @@ -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.example.e_receipt_mobile" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.e_receipt_mobile" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -flutter { - source = "../.." -} +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + + +android { + namespace = "com.example.e_receipt_mobile" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.e_receipt_mobile" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..8ffe024 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,7 @@ - - - - + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bbcdf52..113b9b3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,45 +1,48 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/e_receipt_mobile/MainActivity.kt b/android/app/src/main/kotlin/com/example/e_receipt_mobile/MainActivity.kt index 62b3f5d..3e90457 100644 --- a/android/app/src/main/kotlin/com/example/e_receipt_mobile/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/e_receipt_mobile/MainActivity.kt @@ -1,5 +1,5 @@ -package com.example.e_receipt_mobile - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() +package com.example.e_receipt_mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f..1cb7aa2 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,12 +1,12 @@ - - - - - - - - + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..8403758 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,12 @@ - - - - - - - - + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4b7b0906d62b1847e87f15cdcacf6a4f29..f0760175fddb835cd716f9eab87dc1eead4ac67d 100644 GIT binary patch literal 7196 zcmV+%9OL7OP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91NT34%1ONa40RR91NB{r;0FW_8?*ITBhDk(0RCodHTnU&I)wTYsyQ}w} zm03Vmg&-=4h`XqhsHnj$QM0OvNnXChhbC^A|Jq%lX|K|_w+3?GmMKq}GUfP7F1h54>gwtU z58JXVZRgINb|@4=JRV0TlY!swM<^6QHk*cF7zhLcFilfkOZ|==+u(A!!2U8M+hx^n zUH7RzKA%suk48J;@f6yn(`n>#IgMlIG)>E=(^)$l4%+SQQ8SrL+UwV^vx{w1UwNj? ziG7gI=h4#Ag2u*1$k;MZ*5x;C+Vrm(GiEGTlPEAr(OCT5=aVo;n>cY|Xu-S}@3C#m zmcz6xJD1C=gB7-EO8xHA^|xHJxhh@jrOv6Bo6bEsJJ(WACd-SEOV*pa?iq%u>h|Z) zk|j(2Ky~&Cjm4uRV|tSyRX=s=RQC^laOIqahQSjP$+XE4gwvV^o9FIbIa#?Ox8)Cu zYFfA~UP~J(l!RivbJAdG7Bt>>StcyP97ijT=d{`L?2PB!Da@h!*iIYgnZ@6(ii!%? z^5x5)9zTBkSJ_`C<{Zv&>Rb|A$XThk4?AqwnGFp?C&uIHyqmhg!^1=V^YCZ4!|j&3 zuJwCfOPfNw?tNt}H8ymP;q_>$9lDL*^LeUIaXe>S4~ui{oTW}mkKbA>7Rw)V%rO^F zpFaI8(rOiH?#3MXQIbK#7WEni4H|j^`DF8j%kS>5W{;uvy+VTtkKutud@|iB%1;(< zi*mZ}L+-7oMh0tCVe90RPbQ6kLvzoLB^k7E;X*YxieX4;j`nYyDrZE+B4bRr?I^b0 zJQ--&3d`rG%2_?$WGou_stTm%yoTh$XWrs1cEYLweD<=(!K_%OlWDrfBYAoJy5q}QY)Ler49L)yRN+Z7MCOYoA zAO0-~_%t16CWlP$^F zeXSHM5v%25D1u-mrR48OKl=(?wbe-GIuV(C0rHi^9IBnYYztav-3Nrc@Z^a_#3OMa z4@6NILBaYDVKK$fEe09cJELyk}HRoRe z?X;uOX|=->)zLcb9(d7-yykW5?6bMGOZwlLuOYZYDvBR=F_dIO2q{(*4N z64Ok!2oA|n7D60S_Bbn$rW<2rEydw|0%^gY{Vx*fk|27`8hs!aGKk)FA3U98eku#& zgwd$G?E83Y(GvXa-3-FD6C=Z>KD&imksZ-I+yPgX0d zw!ac&xywQj=Ub*ohH!rzMTI#kpwi%?NAC#*5MTHVa&z85FxZGVqNttxAIP}8xaFz$ z(eACGZ6YJXA>1){9ky?6#=vW?N3@Z_tdH5r<(tv=_+Q`+c%fMtg+YZxLm49Wa*G&S zmO~{OpG$j+zF(m$mlLK-gF(>ckIBee_sQ4qyk2!9h1kU zQHM6TY2EIBvkgm@zl}lPxeoTY!3;yP^tiL=xaS^*D8wH1?#l|mUb)xO+ETVnNeOa# z)YQ}{=Ikp-Gu1}bB8>WJ*4KZ8)`uQNsInf})>ecspM=QRCj4R6e7v7!C}DbVTy+%F ze{>PPa!fVML<~vy2>kkqWw3%?)LwfdQg%B6l|iIu&qrp(n^akT>^*!_I#ZCQ|C$c?B(R3=o&VUTo3C!7LuOs!$Tm&Pv z#GV#ZU4127HG^^6!*8R_+lXAe6W=^qN7L!Yqq8%MA;X5@>hp%d6>DW?F@)bdvkvi8 z0(IA3gH#2*-=L1n+}DwKaA-LVb>e|QtJ zjg@eFsJtsSp#86ZL4eMuDe;*s9SIKgKHctjC`Cht4e4Ij<5x)ua@zDK)R0MWA%cZ( zlFq5A(}(KVijKSggdn9PMQ6`*<^)tun1sh?zm5fM5w>^ZxT-X+JnsZX)e?BpC7|hc zbFfb0%ok2M0$&>Krgby1E>VSPv;GNR<6#(Z)z^{jV6-2qL+77nA-8!k;Nyy}%c3yR zkK(_*6)tKh2|8@VVXE;yfwW{4dn=CQIQQi#YN>`7COPHvyU`|Qebsj4y&f3M5)Qig zKhfO01HXM`9T1Knm}2cqkBXBNPsjP9H%#2t7hZc?aOXCXYi+i0(#(5BI*+ zfyM8=gNE;41?!7PaTz%W*H#m)cQa+;&GNy-A4P88WJqZ6aBk&F*}Jv1wJPqB0rpU# zQlnDD#c`Guiv0Ubqu&1rDykwdvw8UIE9t*HgqD>%anlxpOu!E>bIvEfupFPh_B?dc zP^uyHDe`#HK>`K@@_4xYQ?$l74#Sk}FBYKXv!mc67!!5~{FTT&u?Xp}y@cRrFG4!U z6bb+Bt#D}N<2idHiOBHIwm9PV zzkqt52N%=H%g6ojFz8ILTcuoq46P8~f}%_Qiki}kqRRO&!DqmbH*K5DUmFZpK7`lr zfxAM|wr+Tm7Fz!JXAGHn7X2Z%Q@)TArLugI6%(5IvJFcz$Qjj1mi5I^R8h6zf*uYa z`Hxw!9)A?xAVZaAa*5_X$Q>8{3i>Qo8foH8Iv%Cfp_Sq;4wV_S3Yi0uRC%5$`D)6H zPES0Rwvo#)GflM(`zU-t-pM49XrZ4*+@YyO`pK6OJ#QK+FZvm>G=uEcr^4Jo*>+_a z|k9sYRFP}S<_Z!Hh z9Q*ZniTqhu{UF#&sKYFqhm3e_;R>wY;fFyKKWQ*<^6A8(Tpm6%h&-jysH18d2{Ks7 zDoA6w*$c*sD+l)gVX$mVyO8 zJ6Nf7pH)mW+Us#Qu7i{D)vXC(SGHmRR)xACC^FbWK&f%0k4t9gEODU;!kLq z=*XQ|8^<-1PC|-mpm!u;v7OnOV0-6C@>-4raZ2ivFybjBVi}xGC-bu7Nky&=n_7X} zX3XPiN0dpoP8bP}lcYLR;tFgnL2Z|+LJ)4LO z42AvJvbGsB=F?2q5Q7qJxarHo>C03iM;x*lYnfbjwwd10xUx|=R97a&sAg29!ofO5 zqqgnC^v=fFrQ^^(55F-Ts6 zY-3ZBB-ec@vaD>;0aFn*Es2NQSnmAWQfx4)2&yrhHA=&zQzhPV(SlLR#3$fcRynwV zjQ-Q^!yvQOLCa+jz$GKu$$0GWkw@aHvuj|rZ%5YCfL}hb93~~oV){ftpDg9kw`seG zIwZxxyn-nOa)Y3T;vPU;d^W0O5D3V0Z+vMRHtIN^~w`z$Sa?-Wa5M5ardA`ex>-Vtke?DZ4y$>1Z8(`Q)2OF$?I*W-xBB3!CHZSU>-D*iLJDIcNvLo9q#6JH+&bj- z!O9X5Akbwli zk+%c*&EsvjVBC8+{P3|zXBne840nhrapsO)*}PEV&hDzR3>q+CfZDv!n~HTIkL;lZ zlkk8J-R5^!;`|XIra1V137i%I*3A$gOGh$b(j3!7slK9S>VmM!JdVwmDRLxrG)f{g z>bLqM?RY~%iLBepSd4pzXb4QY6*|Yyq(b<(`9q8uJ(A5N1SvpuQ7(5=T)Yws=H~u@ zSUj#INI<%j6qRKV1G!%Pa6t%-QXxc0iXks{nPxf11-}y}osQEk;+tUrDq#T46j7P? z$v~NSp|DN}5tx&wC`sUSgd#E35>v6g3a!{wNtcX6_razrYcjU#&+%kP5}V6e+>kDh zlsdi`o;BN0X&CX$OO>2auwtd|NnKe6QF3}^5W`Z=nG+!~IS=1PUYNv52mqTty8Ia! zthe$yg4_m}r;j2Lh0rQ9mL07cO}I#$N#Z0`D!Vth#hhcBg;0YVJH<`5sG=N6g)$y8 z$w<54wYW~E=ZTTTCR$Im=Fk3anz0n0B74_7{9Ry_aD7iwM2dVm{ev>4B!e7LYj1D2 zdfU7p0j5C$bWSi&%c9Y&`D zNG_NUYu$Q8NqzXJ!=RsZ8jOGsiC5=BYiomk;l;|i%sx5`zA+=<9(N+re|-X;vB$t_ zYeq7eLFBB@Bl*aqh%<{BxZn%OQ0=`+Y3>3$TWXqrbQ77M=DghS<^?DBBvaH&dD6^zNoi_o zQsLPN)|_#&TM7Gj{ys7b?%Bd71L4t3?H0Nc_5=pz3QEqV|%na1}+2giN@! zkO-4R0lJgi{a0BA$=bZ=sfc3FBAb_Fw}#2LV-jdBM(sJSQidL)>EKv)p1WDO3X2oRbM>_yiMg*L z|JqAP?`Q@#Y=gbvWta@(U7c}+Z@3oOHEVzsJc7g#c|Nm}kt;8U@waCwRf}K|pDbH8 zIrN-;@i*(KVTuR6VNgj1QQXv!U6S^DJVGKELrgoF=jRNVnSgQHDQNuTG#K^u*mBDq z$dY+hJ^@7lfba031Y{2a6OKdW(c{F%Au{14uC#Jv)fby!RMsL?*MO04Er#n5W+qQM zo!)gjU5q@2J^3J?-^x{w6fAG0asj$beb=Rs#Z?W5L3_|B4a+jf+5S>W#RP$r=+bAg z82c~Wh4)B{ETB|Br zz})677&CAnwuT2`Te1x!J~ssaT>TLy&{Ek)9femGufuRA|IJVh{Z0B!o-iWR#E;i( zMAhKoXlrHCj97cbsF7H|u^qK_^;o)9!zpgRau_59Ly_6V7fBTD>Vj1`r)ex+d3^WtNd$dL=={aYFk$>itRo%;YU0?oC64Bn4t(2tES{ae5*M5?3Ugjwf+L2XgQ_YY z()6WDz^Ee0fsx)mhw8!B0vm`y9)gy^&lYb#_Sj=@;J2X$2kOC0u?(`af@dKV9JRw4dXM zD)%(?G(JW!81%gJ&O5(9{`li>bZG7=2t9}{+;$M1;+LYH%15I;`%!u%8sp^11dWz% zXnlw)3!Jl=Vd_)kx}YrVG3`o6Pvf$Wd~&J$rXY7=@Sj2d%*&utQ$cD@g$x0PCWj8L zVd6hcub3eP-CvH*l~v&bpU1e0-z;)Xj2<^JP=a6=Ra62P8bNy9Zqx{*u%CP^ljZ6L zpDNK$$s*su@`iGR9GZJJENR8qNO9!tx8Ghu$#E42>c%C>0Sic*GEOkgZSI#o0N^S5`vIgH5Sh5o2<|uiLvD2KB+Q22xRwH#IU410X%~RU4Wy_Wn?{uTj zY2BNXB#mACjCup}LBmIn9zBj9ws32*EuR}JtENtJRrn~G7c686rJWaMp_;#)cA^I+ zlh4vf5?==8OAcv5;gV`z2{HU4Lxxc!$T;jrz@wD1uj(#;SjVwgB~N1~;hw4$QRS;4 zt2Mb2O;YXoN0s6z8o#D=uUffs!PKc!Z{D_ToA9F~gB(VcIu~9bU*eoOb8h3463#>Z zADq+k=FNL#?AWn|5BNm|dMUkV=dRYyefx&VlShmgHD+`)+G>X?D=7soJIUo=E}Pg! z#;{u2Tg*caImFiW0G;X#RUyyqDV2mDQ%fYGsIRHl)^A?#=0ZSYeSLk545t&Fa`THK zSAe3PF$^xUnv#)pZQi`OhHbQPFl;jeU}y3)0QBnVZfKpIof=cY+Rp7e`Kdw@4g8-5 z@GEI8#l)A3sSEy(1vHDxA@O8ftFEZP#~-hcRn}DIYpbfPEI+uXCgiy}b?xo9-~Mrt zN};je(zA<0#fx4!+_T~Tq2EH2BLKZld#??gp43&>>eZ`vwRfDVt`3&-A)eF6IXi8g z-#y#OeW$M2R-@TFBhptE<~3C47cli-9{>4szfi2M%)JAO{XG2X+~X e13YfY;r<7>o)FN+{q>>%0000P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b79bb8a35cc66c3c1fd44f5a5526c1b78be..41c41b84370d0560cae97a67628b5048d20c1a35 100644 GIT binary patch literal 3839 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 BF!cZc literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@UPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91V4wp41ONa40RR91U;qFB0I4%yP5=Nc_(?=TRCodHoe7+sReAq^@7T`=0fAp7U(yIp<36rFSI+ zymw`<_Fkv{w~v4CLHfOx-h;q<5O}vk;N8j(_oQEYtIWF{r|G|iM=lNuwb-8vd zuO-XZ($Z2$CX?BYj*fCP8ZGm^w0ZO9^0sZ;O7y*CWvxBG4DVdM_RNw!_uudO&g#tN zavpW0UFOQnxA8j|3_ct2;sbQVOoJ~iudDcnKls64e)gPmzBp~)eGlSUuw1ssp1jf2 zHdyI9e6b#t_!I}*@d0C3ci-FFTkh%UQ8*l?|DFPYfQrSUN~Mw-8ynT%-!Bj)kms%t znrys%eSHdrLhhbCD;{{@fm^=yt#8dAY-b0o2ESLm)Cw|J;g`O2_PC2LzGU8Ehkfuf z#X?Rgs1UF#;04^(Mr0lL5#b}ou7NbmdHg!)@MDc7NxWF_6M-~iJ5zmqeQ@pCwU3^A?zv|yS+b;Wu&$jtv}^s8D@R+%|n~UtccI zbT#M)JtI8#32<>FZsltESe>=x8=A+J@ydZfDNy#xUddzZUa*|^0_6bTtSb%}Zz)4t zyS8WF0~B%B!S{P~PaT$mz$6G;%FmcFYze-%+e(M4aIBsIcm+!Vg-dj!98tkbD93G}TmYlS?*HdfBoYaLgwpZH zAOC5C*`tp>TA()u!>Xwo0(L6E)S3{FCno0e`I_?YRCtWP7?48-=TcauU|wFSQ=xK~ z!jKG8V~krHU3IXdfPEm+iBqOb89!M1Z6Uxf9Alb=VY4JR%}3X>G0cs$ar{2(rBP+2 zwO9RvGEXm-m#9b8@s*u1Y;e!23b(elCTb~K6Uo5;9owmBGXHba-0)xGPp({4EFRIo zGY_eF)qM5;0_`u?A!v^rKKHC;_+y_3LUkGy+EPs&sR;pK(;>hVj@_`du^-VcH8RKP zLP+6kR-3QAUQLmy>U;b}_20Wd;Y3X3+Qz+0tz-Aq5MT+dsB*KLs%t`kUq_|#n(Fn7 z?C}+INM1Pz#}BG#yq-HW9i~7Li#e>G2k%z-@fQ@XYgJvoNn3BcUcvU&3Pgg+dzG?* zVw5r^SXNEMxW|1RQVX?6rVj#e51JjngKnCpyfRt+*=2(mc5^*f3 z4A*c1X-p8%(9qyjEZn_A{RRoI3_te@021}mR8Dw%B+nxRZ^y!0ReW)cLh(8kiU60{ z9<6KAwwrHLZq+jiM?ex5Ya!Inya}Tb~hVwU#H~7QhY&1(XX7s0?C~n^W3){ZADQ;X(lD<#H5^ zfuBJ)$UEJ4$9|j;c9yZ(0G37s+K33Kl$W+|-|qY$gWpH2&@c0Lug3Guy$~#hP(0!P zVkShh+WNCw6U(s7f(;1-b(EP@28j+2Z?{Xk zEf3{8*mxj(+O%mk!D&PU_%i?(ZrNDS-b{r6QyF6`1m)+;VFlw2O8@@1O5JszqD>9T zGRT2czY^yjuhOAYl}n|R$4IX$)oJsOu2(6uPQ_3JtJzbelpmQ4$8KPra}-jaxvpzT zBO<`B01LQiuGhh|R~V`_g2TrNQ}eb4(|y`{^|h)ipy8M)-0!JxW>n4J`M;X*m9JnM z^ed9fE6^}bp=Z{pd(mz3lFTAlnIXZ1xalXLm5&XZ*){eo6{@cgJ~F_E?TGO5Gx@;v zuda|k13Ei9_bT~Yx2y1n*A!`J0H_i5<=Qpzo1a%HaeyKxoUZ7{ zk5jg{8@QHK7pqg}O$!v+x>}_ez?ZQxz|T9dN~PvL=Tj4t@Ig}36)prwsh70`zjpsR zYz5U2uu~yhHL|p~*pM}D)b9cOQY@_OmX+G}vm4bA1Na_3psuvMQ})-mPkce;+*qVAx}*ZHbgJu)+ZBl#&1Z1l<)HY2Q)?01%z`pV4!d!U;UBv0Kv!ms z!B<1~Xa=}G`7>bs`t@Vj$K6+DLx>e@k))tlII8wv+@!?X4*Wf6J%zCP;wer2%7s#5 zs@6V#uR7MQQ|ypa)OhZx8ej!04523@jq3U39nx#hQdvw`6U>A#b;8aZBVyHS?5Dt> z6jm7rrsEpG&|#$7sH@F@o$}jqoF%h__l7EJB$I`J0vK4?mtRoN{0Ee%Ye6HD(mkDu zeD)+IkNKEV7-Sb-`=suEt4#G3brfcIOg2?1IBE|z>f*Dw5zUFkXuLP0#9n{K>X^;q7mgY zS(T^MBXqwm96!4C{>Sui&-?WA$J@1W%}Pb59j>MezMyWe-wEG(+!Q^Fey_~qzf)Zt z;axP=!_0yySsIZy?BU>yNNK=;Uz6Od2?2Z9mr)jblVA+daRlkW@9$UfxBsn3eLY;; zQz6r@ap(QH60?rhwpA;2<9~H4N|eNFT>)Kp&&w(n2GnrY=Tvv-;fi+lsT|L%A)C|2 z|M*X))E2RHgF#mWIyP)(Gst*{Qke*kHwQFABQ+twXF=}-`!Q;Cz8dFtFx?*t!VgsL z-L9^m{T#OADdfQd`*X^EV1^pc`5Kg*(J$_OS}*t3%hu-Pk}oJ&-zq({RDE~dsc@3Whf)E{7+C=D0~mK>oGKt<2m=EHV}}6Wvp2?_b=8?x zZrX`3w%o#gp{!^;sIGf{uH2LBR2QABEQVA%(4m&|zpQZEG`+lZm2P;dN5N#HJoHsm z4s~WDy5`p}Dx2<9(?|YX@l%giUuv6jVT5jcqSjx3vr1@F9^ogUViu&^OnB!Kp(-_1 zU(*cmgR1;cjIrxB%m8*%be>NLKMz&LLdtDisrKuCsYZZR#WJ# zSKs}VR+rmUiqQQ~0RfBGv#~+9FCWl<|9*)A;bygd`)lfJ3#o{4mPD0}t?1L1o30_g z2L+TiO9AAHV?kpo3GjS4xYq=HgX4$@@YPU@VtZ$jS-NGMnI;%bFE1*JYQE*VYZY6a zQl=q>i>CnfcdPCE^P%MNdh+4twD9E|D`HK!shR4T0T5?S;DGXxDf+=(%eB30h3XDD zN#oBwOS$e2W>{KH$#Lqu`5_Iw{D=aHBsNWe5<9H>F&Q4fFUMk0^>z1Y@#4ibAz?%W z_`R|aLrbH@u3vtntGNh*7HEZwA(RX%{p7>S|KhibH#EbqgUWRFDtgw()$oxMl-j&O zSNvwV`lC&#mO16py*g{ZE*)Cmp+YtVQj&UVTTnOr`Y9)3$A9Tt@(vqM02%Utb%Oft zptfG~bHz$M%skXlCoc+h|wrJI#UQlS_ermb!LZwT6cto>G*2R^7@EMjc??ryZfjzQd2m&go zu)llAmME4dKr5b1)?C4e2=I}(YSk)tJ2(UU$8YZnueAEiUF?AbuP&;ddlstv)E^aW zYEls5>;s#4yb-J+4XP_d86dopPl9VydTnUsfe} z*!tXlns+xAaK2ZKr+!i4Q;)!Xfr}y*P&6A*`*n*H?q83x1-~TdzsIs{C2&e4uk#-7&mU51NPtyczb{!EM1{9kl+odh*1^n*r?7MZ&iJ$ zkw7a?S&KV8avwFF^9>>%di0ZDJ*>5v79~SyMA?2FJ)vEH`I*yTLs>QB0sZzl2dcTW z4cGMmvmm1ROG#eFu$MlsPi^10K;f1Ndg_Vgn*Th}$W1NC3Mrb}q`y6Lwi+i)Q_M7rtCXEqxxK$29)D3zV7>&_F%|gupIz=Kj3ws>f zi4>4KZYCHg%~v$~WRcd{Ol~Iv9;ucQ5#Sf}En)xrXsq#2ecQ zDTynTL+xw>U>ANvfoQ${>;A{};Op@2Iw-W1)ui$^UGmwvsHCh9^F4&%6_neb&+6bq z_S3~DG@yk*#aML(SlgAqUa1u80+HDtQuBGAR9~;Tr(s16_3F9x9_5!m3=&wxVOi6b zK3&`q9#fpLdnAfSOQba+z<0*^pxfQm@5JzcA1rOupNK2{)cwlc`KTHaZOY(BP7U;{ z?z3kq^8Syg^Yv|-_uy(}co!u4q13lcU;SW<4uAhasFuhMT49r4M-8o_=)m&Kcmja zf1zMqy)({o7T$;)h@cT!S4WdfNwIwbb@g@L0S6r56q((XuL%LQvY_E8-t~fx**^=G zBDCMMG-7(9>RdEmg~wh|tUjp%I(e?AU-e)7q$2wsqE}yfO+S8Wg9aK}$dAVX!F%DU zlQebK43N>!+5|gWU_T|(S3yA;*FrH2|4%3N=~EBaakILW>SF~gSkkM#4f@fqUL=gA zPfe$NO2tonK)J5%Nb`b{2!#qTcwHR zK205;(PK|+(z53kE6C!~)W7+n(o;cSDkiTvsq!-&1bZ#Sz$+<|VLmYnj1|>H!ag7> z*BwPtsnm}9owMxxttMy4vBw_kN|`gR8lZQscIR(JLQKri@$h)Uj}Urct($kfBCB3k zp^cr6uosj;6)@p9f9bm_L%DypA*GY?ik7yp1PPh!+fh*WUoF-KEao_Tb~yH6L2^MN z)AF#M*|?*BxiQZg!3*pG8<07N1Alj)*0)YlnZT_&&x}209jvAmO|0|!`D#A(WY!ms z!E(mG1o*~&#d2r3*tZc8U}~s+Yz7$MZBc&o9#LaGgo^-t53MDSA=LlubL#oUeQIf* zfD;0rJ^qs@ZsXDhq8iq2WhE@A+4ycknX)o0A%-p74Czf%+PU^&onCBmzaj9BaAk2k=;AGRIvA;P@RFIwSLFLLOeu zD7C><)8KMMC)hadp`4MqpYbVy;B}0iqU0)9(xj%I{AGFS2+eDJ{|WP9_~hzRfvu@g@`(E(+U`bXI#Od5DRtknh6W)|I| z%&%@!T^N@G%Z9O{=}38Hi1Dn)n`x9+Sq~0i4Fk%qZ7jF|P22?*#o*LLW`vna_TT+6 z{|m|dN@X+{Vycxgwue3+#{Ccmko8!+7EM>gwH<|p^$!r7lHaC)xgS7UnfAl&u)v!y z)3kp*h{$SK)gBmQu3z+JIlik}(ufG~E3mkr^UgcZp8@{knyv;TVDJ6}@OI{B%vICv z3t2RQGJ#tR%EbGkhDcEhfAxeO+Yl$joEV2}2o|zQUpntlsd+jE8bXn9^P*D|sn5Y2 z-D>dangHNT0RRU;K^F4}))v&OufC!mFIuTwVlqlVL{0gP`ueBmYU0d&E8a_HK)Kkg zLL{SHq+bJB*q?1Wa|pJYYfhA?^oX7O6{^@x(P{{=!ew6u%$_~lRkNqgI7b!Km>9e@{_oO%Cat(@s%}g_Z!itFbY&d`keUV3h8FvxDFUhh!vM0h+Tr z_*Mex$%aOK_nf(U{GT6Hhc`i?hNNzKc%?ph)N?xK=p)sYM+e585yG+O{y`8PsD@;A z)W=OtX{b!9rGSm3YVu`(FVRO^xnHrVb0Gw+T|G1&_zzcG?+vRy8PVd$Uevdsf+#IEH zeF(`I+7NQW)K7ZLE_tdq?c}W=x3~k_VQB_**~V@A*uG+X{<*Neb@qoeHJ-)*gW7TK zJ@RTv3m#sA0fu3iqdsO?7}jH(zW3I?vYHTpf5S$-#|gN@X3fkuE0b|TVSrOn)&cIi z?`b`&&LcvPb~Z4UwZykeKmR ziwml$cufdE{d5S}F~3IHFY8GGpN;MJG9L@%h&Pf2^4Hes-+s4Vz3gcY!UFOvrd;rW zIvqIcK(%9p+0K>#F3ytSzhGB@hO^7I;Ewn)&nC@OK7_Rtkss7Q(3jVj|NIyoJ-t^0 zJ*+}wX|L!=YTljC;+gHiRxsi)9}|*^I`+UBK}Y$|fNMoSH6gqch~Lf;1%|mGe+-2d zrys(Q%AnULP$#dt<58{1HmIpS;)Ly?C=P%9l@FteL}dH?DI3b>S5bc1nnSLLKQZCIf`V;#^C+{kI=+Q_jgmi zCIsxJjuBs5FN_t0*lJ2x8f&I$BAw%U`q4k=#^*LF(lQIdo5dO}>9EOV-Tl-n$`8DR zb&T<5XR-i{on{^tIyqpNbpsw8C*MuW8pN9Gla;et+nhJi%6xh_i10^_NepJG`%A9Cgx1*myWr)IG0cbjz~!Oc_{} zg{#1_`12-EO>Z3p>RTGKcb0Yx*S^~qpje7A1BsY01epvWL?uY%bO7s@4mk^b;S1e5 zZ^;s!{DITemu1t3Q$r0rgY|N^yX5EOiUMYhx#T|{w)lt$@C`BZ1?{N{YOR*c34<17 z)So2E?yh^6>M^{W;Wk{sMv*Yzpcz`Md?gwX&<5mE99X9rzNw!>lToz^;vul+-uf3X zryav}RA~Ib653XY#UCJz;6<2&Tsz_>Y-ik*`q8W)2tw>K8mAvFTBi>mwpHOklan76 zb=1{T+aDSxjfzf3M1bF_9gsI98Af-8$&i=3-dbkk`vDfbS%T8W)f@Dq`Om6PlLe^( z1gOmkKigcrop?_yQ1ifPLLPb>Aq5lpigK;10ifNRX=HGuz# zAQc2IZV1k!$in|4aWtgV1{`=ZnLOr}uE-I899I2E$(I4kmM!yH&`8;lH8mtWdqp~8 z(`&@s=2ABHV8OPwFcLwq+L;edhX=^HOj!G$bfOOa7~v-X-z&o7ous21=Rpk;nRLrZH-(tH(sE$rp1m zIDH>%Vmoh|K{w#$7VO2(h3>)d*{J ze)Eo+5oDBPo9)1Sg{lF7@o)DXc7c-teAqlO9&e6gVloY1T(j5>cC48@ZH1ZTHsjG) z2tk|yc2STY0V(OQxvr_n$qr^6GsN5xQJ&f=-arIV3=zdK+W1d@jZh5iRpIGojJ@W3 zQ-k4dQ#Ay*7JL?j5H=F=XMnvMwsIRx&anp|VFehB9k5{?%0wAh0TdMwdJMRX)oGDv zCKyfSD;b56F%RK~kr_pdH49O+;|k23U?7fC&dzSK9iC>HPy}A4TjD zTk#4a0dr8Nmk2X_0Gl^cThP{H(pZGj4F zgZhKGFU;u{#${nL8y_~_PqZy_*wL3d>Q1v!JAgCJvuHVx97^Ew0H7rn_oGB#6_VHj zu#_w-bU~u7nPkcUU(>y!wwlIWArCPiCxQ@{&2T2lun9K3W4_LuG}DKfe;u|=O$gvL zKx@)()++SxcdE1q09`ZzJ%o!Q*xHEfk0_ffYgm~9SLwfz%8aR@cO}2}m&$GL*4i)p zje^}9H2D?+!cIFw8@~DX>cVe!$Roeeri=bYY1*9gpYdt`jlo}`_FJ4 zvy5$C7+b7P)aHudz>YHKi29ifUjF@CU{({K0<$Les{-Dy)hu2UpcY>l$B$oWH&4(l z`*ZL6FSY;e59IAPLHpnI?~2WwhSEVyL@KTQ|Lz|&_vUMm7X!*X`mDm^$Lpy1x9Xrr z?$?xa{zAoVTU2-K@d_SvIE&@588n_^ut5!}0X0tEM<4plZ#4V!XDUzFd2ZEeEL;K+ zZ@*jhM8_rC*fBqU5r=&+tLRsh1)&K9A#J?u-`U8$MZpLZpC?F*D2gyMcN{FPZ{E+< zdG#$y{>7hZ&b;p_*jUF*vpLN%2_pdC2J8-OB1F2qz1_jqm!m$6cD>d#19mM`?Rz4R z2NR!*scHc<4NMt$ct9<1{m$!eCKj_@iBmtSAj^^#M{fc(QI}Br&9|!;;M6IrhEIQ7 zxoEQ*4sKR<{sMLU_-dt(K3dbS0eP{w!i1-oni(_$3O{~!YrEdK;1cPLRjNDrLuxqs zgDUh6sP{MbYTU^uDSG@7T66K=DfiM+>A;!rcn;&9*rvd%uW94umn*^AbFYQO*rp6N z{nfQ^QqPrFDUs<>=&(Z+Y~v`L9-^;dfp&0|F;|li)sjg8uwYA))iVt#TvH~FEOSMy z@{y9`|M18FI=h{AVj4s-QbNGNLZ(ht_{0yZ`@Y|)>!v$dhD1idzcW~+f#W`)=JUU* zN#iH5hwCN<(*5ecYk}5%<6gE^Pj>ie-(f`9PnzPSP z{|kRq*L@EvzjTQT>(;2u-hli|t26;ktLwgdoQomU3*zP;pr(KPM|Ce)pwMd@a7DK{ zSz}sep1^_fQO77gcaG9m{Y9MRNsQTv8L1N+1{_NmsTsk&pzt8^DY(EzV2+h%=S*TU3h`&Kl%|?lcwRVp=c#CyZit$h@&mfddaw>a{;3$7jRHr_ zRzLfUGc%^E<>RNh{&cT-L#5gKsqK_g)bZRh1tw2a{aGJVaMm34ZriS&&Mm5+IZNT0 zGvz@{w!>y5IY9KF$F|<|Bab|CJ-bWo>`FITs{wgfKn}@lSS($7>7_F-zx?uN*!|tY z4v?x$&=BA!JEbZO`)Obnc&L-+VUHvTv@*ktC}kD_kOS!50Ec5yc!p{dN)q|N`+j!8 zCs@To9SsuK!=XRe08D@M_$-dIf`u~X;n^5cktlAB0jSdycz%H+X9tT6P(IcKE5I0I z`%w@jF2r(X&iR|r{h{U@{fpxl^4Kv$tx$Lac0vkk9OEoZwDw>dSalFYMty7+u|=8y z2BH~dptv$$3i=z4G!}P2G1Q z+mMP{_xdKyn#pXzP=X9qkA~%Of?^7P(b5euytv?4++w51QZlPW5K zyCD>gDQ$KFhF>z+q&|S&#A!0?O(|fzY90$Y)!)e;kFq9CN~p6ZgWH@_xmdAR7Vu2RkZ|6hA^^>1J#Ba<5^q&tb(a-Mu1D@HCuDIc0EqeSUtSu31x(I^l>(u%Lpb zO=(dx?tnPqCntUIaE=z(rZ%(=gM1+s)a2$yopRJZY9?rR&WuT#gGEh8Q1uF$GO-Q= zt(&ue0-7|gPM`V2iHczV6jtbCOTP4U=@WX`{WsiT}l5n*MloTcA* zwhP!U;WW~!kstx{DUrX9px!ioBXb^_|0!TwTCESmr?MT|4a@Gj)(r_nKj2<0KoMnBI6DK>juQ457;BLT(ZTR^EY|=4 zEHZeD{wzan^*l3gWB^tXw&P`pjqtEyEAz{u>P&8D)ktVb5tTE9v0g?F1VFllmUwix zXe)Wjv+IkQ=9qg?P{J-hadp&@t ziHHf0$hgXZ-?qL%lpKh)a@JqBJEzL{q0IunGZt8}V#ROm_hSrpZs1k&+hfZxVxls8 z@3F?YsTAm|3U~9}D4~TtWAO?T?ZqQ)9^Uz?GMZ5r7-`{SZett&PW z+(SptMj7rqk6gXH<+aOW8iU=_sz$me%sU`<1Qb9u#4roWHS8v>NT(Z z)AzpjwUsMZ4h+K1@>WgOgIbd7DK3fdEw|iq)ggx*@^uTnvxhDcY_Jv^y755U^OZxl zw{80t9>d_#OyK$CbHjpq8KjkFsDO(+_A*bI5Q5jvTd;B3WV3t2ish+{-M|FVj2Y9hpLnu-(Y zuw!7vLKeSy0bs_rP6%x8>_lu7#!*>aeN+8L`>n=kBpS%1Qyz`5y2vHWftKmC7Y;?d z#)gJc=k|_PMj8(WLZx_utsDR-!tX|=IndhxL)~5^6!Loe`@9CUpfHEQc5wzp2_Jui zRU!8E0(!ttLe>Rr0gCqBlm5OwucfWc!%0_ww|AkHm6COJb}69|(p0gub?df`t6yKe z=u@Bi)cu1nstNc8%h4df&u?XRi&@LOmy-?J2*eS|Fl|>IE4sSJ$BK9-oN&TU`S@Kh zj~^yA|9;8%`Y`qG`lx2$?^ngoA9i)0L4gzWuY>O`Pmn>`e1FTwp8KD{_bxy8 zJ`nuxJKEu$y9Z!HpYeFFCF|R+AAj6_ziaNys0-9w^V`p8eKTtIuOrpCH^<@6uv+>x zQlH;@_il#3-lV8^BOLy>kN@3>llS`c9t7Tlzy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d34e7a88e3f88bea192c3a370d44689c3c..571adf4625b0ac28e0dba6078fc12d0645fc1b40 100644 GIT binary patch literal 28727 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 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372eebdb28e45604e46eeda8dd24651419bc0..41137dd1631bd5105d47eca4b51adda68f26b454 100644 GIT binary patch literal 51079 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 literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..360a160 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -1,18 +1,18 @@ - - - - - - - + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..5fac679 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,18 +1,18 @@ - - - - - - - + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 399f698..8ffe024 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -1,7 +1,7 @@ - - - - + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..1f88145 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,24 +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) -} +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..21dbfa5 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,2 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e4ef43f..db3f453 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +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 +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/settings.gradle.kts b/android/settings.gradle.kts index ca7fe06..4dcef4b 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,26 +1,26 @@ -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 - id("org.jetbrains.kotlin.android") version "2.2.20" apply false -} - -include(":app") +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 + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/ios/.gitignore b/ios/.gitignore index 7a7f987..ad322bc 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -1,34 +1,34 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..b5586f2 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -1,26 +1,26 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 13.0 - - + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..dfd2626 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ -#include "Generated.xcconfig" +#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..a97381a 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ -#include "Generated.xcconfig" +#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.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7cf71b5..35429cc 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -1,616 +1,616 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.eReceiptMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 919434a..c4b79bd 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist index 18d9810..fc6bf80 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -1,8 +1,8 @@ - - - - - IDEDidComputeMac32BitWarning - - - + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings index f9b0d7c..af0309c 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -1,8 +1,8 @@ - - - - - PreviewsEnabled - - - + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..bbabc4e 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,101 +1,101 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..59c6d39 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist index 18d9810..fc6bf80 100644 --- a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -1,8 +1,8 @@ - - - - - IDEDidComputeMac32BitWarning - - - + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings index f9b0d7c..af0309c 100644 --- a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -1,8 +1,8 @@ - - - - - PreviewsEnabled - - - + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..8be1cec 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,13 +1,13 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..1950fd8 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1,122 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json index 0bedcf2..d08a4de 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -1,23 +1,23 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md index 89c2725..65a94b5 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -1,5 +1,5 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index f2e259c..497371e 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -1,37 +1,37 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c2851..bbb83ca 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,26 +1,26 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index eb83d0e..1c28040 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,49 +1,49 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - E Receipt Mobile - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - e_receipt_mobile - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - - + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + E Receipt Mobile + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + e_receipt_mobile + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h index 308a2a5..fae207f 100644 --- a/ios/Runner/Runner-Bridging-Header.h +++ b/ios/Runner/Runner-Bridging-Header.h @@ -1 +1 @@ -#import "GeneratedPluginRegistrant.h" +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 86a7c3b..4d206de 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,12 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index e4ece6f..e4933d5 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -1,7 +1,7 @@ -class AppConfig { - const AppConfig._(); - - // static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443'; - static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com'; - static const String apiSecret = 'y812J21lhha11OS'; -} +class AppConfig { + const AppConfig._(); + + // static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443'; + static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com'; + static const String apiSecret = 'y812J21lhha11OS'; +} diff --git a/lib/core/network/api_key_http_client.dart b/lib/core/network/api_key_http_client.dart index 1ddf7ab..3328c71 100644 --- a/lib/core/network/api_key_http_client.dart +++ b/lib/core/network/api_key_http_client.dart @@ -1,24 +1,24 @@ -import 'package:http/http.dart' as http; - -class ApiKeyHttpClient extends http.BaseClient { - ApiKeyHttpClient({required http.Client innerClient, required this.apiSecret}) - : _innerClient = innerClient; - - final http.Client _innerClient; - final String apiSecret; - - @override - Future send(http.BaseRequest request) { - if (apiSecret.trim().isNotEmpty && - !request.headers.keys.any((k) => k.toLowerCase() == 'x-api-key')) { - request.headers['x-api-key'] = apiSecret.trim(); - } - return _innerClient.send(request); - } - - @override - void close() { - _innerClient.close(); - super.close(); - } -} +import 'package:http/http.dart' as http; + +class ApiKeyHttpClient extends http.BaseClient { + ApiKeyHttpClient({required http.Client innerClient, required this.apiSecret}) + : _innerClient = innerClient; + + final http.Client _innerClient; + final String apiSecret; + + @override + Future send(http.BaseRequest request) { + if (apiSecret.trim().isNotEmpty && + !request.headers.keys.any((k) => k.toLowerCase() == 'x-api-key')) { + request.headers['x-api-key'] = apiSecret.trim(); + } + return _innerClient.send(request); + } + + @override + void close() { + _innerClient.close(); + super.close(); + } +} diff --git a/lib/core/network/auth_http_client.dart b/lib/core/network/auth_http_client.dart index d210c44..f0d9c29 100644 --- a/lib/core/network/auth_http_client.dart +++ b/lib/core/network/auth_http_client.dart @@ -1,93 +1,93 @@ -import 'dart:async'; - -import 'package:http/http.dart' as http; - -typedef AccessTokenProvider = String? Function(); -typedef RefreshAccessToken = Future Function(); -typedef RequestUriPredicate = bool Function(Uri uri); - -class AuthHttpClient extends http.BaseClient { - AuthHttpClient({ - required http.Client innerClient, - required AccessTokenProvider accessTokenProvider, - required RefreshAccessToken refreshAccessToken, - RequestUriPredicate? shouldAttachToken, - RequestUriPredicate? shouldRefreshOnUnauthorized, - }) : _innerClient = innerClient, - _accessTokenProvider = accessTokenProvider, - _refreshAccessToken = refreshAccessToken, - _shouldAttachToken = shouldAttachToken ?? _always, - _shouldRefreshOnUnauthorized = - shouldRefreshOnUnauthorized ?? _always; - - final http.Client _innerClient; - final AccessTokenProvider _accessTokenProvider; - final RefreshAccessToken _refreshAccessToken; - final RequestUriPredicate _shouldAttachToken; - final RequestUriPredicate _shouldRefreshOnUnauthorized; - - Future? _ongoingRefresh; - - static bool _always(Uri _) => true; - - @override - Future send(http.BaseRequest request) async { - if (_shouldAttachToken(request.url)) { - final token = _accessTokenProvider(); - if (token != null && - token.trim().isNotEmpty && - !request.headers.containsKey('Authorization')) { - request.headers['Authorization'] = 'Bearer ${token.trim()}'; - } - } - - final firstResponse = await _innerClient.send(request); - if (firstResponse.statusCode != 401 || - !_shouldRefreshOnUnauthorized(request.url) || - request is! http.Request) { - return firstResponse; - } - - final refreshedToken = await _refreshTokenWithLock(); - if (refreshedToken == null || refreshedToken.trim().isEmpty) { - return firstResponse; - } - - await firstResponse.stream.drain(); - final retryRequest = _cloneRequest(request); - retryRequest.headers['Authorization'] = 'Bearer ${refreshedToken.trim()}'; - return _innerClient.send(retryRequest); - } - - @override - void close() { - _innerClient.close(); - super.close(); - } - - Future _refreshTokenWithLock() async { - final inFlight = _ongoingRefresh; - if (inFlight != null) { - return inFlight; - } - - final nextRefresh = _refreshAccessToken(); - _ongoingRefresh = nextRefresh; - try { - return await nextRefresh; - } finally { - _ongoingRefresh = null; - } - } - - http.Request _cloneRequest(http.Request original) { - final cloned = http.Request(original.method, original.url) - ..followRedirects = original.followRedirects - ..maxRedirects = original.maxRedirects - ..persistentConnection = original.persistentConnection - ..encoding = original.encoding - ..bodyBytes = original.bodyBytes; - cloned.headers.addAll(original.headers); - return cloned; - } -} +import 'dart:async'; + +import 'package:http/http.dart' as http; + +typedef AccessTokenProvider = String? Function(); +typedef RefreshAccessToken = Future Function(); +typedef RequestUriPredicate = bool Function(Uri uri); + +class AuthHttpClient extends http.BaseClient { + AuthHttpClient({ + required http.Client innerClient, + required AccessTokenProvider accessTokenProvider, + required RefreshAccessToken refreshAccessToken, + RequestUriPredicate? shouldAttachToken, + RequestUriPredicate? shouldRefreshOnUnauthorized, + }) : _innerClient = innerClient, + _accessTokenProvider = accessTokenProvider, + _refreshAccessToken = refreshAccessToken, + _shouldAttachToken = shouldAttachToken ?? _always, + _shouldRefreshOnUnauthorized = + shouldRefreshOnUnauthorized ?? _always; + + final http.Client _innerClient; + final AccessTokenProvider _accessTokenProvider; + final RefreshAccessToken _refreshAccessToken; + final RequestUriPredicate _shouldAttachToken; + final RequestUriPredicate _shouldRefreshOnUnauthorized; + + Future? _ongoingRefresh; + + static bool _always(Uri _) => true; + + @override + Future send(http.BaseRequest request) async { + if (_shouldAttachToken(request.url)) { + final token = _accessTokenProvider(); + if (token != null && + token.trim().isNotEmpty && + !request.headers.containsKey('Authorization')) { + request.headers['Authorization'] = 'Bearer ${token.trim()}'; + } + } + + final firstResponse = await _innerClient.send(request); + if (firstResponse.statusCode != 401 || + !_shouldRefreshOnUnauthorized(request.url) || + request is! http.Request) { + return firstResponse; + } + + final refreshedToken = await _refreshTokenWithLock(); + if (refreshedToken == null || refreshedToken.trim().isEmpty) { + return firstResponse; + } + + await firstResponse.stream.drain(); + final retryRequest = _cloneRequest(request); + retryRequest.headers['Authorization'] = 'Bearer ${refreshedToken.trim()}'; + return _innerClient.send(retryRequest); + } + + @override + void close() { + _innerClient.close(); + super.close(); + } + + Future _refreshTokenWithLock() async { + final inFlight = _ongoingRefresh; + if (inFlight != null) { + return inFlight; + } + + final nextRefresh = _refreshAccessToken(); + _ongoingRefresh = nextRefresh; + try { + return await nextRefresh; + } finally { + _ongoingRefresh = null; + } + } + + http.Request _cloneRequest(http.Request original) { + final cloned = http.Request(original.method, original.url) + ..followRedirects = original.followRedirects + ..maxRedirects = original.maxRedirects + ..persistentConnection = original.persistentConnection + ..encoding = original.encoding + ..bodyBytes = original.bodyBytes; + cloned.headers.addAll(original.headers); + return cloned; + } +} diff --git a/lib/core/network/logging_http_client.dart b/lib/core/network/logging_http_client.dart index c5ea7f5..17d79c9 100644 --- a/lib/core/network/logging_http_client.dart +++ b/lib/core/network/logging_http_client.dart @@ -1,118 +1,118 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; - -class LoggingHttpClient extends http.BaseClient { - LoggingHttpClient([http.Client? innerClient]) - : _innerClient = innerClient ?? http.Client(); - - final http.Client _innerClient; - - @override - Future send(http.BaseRequest request) async { - final requestBody = request is http.Request ? request.body : null; - _logRequest(request, requestBody); - - try { - final response = await _innerClient.send(request); - final responseBytes = await response.stream.toBytes(); - final responseBody = _formatBodyForLogging( - responseBytes: responseBytes, - contentType: response.headers['content-type'], - ); - - _logResponse(response, responseBody); - - return http.StreamedResponse( - Stream>.fromIterable([responseBytes]), - response.statusCode, - contentLength: response.contentLength, - request: response.request, - headers: response.headers, - isRedirect: response.isRedirect, - persistentConnection: response.persistentConnection, - reasonPhrase: response.reasonPhrase, - ); - } catch (error, stackTrace) { - debugPrint('[API][ERROR] ${request.method} ${request.url}'); - debugPrint('[API][ERROR] $error'); - debugPrint('[API][ERROR] $stackTrace'); - rethrow; - } - } - - @override - void close() { - _innerClient.close(); - super.close(); - } - - void _logRequest(http.BaseRequest request, String? requestBody) { - final safeHeaders = _sanitizeHeaders(request.headers); - debugPrint('[API][REQUEST] ${request.method} ${request.url}'); - debugPrint('[API][REQUEST][HEADERS] $safeHeaders'); - if (requestBody != null && requestBody.isNotEmpty) { - debugPrint('[API][REQUEST][BODY] $requestBody'); - } - } - - void _logResponse(http.StreamedResponse response, String responseBody) { - final safeHeaders = _sanitizeHeaders(response.headers); - debugPrint( - '[API][RESPONSE] ${response.statusCode} ${response.request?.method} ${response.request?.url}', - ); - debugPrint('[API][RESPONSE][HEADERS] $safeHeaders'); - if (responseBody.isNotEmpty) { - debugPrint('[API][RESPONSE][BODY] $responseBody'); - } - } - - Map _sanitizeHeaders(Map headers) { - final safeHeaders = Map.from(headers); - const sensitiveKeys = { - 'authorization', - 'x-api-key', - 'api-key', - 'x-signature', - 'cookie', - 'set-cookie', - }; - - for (final key in safeHeaders.keys.toList()) { - if (sensitiveKeys.contains(key.toLowerCase())) { - safeHeaders[key] = '***'; - } - } - - return safeHeaders; - } - - String _formatBodyForLogging({ - required List responseBytes, - required String? contentType, - }) { - final normalizedType = contentType?.toLowerCase() ?? ''; - final isBinary = - normalizedType.contains('application/pdf') || - normalizedType.contains('application/octet-stream') || - normalizedType.startsWith('image/') || - normalizedType.startsWith('audio/') || - normalizedType.startsWith('video/'); - - if (isBinary) { - return ''; - } - - final decoded = utf8.decode(responseBytes, allowMalformed: true).trim(); - if (decoded.isEmpty) { - return ''; - } - - const maxChars = 4000; - if (decoded.length <= maxChars) { - return decoded; - } - return '${decoded.substring(0, maxChars)}…'; - } -} +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +class LoggingHttpClient extends http.BaseClient { + LoggingHttpClient([http.Client? innerClient]) + : _innerClient = innerClient ?? http.Client(); + + final http.Client _innerClient; + + @override + Future send(http.BaseRequest request) async { + final requestBody = request is http.Request ? request.body : null; + _logRequest(request, requestBody); + + try { + final response = await _innerClient.send(request); + final responseBytes = await response.stream.toBytes(); + final responseBody = _formatBodyForLogging( + responseBytes: responseBytes, + contentType: response.headers['content-type'], + ); + + _logResponse(response, responseBody); + + return http.StreamedResponse( + Stream>.fromIterable([responseBytes]), + response.statusCode, + contentLength: response.contentLength, + request: response.request, + headers: response.headers, + isRedirect: response.isRedirect, + persistentConnection: response.persistentConnection, + reasonPhrase: response.reasonPhrase, + ); + } catch (error, stackTrace) { + debugPrint('[API][ERROR] ${request.method} ${request.url}'); + debugPrint('[API][ERROR] $error'); + debugPrint('[API][ERROR] $stackTrace'); + rethrow; + } + } + + @override + void close() { + _innerClient.close(); + super.close(); + } + + void _logRequest(http.BaseRequest request, String? requestBody) { + final safeHeaders = _sanitizeHeaders(request.headers); + debugPrint('[API][REQUEST] ${request.method} ${request.url}'); + debugPrint('[API][REQUEST][HEADERS] $safeHeaders'); + if (requestBody != null && requestBody.isNotEmpty) { + debugPrint('[API][REQUEST][BODY] $requestBody'); + } + } + + void _logResponse(http.StreamedResponse response, String responseBody) { + final safeHeaders = _sanitizeHeaders(response.headers); + debugPrint( + '[API][RESPONSE] ${response.statusCode} ${response.request?.method} ${response.request?.url}', + ); + debugPrint('[API][RESPONSE][HEADERS] $safeHeaders'); + if (responseBody.isNotEmpty) { + debugPrint('[API][RESPONSE][BODY] $responseBody'); + } + } + + Map _sanitizeHeaders(Map headers) { + final safeHeaders = Map.from(headers); + const sensitiveKeys = { + 'authorization', + 'x-api-key', + 'api-key', + 'x-signature', + 'cookie', + 'set-cookie', + }; + + for (final key in safeHeaders.keys.toList()) { + if (sensitiveKeys.contains(key.toLowerCase())) { + safeHeaders[key] = '***'; + } + } + + return safeHeaders; + } + + String _formatBodyForLogging({ + required List responseBytes, + required String? contentType, + }) { + final normalizedType = contentType?.toLowerCase() ?? ''; + final isBinary = + normalizedType.contains('application/pdf') || + normalizedType.contains('application/octet-stream') || + normalizedType.startsWith('image/') || + normalizedType.startsWith('audio/') || + normalizedType.startsWith('video/'); + + if (isBinary) { + return ''; + } + + final decoded = utf8.decode(responseBytes, allowMalformed: true).trim(); + if (decoded.isEmpty) { + return ''; + } + + const maxChars = 4000; + if (decoded.length <= maxChars) { + return decoded; + } + return '${decoded.substring(0, maxChars)}…'; + } +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 3d13ea8..fde33bb 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; - -class AppColors { - const AppColors._(); - - static const Color primary = Color(0xFF2A60AF); -} +import 'package:flutter/material.dart'; + +class AppColors { + const AppColors._(); + + static const Color primary = Color(0xFF2A60AF); +} diff --git a/lib/data/repositories/api_auth_repository.dart b/lib/data/repositories/api_auth_repository.dart index 96bc275..fef17fa 100644 --- a/lib/data/repositories/api_auth_repository.dart +++ b/lib/data/repositories/api_auth_repository.dart @@ -1,242 +1,242 @@ -import 'dart:convert'; - -import 'package:e_receipt_mobile/domain/entities/login_user.dart'; -import 'package:e_receipt_mobile/domain/exceptions/auth_exceptions.dart'; -import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; -import 'package:http/http.dart' as http; - -class ApiAuthRepository implements AuthRepository { - ApiAuthRepository({ - required this.baseUrl, - required this.apiSecret, - http.Client? client, - }) : _client = client ?? http.Client(); - - final String baseUrl; - final String apiSecret; - final http.Client _client; - - @override - Future login({ - required String username, - required String password, - }) async { - final uri = Uri.parse('$baseUrl/auth/login'); - final requestBody = jsonEncode({ - 'username': username.trim().toLowerCase(), - 'password': password, - }); - - final response = await _client.post( - uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - }, - body: requestBody, - ); - - if (response.statusCode >= 200 && response.statusCode < 300) { - return _parseUserFromResponse( - response.body, - fallbackUsername: username.trim(), - ); - } - - final data = _asMap(response.body); - final payload = _extractPayload(data); - final require = - _pickString(data, const ['require', 'required']) ?? - _pickString(payload, const ['require', 'required']); - if (require == 'PASSWORD_UPDATE_REQUIRED') { - final userId = _extractUserId(data); - if (userId != null) { - throw PasswordUpdateRequiredException( - userId: userId, - message: _extractErrorMessage(response.body), - ); - } - } - - throw Exception(_extractErrorMessage(response.body)); - } - - @override - Future refreshSession({ - required String username, - required String refreshToken, - required String role, - }) async { - final uri = Uri.parse('$baseUrl/auth/refresh-token'); - final requestBody = jsonEncode({ - 'refreshToken': refreshToken, - }); - - final response = await _client.post( - uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - }, - body: requestBody, - ); - - if (response.statusCode >= 200 && response.statusCode < 300) { - return _parseUserFromResponse( - response.body, - fallbackUsername: username, - fallbackRefreshToken: refreshToken, - fallbackRole: role, - ); - } - - throw Exception(_extractErrorMessage(response.body)); - } - - @override - Future resetPassword({ - required String userId, - required String password, - }) async { - final uri = Uri.parse('$baseUrl/auth/reset-password'); - final requestBody = jsonEncode({ - 'userId': userId, - 'password': password, - }); - - final response = await _client.put( - uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - }, - body: requestBody, - ); - - if (response.statusCode >= 200 && response.statusCode < 300) { - return; - } - - throw Exception(_extractErrorMessage(response.body)); - } - - @override - Future logout({required String refreshToken}) async { - final uri = Uri.parse('$baseUrl/auth/logout'); - final requestBody = jsonEncode({ - 'refreshToken': refreshToken, - }); - - final response = await _client.post( - uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - }, - body: requestBody, - ); - - if (response.statusCode >= 200 && response.statusCode < 300) { - return; - } - - throw Exception(_extractErrorMessage(response.body)); - } - - Map _asMap(String body) { - try { - final decoded = jsonDecode(body); - if (decoded is Map) { - return decoded; - } - return {}; - } catch (_) { - return {}; - } - } - - String _extractErrorMessage(String body) { - final data = _asMap(body); - final message = data['message'] ?? data['error'] ?? data['detail']; - if (message == null) { - return 'Login failed'; - } - return message.toString(); - } - - String? _extractUserId(Map data) { - final rootUser = data['user']; - if (rootUser is Map) { - final id = _pickString(rootUser, const ['id', 'userId', 'user_id']); - if (id != null) { - return id; - } - } - - final payload = _extractPayload(data); - final payloadUser = payload['user']; - if (payloadUser is Map) { - return _pickString(payloadUser, const ['id', 'userId', 'user_id']); - } - - return _pickString(payload, const ['userId', 'user_id', 'id']); - } - - LoginUser _parseUserFromResponse( - String body, { - required String fallbackUsername, - String? fallbackRefreshToken, - String? fallbackRole, - }) { - final data = _asMap(body); - final payload = _extractPayload(data); - final apiUsername = _pickString(payload, const ['username', 'email']); - final token = _pickString(payload, const ['token', 'accessToken']); - final refreshToken = _pickString( - payload, - const ['refreshToken', 'refresh_token'], - ); - final role = _pickString(payload, const ['role']); - - if (token == null) { - throw Exception('Auth response is missing token'); - } - - final effectiveRefreshToken = refreshToken ?? fallbackRefreshToken; - if (effectiveRefreshToken == null) { - throw Exception('Auth response is missing refresh token'); - } - - final effectiveRole = role ?? fallbackRole; - if (effectiveRole == null || effectiveRole.trim().isEmpty) { - throw Exception('Auth response is missing role'); - } - - return LoginUser( - username: apiUsername?.trim().isNotEmpty == true - ? apiUsername!.trim() - : fallbackUsername.trim(), - token: token, - refreshToken: effectiveRefreshToken, - role: effectiveRole, - ); - } - - Map _extractPayload(Map data) { - final dynamic nested = data['data'] ?? data['result'] ?? data['payload']; - if (nested is Map) { - return nested; - } - return data; - } - - String? _pickString(Map data, List keys) { - for (final key in keys) { - final value = data[key]; - if (value != null && value.toString().trim().isNotEmpty) { - return value.toString().trim(); - } - } - return null; - } -} +import 'dart:convert'; + +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:e_receipt_mobile/domain/exceptions/auth_exceptions.dart'; +import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; +import 'package:http/http.dart' as http; + +class ApiAuthRepository implements AuthRepository { + ApiAuthRepository({ + required this.baseUrl, + required this.apiSecret, + http.Client? client, + }) : _client = client ?? http.Client(); + + final String baseUrl; + final String apiSecret; + final http.Client _client; + + @override + Future login({ + required String username, + required String password, + }) async { + final uri = Uri.parse('$baseUrl/auth/login'); + final requestBody = jsonEncode({ + 'username': username.trim().toLowerCase(), + 'password': password, + }); + + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + }, + body: requestBody, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return _parseUserFromResponse( + response.body, + fallbackUsername: username.trim(), + ); + } + + final data = _asMap(response.body); + final payload = _extractPayload(data); + final require = + _pickString(data, const ['require', 'required']) ?? + _pickString(payload, const ['require', 'required']); + if (require == 'PASSWORD_UPDATE_REQUIRED') { + final userId = _extractUserId(data); + if (userId != null) { + throw PasswordUpdateRequiredException( + userId: userId, + message: _extractErrorMessage(response.body), + ); + } + } + + throw Exception(_extractErrorMessage(response.body)); + } + + @override + Future refreshSession({ + required String username, + required String refreshToken, + required String role, + }) async { + final uri = Uri.parse('$baseUrl/auth/refresh-token'); + final requestBody = jsonEncode({ + 'refreshToken': refreshToken, + }); + + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + }, + body: requestBody, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return _parseUserFromResponse( + response.body, + fallbackUsername: username, + fallbackRefreshToken: refreshToken, + fallbackRole: role, + ); + } + + throw Exception(_extractErrorMessage(response.body)); + } + + @override + Future resetPassword({ + required String userId, + required String password, + }) async { + final uri = Uri.parse('$baseUrl/auth/reset-password'); + final requestBody = jsonEncode({ + 'userId': userId, + 'password': password, + }); + + final response = await _client.put( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + }, + body: requestBody, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return; + } + + throw Exception(_extractErrorMessage(response.body)); + } + + @override + Future logout({required String refreshToken}) async { + final uri = Uri.parse('$baseUrl/auth/logout'); + final requestBody = jsonEncode({ + 'refreshToken': refreshToken, + }); + + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + }, + body: requestBody, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return; + } + + throw Exception(_extractErrorMessage(response.body)); + } + + Map _asMap(String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + return decoded; + } + return {}; + } catch (_) { + return {}; + } + } + + String _extractErrorMessage(String body) { + final data = _asMap(body); + final message = data['message'] ?? data['error'] ?? data['detail']; + if (message == null) { + return 'Login failed'; + } + return message.toString(); + } + + String? _extractUserId(Map data) { + final rootUser = data['user']; + if (rootUser is Map) { + final id = _pickString(rootUser, const ['id', 'userId', 'user_id']); + if (id != null) { + return id; + } + } + + final payload = _extractPayload(data); + final payloadUser = payload['user']; + if (payloadUser is Map) { + return _pickString(payloadUser, const ['id', 'userId', 'user_id']); + } + + return _pickString(payload, const ['userId', 'user_id', 'id']); + } + + LoginUser _parseUserFromResponse( + String body, { + required String fallbackUsername, + String? fallbackRefreshToken, + String? fallbackRole, + }) { + final data = _asMap(body); + final payload = _extractPayload(data); + final apiUsername = _pickString(payload, const ['username', 'email']); + final token = _pickString(payload, const ['token', 'accessToken']); + final refreshToken = _pickString( + payload, + const ['refreshToken', 'refresh_token'], + ); + final role = _pickString(payload, const ['role']); + + if (token == null) { + throw Exception('Auth response is missing token'); + } + + final effectiveRefreshToken = refreshToken ?? fallbackRefreshToken; + if (effectiveRefreshToken == null) { + throw Exception('Auth response is missing refresh token'); + } + + final effectiveRole = role ?? fallbackRole; + if (effectiveRole == null || effectiveRole.trim().isEmpty) { + throw Exception('Auth response is missing role'); + } + + return LoginUser( + username: apiUsername?.trim().isNotEmpty == true + ? apiUsername!.trim() + : fallbackUsername.trim(), + token: token, + refreshToken: effectiveRefreshToken, + role: effectiveRole, + ); + } + + Map _extractPayload(Map data) { + final dynamic nested = data['data'] ?? data['result'] ?? data['payload']; + if (nested is Map) { + return nested; + } + return data; + } + + String? _pickString(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString().trim(); + } + } + return null; + } +} diff --git a/lib/data/repositories/api_merchant_repository.dart b/lib/data/repositories/api_merchant_repository.dart index 5582d9b..149ebaa 100644 --- a/lib/data/repositories/api_merchant_repository.dart +++ b/lib/data/repositories/api_merchant_repository.dart @@ -1,270 +1,270 @@ -import 'dart:convert'; - -import 'package:e_receipt_mobile/domain/entities/merchant.dart'; -import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart'; -import 'package:e_receipt_mobile/domain/entities/terminal.dart'; -import 'package:http/http.dart' as http; - -class ApiMerchantRepository implements MerchantRepository { - ApiMerchantRepository({ - required this.baseUrl, - required this.apiSecret, - required http.Client client, - }) : _client = client; - - final String baseUrl; - final String apiSecret; - final http.Client _client; - - @override - Future> getMerchants({ - required String token, - int page = 1, - int limit = 10, - String searchTerm = '', - }) async { - final queryParameters = { - 'page': page.toString(), - 'limit': limit.toString(), - if (searchTerm.trim().isNotEmpty) 'searchTerm': searchTerm.trim(), - }; - final uri = Uri.parse( - '$baseUrl/merchant', - ).replace(queryParameters: queryParameters); - final response = await _client.get( - uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - 'Authorization': 'Bearer ${token.trim()}', - }, - ); - - if (response.statusCode >= 200 && response.statusCode < 300) { - return _parseMerchants(response.body); - } - - throw Exception(_extractErrorMessage(response.body)); - } - - @override - Future> getTerminalsByMerchantId({ - required String token, - required String merchantId, - }) async { - final uri = Uri.parse('$baseUrl/terminal/list/$merchantId'); - final response = await _client.get( - uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - 'Authorization': 'Bearer ${token.trim()}', - }, - ); - - if (response.statusCode >= 200 && response.statusCode < 300) { - return _parseTerminals(response.body); - } - - throw Exception(_extractErrorMessage(response.body)); - } - - List _parseMerchants(String body) { - final decoded = _tryDecode(body); - final list = _extractList(decoded); - return list.map(_merchantFromItem).whereType().toList(); - } - - List _parseTerminals(String body) { - final decoded = _tryDecode(body); - final list = _extractTerminalList(decoded); - return list.map(_terminalFromItem).whereType().toList(); - } - - dynamic _tryDecode(String body) { - try { - return jsonDecode(body); - } catch (_) { - return null; - } - } - - List _extractList(dynamic decoded) { - if (decoded is List) { - return decoded; - } - if (decoded is Map) { - final directMerchants = decoded['merchants']; - if (directMerchants is List) { - return directMerchants; - } - - final data = - decoded['data'] ?? - decoded['result'] ?? - decoded['payload'] ?? - decoded['merchantData']; - if (data is List) { - return data; - } - if (data is Map) { - final nestedMerchants = data['merchants']; - if (nestedMerchants is List) { - return nestedMerchants; - } - } - } - return const []; - } - - List _extractTerminalList(dynamic decoded) { - if (decoded is List) { - return decoded; - } - if (decoded is Map) { - final directTerminals = decoded['terminals']; - if (directTerminals is List) { - return directTerminals; - } - - final data = - decoded['data'] ?? - decoded['result'] ?? - decoded['payload'] ?? - decoded['terminalData']; - if (data is List) { - return data; - } - if (data is Map) { - final nestedTerminals = data['terminals']; - if (nestedTerminals is List) { - return nestedTerminals; - } - } - } - return const []; - } - - Merchant? _merchantFromItem(dynamic item) { - if (item is! Map) { - return null; - } - - return Merchant( - id: _pickString(item, const ['id', 'merchantId', 'merchant_id']), - name: _pickString(item, const ['name', 'merchantName', 'merchant_name']), - mobile: _pickString(item, const ['mobile']), - description: _pickString(item, const ['description']), - address: _pickString(item, const ['address']), - address2: _pickString(item, const ['address2']), - address3: _pickString(item, const ['address3']), - phone: _pickString(item, const ['phone']), - mids: _pickString(item, const ['mids']), - createdBy: _pickString(item, const ['createdBy', 'created_by']), - updatedBy: _pickString(item, const ['updatedBy', 'updated_by']), - createdAt: _pickString(item, const ['createdAt', 'created_at']), - updatedAt: _pickString(item, const ['updatedAt', 'updated_at']), - ); - } - - Terminal? _terminalFromItem(dynamic item) { - if (item is! Map) { - return null; - } - - return Terminal( - id: _pickString(item, const ['id', 'terminalId', 'terminal_id']), - serial: _pickString(item, const ['serial']), - address: _pickString(item, const ['address']), - address2: _pickString(item, const ['address2']), - address3: _pickString(item, const ['address3']), - name: _pickString(item, const ['name', 'terminalName', 'terminal_name']), - appId: _pickString(item, const ['appId', 'app_id']), - tid: _pickString(item, const ['tid', 'terminalNo', 'terminal_no']), - mids: _pickStringList(item, const ['mid', 'mids']), - merchantId: _pickString(item, const ['merchantId', 'merchant_id']), - status: _pickString(item, const ['status']), - totalTransactions: _pickInt(item, const [ - 'totalTransactions', - 'total_transactions', - ]), - totalAmount: _pickNum(item, const ['totalAmount', 'total_amount']), - createdAt: _pickString(item, const ['createdAt', 'created_at']), - updatedAt: _pickString(item, const ['updatedAt', 'updated_at']), - ); - } - - String? _pickString(Map data, List keys) { - for (final key in keys) { - final value = data[key]; - if (value != null && value.toString().trim().isNotEmpty) { - return value.toString().trim(); - } - } - return null; - } - - List? _pickStringList(Map data, List keys) { - for (final key in keys) { - final value = data[key]; - if (value is List) { - final results = value - .where((e) => e != null && e.toString().trim().isNotEmpty) - .map((e) => e.toString().trim()) - .toList(); - return results.isEmpty ? null : results; - } - if (value != null && value.toString().trim().isNotEmpty) { - return [value.toString().trim()]; - } - } - return null; - } - - int? _pickInt(Map data, List keys) { - for (final key in keys) { - final value = data[key]; - if (value is int) { - return value; - } - if (value is num) { - return value.toInt(); - } - if (value != null) { - final parsed = int.tryParse(value.toString().trim()); - if (parsed != null) { - return parsed; - } - } - } - return null; - } - - num? _pickNum(Map data, List keys) { - for (final key in keys) { - final value = data[key]; - if (value is num) { - return value; - } - if (value != null) { - final parsed = num.tryParse(value.toString().trim()); - if (parsed != null) { - return parsed; - } - } - } - return null; - } - - String _extractErrorMessage(String body) { - final decoded = _tryDecode(body); - if (decoded is Map) { - final message = - decoded['message'] ?? decoded['error'] ?? decoded['detail']; - if (message != null) { - return message.toString(); - } - } - return 'Failed to load merchants'; - } -} +import 'dart:convert'; + +import 'package:e_receipt_mobile/domain/entities/merchant.dart'; +import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart'; +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:http/http.dart' as http; + +class ApiMerchantRepository implements MerchantRepository { + ApiMerchantRepository({ + required this.baseUrl, + required this.apiSecret, + required http.Client client, + }) : _client = client; + + final String baseUrl; + final String apiSecret; + final http.Client _client; + + @override + Future> getMerchants({ + required String token, + int page = 1, + int limit = 10, + String searchTerm = '', + }) async { + final queryParameters = { + 'page': page.toString(), + 'limit': limit.toString(), + if (searchTerm.trim().isNotEmpty) 'searchTerm': searchTerm.trim(), + }; + final uri = Uri.parse( + '$baseUrl/merchant', + ).replace(queryParameters: queryParameters); + final response = await _client.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + 'Authorization': 'Bearer ${token.trim()}', + }, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return _parseMerchants(response.body); + } + + throw Exception(_extractErrorMessage(response.body)); + } + + @override + Future> getTerminalsByMerchantId({ + required String token, + required String merchantId, + }) async { + final uri = Uri.parse('$baseUrl/terminal/list/$merchantId'); + final response = await _client.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + 'Authorization': 'Bearer ${token.trim()}', + }, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return _parseTerminals(response.body); + } + + throw Exception(_extractErrorMessage(response.body)); + } + + List _parseMerchants(String body) { + final decoded = _tryDecode(body); + final list = _extractList(decoded); + return list.map(_merchantFromItem).whereType().toList(); + } + + List _parseTerminals(String body) { + final decoded = _tryDecode(body); + final list = _extractTerminalList(decoded); + return list.map(_terminalFromItem).whereType().toList(); + } + + dynamic _tryDecode(String body) { + try { + return jsonDecode(body); + } catch (_) { + return null; + } + } + + List _extractList(dynamic decoded) { + if (decoded is List) { + return decoded; + } + if (decoded is Map) { + final directMerchants = decoded['merchants']; + if (directMerchants is List) { + return directMerchants; + } + + final data = + decoded['data'] ?? + decoded['result'] ?? + decoded['payload'] ?? + decoded['merchantData']; + if (data is List) { + return data; + } + if (data is Map) { + final nestedMerchants = data['merchants']; + if (nestedMerchants is List) { + return nestedMerchants; + } + } + } + return const []; + } + + List _extractTerminalList(dynamic decoded) { + if (decoded is List) { + return decoded; + } + if (decoded is Map) { + final directTerminals = decoded['terminals']; + if (directTerminals is List) { + return directTerminals; + } + + final data = + decoded['data'] ?? + decoded['result'] ?? + decoded['payload'] ?? + decoded['terminalData']; + if (data is List) { + return data; + } + if (data is Map) { + final nestedTerminals = data['terminals']; + if (nestedTerminals is List) { + return nestedTerminals; + } + } + } + return const []; + } + + Merchant? _merchantFromItem(dynamic item) { + if (item is! Map) { + return null; + } + + return Merchant( + id: _pickString(item, const ['id', 'merchantId', 'merchant_id']), + name: _pickString(item, const ['name', 'merchantName', 'merchant_name']), + mobile: _pickString(item, const ['mobile']), + description: _pickString(item, const ['description']), + address: _pickString(item, const ['address']), + address2: _pickString(item, const ['address2']), + address3: _pickString(item, const ['address3']), + phone: _pickString(item, const ['phone']), + mids: _pickString(item, const ['mids']), + createdBy: _pickString(item, const ['createdBy', 'created_by']), + updatedBy: _pickString(item, const ['updatedBy', 'updated_by']), + createdAt: _pickString(item, const ['createdAt', 'created_at']), + updatedAt: _pickString(item, const ['updatedAt', 'updated_at']), + ); + } + + Terminal? _terminalFromItem(dynamic item) { + if (item is! Map) { + return null; + } + + return Terminal( + id: _pickString(item, const ['id', 'terminalId', 'terminal_id']), + serial: _pickString(item, const ['serial']), + address: _pickString(item, const ['address']), + address2: _pickString(item, const ['address2']), + address3: _pickString(item, const ['address3']), + name: _pickString(item, const ['name', 'terminalName', 'terminal_name']), + appId: _pickString(item, const ['appId', 'app_id']), + tid: _pickString(item, const ['tid', 'terminalNo', 'terminal_no']), + mids: _pickStringList(item, const ['mid', 'mids']), + merchantId: _pickString(item, const ['merchantId', 'merchant_id']), + status: _pickString(item, const ['status']), + totalTransactions: _pickInt(item, const [ + 'totalTransactions', + 'total_transactions', + ]), + totalAmount: _pickNum(item, const ['totalAmount', 'total_amount']), + createdAt: _pickString(item, const ['createdAt', 'created_at']), + updatedAt: _pickString(item, const ['updatedAt', 'updated_at']), + ); + } + + String? _pickString(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString().trim(); + } + } + return null; + } + + List? _pickStringList(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is List) { + final results = value + .where((e) => e != null && e.toString().trim().isNotEmpty) + .map((e) => e.toString().trim()) + .toList(); + return results.isEmpty ? null : results; + } + if (value != null && value.toString().trim().isNotEmpty) { + return [value.toString().trim()]; + } + } + return null; + } + + int? _pickInt(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + if (value != null) { + final parsed = int.tryParse(value.toString().trim()); + if (parsed != null) { + return parsed; + } + } + } + return null; + } + + num? _pickNum(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is num) { + return value; + } + if (value != null) { + final parsed = num.tryParse(value.toString().trim()); + if (parsed != null) { + return parsed; + } + } + } + return null; + } + + String _extractErrorMessage(String body) { + final decoded = _tryDecode(body); + if (decoded is Map) { + final message = + decoded['message'] ?? decoded['error'] ?? decoded['detail']; + if (message != null) { + return message.toString(); + } + } + return 'Failed to load merchants'; + } +} diff --git a/lib/data/repositories/api_receipt_repository.dart b/lib/data/repositories/api_receipt_repository.dart index 0bf8a86..95b3c72 100644 --- a/lib/data/repositories/api_receipt_repository.dart +++ b/lib/data/repositories/api_receipt_repository.dart @@ -1,139 +1,139 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; -import 'package:e_receipt_mobile/domain/repositories/receipt_repository.dart'; -import 'package:http/http.dart' as http; - -class ApiReceiptRepository implements ReceiptRepository { - ApiReceiptRepository({ - required this.baseUrl, - required this.apiSecret, - required http.Client client, - }) : _client = client; - - final String baseUrl; - final String apiSecret; - final http.Client _client; - - @override - Future getTransactionReceipt({ - required String token, - required String transactionId, - required String copyFor, - }) async { - final uri = _normalizeLocalhostForAndroid( - Uri.parse('$baseUrl/transaction/pdf').replace( - queryParameters: { - 'transactionId': transactionId, - 'copyFor': copyFor, - }, - ), - ); - - final request = http.Request('GET', uri); - request.headers.addAll({ - 'Accept': 'application/pdf,text/html', - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', - }); - - final streamed = await _client.send(request); - final bytes = await streamed.stream.toBytes(); - final contentType = streamed.headers['content-type']?.trim() ?? ''; - - if (streamed.statusCode < 200 || streamed.statusCode >= 300) { - final body = const Utf8Decoder( - allowMalformed: true, - ).convert(bytes).trim(); - throw Exception( - body.isNotEmpty && body.length <= 500 - ? body - : 'Failed to load receipt (${streamed.statusCode}) $contentType', - ); - } - - if (_looksLikePdf(bytes: bytes, contentType: contentType)) { - return ReceiptPdfContent(bytes); - } - - final html = const Utf8Decoder(allowMalformed: true).convert(bytes); - return ReceiptHtmlContent(html: html, contentType: contentType); - } - - @override - Future getTransactionReceiptPdfBytes({ - required String token, - required String transactionId, - required String copyFor, - }) async { - final uri = _normalizeLocalhostForAndroid( - Uri.parse('$baseUrl/transaction/pdf').replace( - queryParameters: { - 'transactionId': transactionId, - 'copyFor': copyFor, - }, - ), - ); - - final request = http.Request('GET', uri); - request.headers.addAll({ - 'Accept': 'application/pdf', - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', - }); - - final streamed = await _client.send(request); - final bytes = await streamed.stream.toBytes(); - final contentType = streamed.headers['content-type']?.trim() ?? ''; - - if (streamed.statusCode < 200 || streamed.statusCode >= 300) { - final body = const Utf8Decoder( - allowMalformed: true, - ).convert(bytes).trim(); - throw Exception( - body.isNotEmpty && body.length <= 500 - ? body - : 'Failed to load receipt PDF (${streamed.statusCode}) $contentType', - ); - } - - if (_looksLikePdf(bytes: bytes, contentType: contentType)) { - return bytes; - } - - final body = const Utf8Decoder(allowMalformed: true).convert(bytes).trim(); - throw Exception( - body.isNotEmpty && body.length <= 500 - ? body - : 'Server did not return a PDF ($contentType)', - ); - } - - Uri _normalizeLocalhostForAndroid(Uri uri) { - if (!Platform.isAndroid) { - return uri; - } - if (uri.host != 'localhost') { - return uri; - } - return uri.replace(host: '10.0.2.2'); - } - - bool _looksLikePdf({required List bytes, required String contentType}) { - final type = contentType.toLowerCase(); - if (type.contains('application/pdf')) { - return true; - } - if (bytes.length < 4) { - return false; - } - return bytes[0] == 0x25 && // % - bytes[1] == 0x50 && // P - bytes[2] == 0x44 && // D - bytes[3] == 0x46; // F - } -} +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; +import 'package:e_receipt_mobile/domain/repositories/receipt_repository.dart'; +import 'package:http/http.dart' as http; + +class ApiReceiptRepository implements ReceiptRepository { + ApiReceiptRepository({ + required this.baseUrl, + required this.apiSecret, + required http.Client client, + }) : _client = client; + + final String baseUrl; + final String apiSecret; + final http.Client _client; + + @override + Future getTransactionReceipt({ + required String token, + required String transactionId, + required String copyFor, + }) async { + final uri = _normalizeLocalhostForAndroid( + Uri.parse('$baseUrl/transaction/pdf').replace( + queryParameters: { + 'transactionId': transactionId, + 'copyFor': copyFor, + }, + ), + ); + + final request = http.Request('GET', uri); + request.headers.addAll({ + 'Accept': 'application/pdf,text/html', + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', + }); + + final streamed = await _client.send(request); + final bytes = await streamed.stream.toBytes(); + final contentType = streamed.headers['content-type']?.trim() ?? ''; + + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + final body = const Utf8Decoder( + allowMalformed: true, + ).convert(bytes).trim(); + throw Exception( + body.isNotEmpty && body.length <= 500 + ? body + : 'Failed to load receipt (${streamed.statusCode}) $contentType', + ); + } + + if (_looksLikePdf(bytes: bytes, contentType: contentType)) { + return ReceiptPdfContent(bytes); + } + + final html = const Utf8Decoder(allowMalformed: true).convert(bytes); + return ReceiptHtmlContent(html: html, contentType: contentType); + } + + @override + Future getTransactionReceiptPdfBytes({ + required String token, + required String transactionId, + required String copyFor, + }) async { + final uri = _normalizeLocalhostForAndroid( + Uri.parse('$baseUrl/transaction/pdf').replace( + queryParameters: { + 'transactionId': transactionId, + 'copyFor': copyFor, + }, + ), + ); + + final request = http.Request('GET', uri); + request.headers.addAll({ + 'Accept': 'application/pdf', + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', + }); + + final streamed = await _client.send(request); + final bytes = await streamed.stream.toBytes(); + final contentType = streamed.headers['content-type']?.trim() ?? ''; + + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + final body = const Utf8Decoder( + allowMalformed: true, + ).convert(bytes).trim(); + throw Exception( + body.isNotEmpty && body.length <= 500 + ? body + : 'Failed to load receipt PDF (${streamed.statusCode}) $contentType', + ); + } + + if (_looksLikePdf(bytes: bytes, contentType: contentType)) { + return bytes; + } + + final body = const Utf8Decoder(allowMalformed: true).convert(bytes).trim(); + throw Exception( + body.isNotEmpty && body.length <= 500 + ? body + : 'Server did not return a PDF ($contentType)', + ); + } + + Uri _normalizeLocalhostForAndroid(Uri uri) { + if (!Platform.isAndroid) { + return uri; + } + if (uri.host != 'localhost') { + return uri; + } + return uri.replace(host: '10.0.2.2'); + } + + bool _looksLikePdf({required List bytes, required String contentType}) { + final type = contentType.toLowerCase(); + if (type.contains('application/pdf')) { + return true; + } + if (bytes.length < 4) { + return false; + } + return bytes[0] == 0x25 && // % + bytes[1] == 0x50 && // P + bytes[2] == 0x44 && // D + bytes[3] == 0x46; // F + } +} diff --git a/lib/data/repositories/api_transaction_repository.dart b/lib/data/repositories/api_transaction_repository.dart index bd945bd..d63e93b 100644 --- a/lib/data/repositories/api_transaction_repository.dart +++ b/lib/data/repositories/api_transaction_repository.dart @@ -1,148 +1,148 @@ -import 'dart:convert'; - -import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; -import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart'; -import 'package:http/http.dart' as http; - -class ApiTransactionRepository implements TransactionRepository { - ApiTransactionRepository({ - required this.baseUrl, - required this.apiSecret, - required http.Client client, - }) : _client = client; - - final String baseUrl; - final String apiSecret; - final http.Client _client; - - @override - Future> getTransactions({ - required String token, - required String serial, - required String range, - int page = 1, - int limit = 8, - String searchTerm = '', - String sort = '', - }) async { - final uri = Uri.parse('$baseUrl/transaction').replace( - queryParameters: { - 'page': page.toString(), - 'limit': limit.toString(), - 'serial': serial, - 'range': range, - if (searchTerm.trim().isNotEmpty) 'search': searchTerm.trim(), - if (sort.trim().isNotEmpty) 'sort': sort.trim(), - }, - ); - - final response = await _client.get( - uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiSecret, - 'Authorization': 'Bearer ${token.trim()}', - }, - ); - - if (response.statusCode >= 200 && response.statusCode < 300) { - return _parseTransactions(response.body); - } - - throw Exception(_extractErrorMessage(response.body)); - } - - List _parseTransactions(String body) { - final decoded = _tryDecode(body); - final list = _extractTransactionList(decoded); - return list - .map((item) => _transactionFromItem(item)) - .whereType() - .toList(); - } - - dynamic _tryDecode(String body) { - try { - return jsonDecode(body); - } catch (_) { - return null; - } - } - - List _extractTransactionList(dynamic decoded) { - if (decoded is List) { - return decoded; - } - if (decoded is Map) { - final direct = decoded['transactions']; - if (direct is List) { - return direct; - } - - final data = decoded['data'] ?? decoded['result'] ?? decoded['payload']; - if (data is List) { - return data; - } - if (data is Map) { - final nested = data['transactions']; - if (nested is List) { - return nested; - } - } - } - return const []; - } - - TransactionRecord? _transactionFromItem(dynamic item) { - if (item is! Map) { - return null; - } - - return TransactionRecord( - id: _pickString(item, const ['id', 'transactionId', 'transaction_id']), - rrn: _pickString(item, const ['rrn', 'referenceNo', 'reference_no', 'DE37']), - amount: _pickNum(item, const ['amount', 'totalAmount', 'total_amount', 'DE4']), - status: _pickString(item, const ['status', 'description', 'DE39']), - type: _pickString(item, const ['type', 'transactionType', 'DE3']), - createdAt: _pickString(item, const ['createdAt', 'created_at', 'CREATED_AT']), - raw: item, - ); - } - - String? _pickString(Map data, List keys) { - for (final key in keys) { - final value = data[key]; - if (value != null && value.toString().trim().isNotEmpty) { - return value.toString().trim(); - } - } - return null; - } - - num? _pickNum(Map data, List keys) { - for (final key in keys) { - final value = data[key]; - if (value is num) { - return value; - } - if (value != null) { - final parsed = num.tryParse(value.toString().trim()); - if (parsed != null) { - return parsed; - } - } - } - return null; - } - - String _extractErrorMessage(String body) { - final decoded = _tryDecode(body); - if (decoded is Map) { - final message = decoded['message'] ?? decoded['error'] ?? decoded['detail']; - if (message != null) { - return message.toString(); - } - } - return 'Failed to load transactions'; - } -} +import 'dart:convert'; + +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart'; +import 'package:http/http.dart' as http; + +class ApiTransactionRepository implements TransactionRepository { + ApiTransactionRepository({ + required this.baseUrl, + required this.apiSecret, + required http.Client client, + }) : _client = client; + + final String baseUrl; + final String apiSecret; + final http.Client _client; + + @override + Future> getTransactions({ + required String token, + required String serial, + required String range, + int page = 1, + int limit = 8, + String searchTerm = '', + String sort = '', + }) async { + final uri = Uri.parse('$baseUrl/transaction').replace( + queryParameters: { + 'page': page.toString(), + 'limit': limit.toString(), + 'serial': serial, + 'range': range, + if (searchTerm.trim().isNotEmpty) 'search': searchTerm.trim(), + if (sort.trim().isNotEmpty) 'sort': sort.trim(), + }, + ); + + final response = await _client.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + 'Authorization': 'Bearer ${token.trim()}', + }, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return _parseTransactions(response.body); + } + + throw Exception(_extractErrorMessage(response.body)); + } + + List _parseTransactions(String body) { + final decoded = _tryDecode(body); + final list = _extractTransactionList(decoded); + return list + .map((item) => _transactionFromItem(item)) + .whereType() + .toList(); + } + + dynamic _tryDecode(String body) { + try { + return jsonDecode(body); + } catch (_) { + return null; + } + } + + List _extractTransactionList(dynamic decoded) { + if (decoded is List) { + return decoded; + } + if (decoded is Map) { + final direct = decoded['transactions']; + if (direct is List) { + return direct; + } + + final data = decoded['data'] ?? decoded['result'] ?? decoded['payload']; + if (data is List) { + return data; + } + if (data is Map) { + final nested = data['transactions']; + if (nested is List) { + return nested; + } + } + } + return const []; + } + + TransactionRecord? _transactionFromItem(dynamic item) { + if (item is! Map) { + return null; + } + + return TransactionRecord( + id: _pickString(item, const ['id', 'transactionId', 'transaction_id']), + rrn: _pickString(item, const ['rrn', 'referenceNo', 'reference_no', 'DE37']), + amount: _pickNum(item, const ['amount', 'totalAmount', 'total_amount', 'DE4']), + status: _pickString(item, const ['status', 'description', 'DE39']), + type: _pickString(item, const ['type', 'transactionType', 'DE3']), + createdAt: _pickString(item, const ['createdAt', 'created_at', 'CREATED_AT']), + raw: item, + ); + } + + String? _pickString(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString().trim(); + } + } + return null; + } + + num? _pickNum(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is num) { + return value; + } + if (value != null) { + final parsed = num.tryParse(value.toString().trim()); + if (parsed != null) { + return parsed; + } + } + } + return null; + } + + String _extractErrorMessage(String body) { + final decoded = _tryDecode(body); + if (decoded is Map) { + final message = decoded['message'] ?? decoded['error'] ?? decoded['detail']; + if (message != null) { + return message.toString(); + } + } + return 'Failed to load transactions'; + } +} diff --git a/lib/data/repositories/mock_auth_repository.dart b/lib/data/repositories/mock_auth_repository.dart index 3ed6875..045f135 100644 --- a/lib/data/repositories/mock_auth_repository.dart +++ b/lib/data/repositories/mock_auth_repository.dart @@ -1,70 +1,70 @@ -import 'package:e_receipt_mobile/domain/entities/login_user.dart'; -import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; - -class MockAuthRepository implements AuthRepository { - @override - Future login({ - required String username, - required String password, - }) async { - await Future.delayed(const Duration(milliseconds: 800)); - - if (username.trim().isEmpty || password.isEmpty) { - throw Exception('Username and password are required.'); - } - - if (password.length < 6) { - throw Exception('Password must be at least 6 characters.'); - } - - return LoginUser( - username: username.trim(), - token: 'mock-access-token', - refreshToken: 'mock-refresh-token', - role: username.trim().toLowerCase() == 'admin' ? 'admin' : 'user', - ); - } - - @override - Future refreshSession({ - required String username, - required String refreshToken, - required String role, - }) async { - await Future.delayed(const Duration(milliseconds: 300)); - - if (refreshToken.trim().isEmpty) { - throw Exception('Refresh token is required.'); - } - - return LoginUser( - username: username.trim(), - token: 'mock-access-token-refreshed', - refreshToken: 'mock-refresh-token-refreshed', - role: role, - ); - } - - @override - Future resetPassword({ - required String userId, - required String password, - }) async { - await Future.delayed(const Duration(milliseconds: 500)); - - if (userId.trim().isEmpty) { - throw Exception('User ID is required.'); - } - if (password.length < 8) { - throw Exception('Password must be at least 8 characters.'); - } - } - - @override - Future logout({required String refreshToken}) async { - await Future.delayed(const Duration(milliseconds: 250)); - if (refreshToken.trim().isEmpty) { - throw Exception('Refresh token is required.'); - } - } -} +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; + +class MockAuthRepository implements AuthRepository { + @override + Future login({ + required String username, + required String password, + }) async { + await Future.delayed(const Duration(milliseconds: 800)); + + if (username.trim().isEmpty || password.isEmpty) { + throw Exception('Username and password are required.'); + } + + if (password.length < 6) { + throw Exception('Password must be at least 6 characters.'); + } + + return LoginUser( + username: username.trim(), + token: 'mock-access-token', + refreshToken: 'mock-refresh-token', + role: username.trim().toLowerCase() == 'admin' ? 'admin' : 'user', + ); + } + + @override + Future refreshSession({ + required String username, + required String refreshToken, + required String role, + }) async { + await Future.delayed(const Duration(milliseconds: 300)); + + if (refreshToken.trim().isEmpty) { + throw Exception('Refresh token is required.'); + } + + return LoginUser( + username: username.trim(), + token: 'mock-access-token-refreshed', + refreshToken: 'mock-refresh-token-refreshed', + role: role, + ); + } + + @override + Future resetPassword({ + required String userId, + required String password, + }) async { + await Future.delayed(const Duration(milliseconds: 500)); + + if (userId.trim().isEmpty) { + throw Exception('User ID is required.'); + } + if (password.length < 8) { + throw Exception('Password must be at least 8 characters.'); + } + } + + @override + Future logout({required String refreshToken}) async { + await Future.delayed(const Duration(milliseconds: 250)); + if (refreshToken.trim().isEmpty) { + throw Exception('Refresh token is required.'); + } + } +} diff --git a/lib/domain/entities/login_user.dart b/lib/domain/entities/login_user.dart index c0e1f07..c4ff371 100644 --- a/lib/domain/entities/login_user.dart +++ b/lib/domain/entities/login_user.dart @@ -1,17 +1,17 @@ -class LoginUser { - const LoginUser({ - required this.username, - required this.token, - required this.refreshToken, - required this.role, - }); - - final String username; - final String token; - final String refreshToken; - final String role; - - bool get isAdmin => role.toLowerCase() == 'admin'; - - bool get isCashier => role.toLowerCase() == 'cashier'; -} +class LoginUser { + const LoginUser({ + required this.username, + required this.token, + required this.refreshToken, + required this.role, + }); + + final String username; + final String token; + final String refreshToken; + final String role; + + bool get isAdmin => role.toLowerCase() == 'admin'; + + bool get isCashier => role.toLowerCase() == 'cashier'; +} diff --git a/lib/domain/entities/merchant.dart b/lib/domain/entities/merchant.dart index 6482bc0..8d89f08 100644 --- a/lib/domain/entities/merchant.dart +++ b/lib/domain/entities/merchant.dart @@ -1,31 +1,31 @@ -class Merchant { - const Merchant({ - this.id, - this.name, - this.mobile, - this.description, - this.address, - this.address2, - this.address3, - this.phone, - this.mids, - this.createdBy, - this.updatedBy, - this.createdAt, - this.updatedAt, - }); - - final String? id; - final String? name; - final String? mobile; - final String? description; - final String? address; - final String? address2; - final String? address3; - final String? phone; - final String? mids; - final String? createdBy; - final String? updatedBy; - final String? createdAt; - final String? updatedAt; -} +class Merchant { + const Merchant({ + this.id, + this.name, + this.mobile, + this.description, + this.address, + this.address2, + this.address3, + this.phone, + this.mids, + this.createdBy, + this.updatedBy, + this.createdAt, + this.updatedAt, + }); + + final String? id; + final String? name; + final String? mobile; + final String? description; + final String? address; + final String? address2; + final String? address3; + final String? phone; + final String? mids; + final String? createdBy; + final String? updatedBy; + final String? createdAt; + final String? updatedAt; +} diff --git a/lib/domain/entities/receipt_content.dart b/lib/domain/entities/receipt_content.dart index 949213a..d0b41bc 100644 --- a/lib/domain/entities/receipt_content.dart +++ b/lib/domain/entities/receipt_content.dart @@ -1,18 +1,18 @@ -import 'dart:typed_data'; - -sealed class ReceiptContent { - const ReceiptContent(); -} - -class ReceiptPdfContent extends ReceiptContent { - const ReceiptPdfContent(this.bytes); - - final Uint8List bytes; -} - -class ReceiptHtmlContent extends ReceiptContent { - const ReceiptHtmlContent({required this.html, required this.contentType}); - - final String html; - final String contentType; -} +import 'dart:typed_data'; + +sealed class ReceiptContent { + const ReceiptContent(); +} + +class ReceiptPdfContent extends ReceiptContent { + const ReceiptPdfContent(this.bytes); + + final Uint8List bytes; +} + +class ReceiptHtmlContent extends ReceiptContent { + const ReceiptHtmlContent({required this.html, required this.contentType}); + + final String html; + final String contentType; +} diff --git a/lib/domain/entities/terminal.dart b/lib/domain/entities/terminal.dart index 246df94..703a981 100644 --- a/lib/domain/entities/terminal.dart +++ b/lib/domain/entities/terminal.dart @@ -1,35 +1,35 @@ -class Terminal { - const Terminal({ - this.id, - this.serial, - this.address, - this.address2, - this.address3, - this.name, - this.appId, - this.tid, - this.mids, - this.merchantId, - this.status, - this.totalTransactions, - this.totalAmount, - this.createdAt, - this.updatedAt, - }); - - final String? id; - final String? serial; - final String? address; - final String? address2; - final String? address3; - final String? name; - final String? appId; - final String? tid; - final List? mids; - final String? merchantId; - final String? status; - final int? totalTransactions; - final num? totalAmount; - final String? createdAt; - final String? updatedAt; -} +class Terminal { + const Terminal({ + this.id, + this.serial, + this.address, + this.address2, + this.address3, + this.name, + this.appId, + this.tid, + this.mids, + this.merchantId, + this.status, + this.totalTransactions, + this.totalAmount, + this.createdAt, + this.updatedAt, + }); + + final String? id; + final String? serial; + final String? address; + final String? address2; + final String? address3; + final String? name; + final String? appId; + final String? tid; + final List? mids; + final String? merchantId; + final String? status; + final int? totalTransactions; + final num? totalAmount; + final String? createdAt; + final String? updatedAt; +} diff --git a/lib/domain/entities/transaction_record.dart b/lib/domain/entities/transaction_record.dart index 91f4b12..0bbe64c 100644 --- a/lib/domain/entities/transaction_record.dart +++ b/lib/domain/entities/transaction_record.dart @@ -1,19 +1,19 @@ -class TransactionRecord { - const TransactionRecord({ - this.id, - this.rrn, - this.amount, - this.status, - this.type, - this.createdAt, - this.raw, - }); - - final String? id; - final String? rrn; - final num? amount; - final String? status; - final String? type; - final String? createdAt; - final Map? raw; -} +class TransactionRecord { + const TransactionRecord({ + this.id, + this.rrn, + this.amount, + this.status, + this.type, + this.createdAt, + this.raw, + }); + + final String? id; + final String? rrn; + final num? amount; + final String? status; + final String? type; + final String? createdAt; + final Map? raw; +} diff --git a/lib/domain/exceptions/auth_exceptions.dart b/lib/domain/exceptions/auth_exceptions.dart index 02f9832..f42da54 100644 --- a/lib/domain/exceptions/auth_exceptions.dart +++ b/lib/domain/exceptions/auth_exceptions.dart @@ -1,13 +1,13 @@ -class PasswordUpdateRequiredException implements Exception { - const PasswordUpdateRequiredException({ - required this.userId, - this.message = 'Password update required', - }); - - final String userId; - final String message; - - @override - String toString() => message; -} - +class PasswordUpdateRequiredException implements Exception { + const PasswordUpdateRequiredException({ + required this.userId, + this.message = 'Password update required', + }); + + final String userId; + final String message; + + @override + String toString() => message; +} + diff --git a/lib/domain/repositories/auth_repository.dart b/lib/domain/repositories/auth_repository.dart index cb898ec..5c0a4b6 100644 --- a/lib/domain/repositories/auth_repository.dart +++ b/lib/domain/repositories/auth_repository.dart @@ -1,15 +1,15 @@ -import 'package:e_receipt_mobile/domain/entities/login_user.dart'; - -abstract class AuthRepository { - Future login({required String username, required String password}); - - Future refreshSession({ - required String username, - required String refreshToken, - required String role, - }); - - Future resetPassword({required String userId, required String password}); - - Future logout({required String refreshToken}); -} +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; + +abstract class AuthRepository { + Future login({required String username, required String password}); + + Future refreshSession({ + required String username, + required String refreshToken, + required String role, + }); + + Future resetPassword({required String userId, required String password}); + + Future logout({required String refreshToken}); +} diff --git a/lib/domain/repositories/merchant_repository.dart b/lib/domain/repositories/merchant_repository.dart index a41a566..df7e8a0 100644 --- a/lib/domain/repositories/merchant_repository.dart +++ b/lib/domain/repositories/merchant_repository.dart @@ -1,16 +1,16 @@ -import 'package:e_receipt_mobile/domain/entities/merchant.dart'; -import 'package:e_receipt_mobile/domain/entities/terminal.dart'; - -abstract class MerchantRepository { - Future> getMerchants({ - required String token, - int page = 1, - int limit = 10, - String searchTerm = '', - }); - - Future> getTerminalsByMerchantId({ - required String token, - required String merchantId, - }); -} +import 'package:e_receipt_mobile/domain/entities/merchant.dart'; +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; + +abstract class MerchantRepository { + Future> getMerchants({ + required String token, + int page = 1, + int limit = 10, + String searchTerm = '', + }); + + Future> getTerminalsByMerchantId({ + required String token, + required String merchantId, + }); +} diff --git a/lib/domain/repositories/receipt_repository.dart b/lib/domain/repositories/receipt_repository.dart index f2f7c52..44a6dd7 100644 --- a/lib/domain/repositories/receipt_repository.dart +++ b/lib/domain/repositories/receipt_repository.dart @@ -1,17 +1,17 @@ -import 'dart:typed_data'; - -import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; - -abstract class ReceiptRepository { - Future getTransactionReceipt({ - required String token, - required String transactionId, - required String copyFor, - }); - - Future getTransactionReceiptPdfBytes({ - required String token, - required String transactionId, - required String copyFor, - }); -} +import 'dart:typed_data'; + +import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; + +abstract class ReceiptRepository { + Future getTransactionReceipt({ + required String token, + required String transactionId, + required String copyFor, + }); + + Future getTransactionReceiptPdfBytes({ + required String token, + required String transactionId, + required String copyFor, + }); +} diff --git a/lib/domain/repositories/transaction_repository.dart b/lib/domain/repositories/transaction_repository.dart index 105a340..d697a04 100644 --- a/lib/domain/repositories/transaction_repository.dart +++ b/lib/domain/repositories/transaction_repository.dart @@ -1,13 +1,13 @@ -import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; - -abstract class TransactionRepository { - Future> getTransactions({ - required String token, - required String serial, - required String range, - int page = 1, - int limit = 8, - String searchTerm = '', - String sort = '', - }); -} +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; + +abstract class TransactionRepository { + Future> getTransactions({ + required String token, + required String serial, + required String range, + int page = 1, + int limit = 8, + String searchTerm = '', + String sort = '', + }); +} diff --git a/lib/main.dart b/lib/main.dart index b4b2dcf..c5df4df 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,96 +1,96 @@ -import 'package:e_receipt_mobile/core/theme/app_colors.dart'; -import 'package:e_receipt_mobile/domain/entities/terminal.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/home/home_screen.dart'; -import 'package:e_receipt_mobile/presentation/login/login_page.dart'; -import 'package:e_receipt_mobile/presentation/settings/theme_mode_provider.dart'; -import 'package:e_receipt_mobile/presentation/terminal/terminal_next_screen.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -void main() { - WidgetsFlutterBinding.ensureInitialized(); - - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - statusBarColor: AppColors.primary, - statusBarIconBrightness: Brightness.light, - statusBarBrightness: Brightness.dark, - ), - ); - runApp(const ProviderScope(child: EReceiptApp())); -} - -class EReceiptApp extends ConsumerWidget { - const EReceiptApp({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final themeMode = ref.watch(appThemeModeProvider); - - return MaterialApp( - title: 'E-Receipt', - debugShowCheckedModeBanner: false, - themeMode: themeMode, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: AppColors.primary, - ), - primaryColor: AppColors.primary, - appBarTheme: const AppBarTheme( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - systemOverlayStyle: SystemUiOverlayStyle( - statusBarColor: AppColors.primary, - statusBarIconBrightness: Brightness.light, - statusBarBrightness: Brightness.dark, - ), - ), - useMaterial3: true, - ), - darkTheme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: AppColors.primary, - brightness: Brightness.dark, - ), - appBarTheme: const AppBarTheme( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - systemOverlayStyle: SystemUiOverlayStyle( - statusBarColor: AppColors.primary, - statusBarIconBrightness: Brightness.light, - statusBarBrightness: Brightness.dark, - ), - ), - useMaterial3: true, - ), - home: const AppRoot(), - ); - } -} - -class AppRoot extends ConsumerWidget { - const AppRoot({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final user = ref.watch(sessionControllerProvider); - if (user == null) { - return const LoginPage(); - } - - final role = user.role.trim().toLowerCase(); - if (role == 'cashier') { - final serial = user.username.trim().toUpperCase(); - final terminal = Terminal(serial: serial, name: serial); - return TerminalNextScreen(merchantName: serial, terminal: terminal); - } - - return HomeScreen(user: user); - } -} +import 'package:e_receipt_mobile/core/theme/app_colors.dart'; +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/home/home_screen.dart'; +import 'package:e_receipt_mobile/presentation/login/login_page.dart'; +import 'package:e_receipt_mobile/presentation/settings/theme_mode_provider.dart'; +import 'package:e_receipt_mobile/presentation/terminal/terminal_next_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: AppColors.primary, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + ); + runApp(const ProviderScope(child: EReceiptApp())); +} + +class EReceiptApp extends ConsumerWidget { + const EReceiptApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(appThemeModeProvider); + + return MaterialApp( + title: 'E-Receipt', + debugShowCheckedModeBanner: false, + themeMode: themeMode, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.primary, + ), + primaryColor: AppColors.primary, + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: AppColors.primary, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + ), + useMaterial3: true, + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.primary, + brightness: Brightness.dark, + ), + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: AppColors.primary, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + ), + useMaterial3: true, + ), + home: const AppRoot(), + ); + } +} + +class AppRoot extends ConsumerWidget { + const AppRoot({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(sessionControllerProvider); + if (user == null) { + return const LoginPage(); + } + + final role = user.role.trim().toLowerCase(); + if (role == 'cashier') { + final serial = user.username.trim().toUpperCase(); + final terminal = Terminal(serial: serial, name: serial); + return TerminalNextScreen(merchantName: serial, terminal: terminal); + } + + return HomeScreen(user: user); + } +} diff --git a/lib/presentation/auth/logout_state.dart b/lib/presentation/auth/logout_state.dart index 7f1a581..07a1314 100644 --- a/lib/presentation/auth/logout_state.dart +++ b/lib/presentation/auth/logout_state.dart @@ -1,24 +1,24 @@ -import 'package:flutter/foundation.dart'; - -@immutable -class LogoutState { - const LogoutState({ - this.isLoading = false, - this.lastError, - }); - - final bool isLoading; - final Object? lastError; - - LogoutState copyWith({ - bool? isLoading, - Object? lastError, - bool clearError = false, - }) { - return LogoutState( - isLoading: isLoading ?? this.isLoading, - lastError: clearError ? null : lastError ?? this.lastError, - ); - } -} - +import 'package:flutter/foundation.dart'; + +@immutable +class LogoutState { + const LogoutState({ + this.isLoading = false, + this.lastError, + }); + + final bool isLoading; + final Object? lastError; + + LogoutState copyWith({ + bool? isLoading, + Object? lastError, + bool clearError = false, + }) { + return LogoutState( + isLoading: isLoading ?? this.isLoading, + lastError: clearError ? null : lastError ?? this.lastError, + ); + } +} + diff --git a/lib/presentation/auth/logout_view_model.dart b/lib/presentation/auth/logout_view_model.dart index 1796714..af33daf 100644 --- a/lib/presentation/auth/logout_view_model.dart +++ b/lib/presentation/auth/logout_view_model.dart @@ -1,54 +1,54 @@ -import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; -import 'package:e_receipt_mobile/domain/entities/login_user.dart'; -import 'package:e_receipt_mobile/presentation/auth/logout_state.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final logoutViewModelProvider = - StateNotifierProvider((ref) { - return LogoutViewModel( - authRepository: ref.watch(authRepositoryProvider), - sessionController: ref.watch(sessionControllerProvider.notifier), - sessionUserProvider: () => ref.read(sessionControllerProvider), - ); -}); - -class LogoutViewModel extends StateNotifier { - LogoutViewModel({ - required AuthRepository authRepository, - required SessionController sessionController, - required SessionUserProvider sessionUserProvider, - }) : _authRepository = authRepository, - _sessionController = sessionController, - _sessionUserProvider = sessionUserProvider, - super(const LogoutState()); - - final AuthRepository _authRepository; - final SessionController _sessionController; - final SessionUserProvider _sessionUserProvider; - - Future logout() async { - if (state.isLoading) { - return; - } - - state = state.copyWith(isLoading: true, clearError: true); - - final sessionUser = _sessionUserProvider(); - final refreshToken = sessionUser?.refreshToken.trim() ?? ''; - - try { - if (refreshToken.isNotEmpty) { - await _authRepository.logout(refreshToken: refreshToken); - } - } catch (error) { - state = state.copyWith(lastError: error); - } finally { - _sessionController.clearUser(); - state = state.copyWith(isLoading: false); - } - } -} - -typedef SessionUserProvider = LoginUser? Function(); +import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:e_receipt_mobile/presentation/auth/logout_state.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final logoutViewModelProvider = + StateNotifierProvider((ref) { + return LogoutViewModel( + authRepository: ref.watch(authRepositoryProvider), + sessionController: ref.watch(sessionControllerProvider.notifier), + sessionUserProvider: () => ref.read(sessionControllerProvider), + ); +}); + +class LogoutViewModel extends StateNotifier { + LogoutViewModel({ + required AuthRepository authRepository, + required SessionController sessionController, + required SessionUserProvider sessionUserProvider, + }) : _authRepository = authRepository, + _sessionController = sessionController, + _sessionUserProvider = sessionUserProvider, + super(const LogoutState()); + + final AuthRepository _authRepository; + final SessionController _sessionController; + final SessionUserProvider _sessionUserProvider; + + Future logout() async { + if (state.isLoading) { + return; + } + + state = state.copyWith(isLoading: true, clearError: true); + + final sessionUser = _sessionUserProvider(); + final refreshToken = sessionUser?.refreshToken.trim() ?? ''; + + try { + if (refreshToken.isNotEmpty) { + await _authRepository.logout(refreshToken: refreshToken); + } + } catch (error) { + state = state.copyWith(lastError: error); + } finally { + _sessionController.clearUser(); + state = state.copyWith(isLoading: false); + } + } +} + +typedef SessionUserProvider = LoginUser? Function(); diff --git a/lib/presentation/auth/session_controller.dart b/lib/presentation/auth/session_controller.dart index dd48627..862a9fa 100644 --- a/lib/presentation/auth/session_controller.dart +++ b/lib/presentation/auth/session_controller.dart @@ -1,19 +1,19 @@ -import 'package:e_receipt_mobile/domain/entities/login_user.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final sessionControllerProvider = - StateNotifierProvider((ref) { - return SessionController(); - }); - -class SessionController extends StateNotifier { - SessionController() : super(null); - - void setUser(LoginUser user) { - state = user; - } - - void clearUser() { - state = null; - } -} +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final sessionControllerProvider = + StateNotifierProvider((ref) { + return SessionController(); + }); + +class SessionController extends StateNotifier { + SessionController() : super(null); + + void setUser(LoginUser user) { + state = user; + } + + void clearUser() { + state = null; + } +} diff --git a/lib/presentation/components/rounded_input.dart b/lib/presentation/components/rounded_input.dart index d8863e7..5556cf5 100644 --- a/lib/presentation/components/rounded_input.dart +++ b/lib/presentation/components/rounded_input.dart @@ -1,80 +1,80 @@ -import 'package:flutter/material.dart'; - -Widget roundedInput({ - required TextEditingController controller, - required String hint, - required IconData icon, - bool isPassword = false, - String? Function(String?)? validator, -}) { - return RoundedInput( - controller: controller, - hint: hint, - icon: icon, - isPassword: isPassword, - validator: validator, - ); -} - -class RoundedInput extends StatefulWidget { - const RoundedInput({ - super.key, - required this.controller, - required this.hint, - required this.icon, - this.isPassword = false, - this.validator, - }); - - final TextEditingController controller; - final String hint; - final IconData icon; - final bool isPassword; - final String? Function(String?)? validator; - - @override - State createState() => _RoundedInputState(); -} - -class _RoundedInputState extends State { - late bool _obscured; - - @override - void initState() { - super.initState(); - _obscured = widget.isPassword; - } - - @override - Widget build(BuildContext context) { - return TextFormField( - controller: widget.controller, - obscureText: _obscured, - validator: widget.validator, - decoration: InputDecoration( - hintText: widget.hint, - prefixIcon: Icon(widget.icon, color: Colors.grey), - suffixIcon: widget.isPassword - ? IconButton( - onPressed: () { - setState(() { - _obscured = !_obscured; - }); - }, - icon: Icon( - _obscured ? Icons.visibility : Icons.visibility_off, - color: Colors.grey, - ), - ) - : null, - filled: true, - fillColor: const Color(0xFFE9EEF3), - contentPadding: const EdgeInsets.symmetric(vertical: 16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(30), - borderSide: BorderSide.none, - ), - ), - ); - } -} +import 'package:flutter/material.dart'; + +Widget roundedInput({ + required TextEditingController controller, + required String hint, + required IconData icon, + bool isPassword = false, + String? Function(String?)? validator, +}) { + return RoundedInput( + controller: controller, + hint: hint, + icon: icon, + isPassword: isPassword, + validator: validator, + ); +} + +class RoundedInput extends StatefulWidget { + const RoundedInput({ + super.key, + required this.controller, + required this.hint, + required this.icon, + this.isPassword = false, + this.validator, + }); + + final TextEditingController controller; + final String hint; + final IconData icon; + final bool isPassword; + final String? Function(String?)? validator; + + @override + State createState() => _RoundedInputState(); +} + +class _RoundedInputState extends State { + late bool _obscured; + + @override + void initState() { + super.initState(); + _obscured = widget.isPassword; + } + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: widget.controller, + obscureText: _obscured, + validator: widget.validator, + decoration: InputDecoration( + hintText: widget.hint, + prefixIcon: Icon(widget.icon, color: Colors.grey), + suffixIcon: widget.isPassword + ? IconButton( + onPressed: () { + setState(() { + _obscured = !_obscured; + }); + }, + icon: Icon( + _obscured ? Icons.visibility : Icons.visibility_off, + color: Colors.grey, + ), + ) + : null, + filled: true, + fillColor: const Color(0xFFE9EEF3), + contentPadding: const EdgeInsets.symmetric(vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: BorderSide.none, + ), + ), + ); + } +} diff --git a/lib/presentation/home/home_pagination_providers.dart b/lib/presentation/home/home_pagination_providers.dart index b022413..2635820 100644 --- a/lib/presentation/home/home_pagination_providers.dart +++ b/lib/presentation/home/home_pagination_providers.dart @@ -1,7 +1,7 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final merchantPageSizeProvider = Provider((ref) => 10); - -final merchantSearchQueryProvider = StateProvider.autoDispose((ref) { - return ''; -}); +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final merchantPageSizeProvider = Provider((ref) => 10); + +final merchantSearchQueryProvider = StateProvider.autoDispose((ref) { + return ''; +}); diff --git a/lib/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index 014fdc7..2f75560 100644 --- a/lib/presentation/home/home_screen.dart +++ b/lib/presentation/home/home_screen.dart @@ -1,264 +1,214 @@ -import 'package:e_receipt_mobile/domain/entities/login_user.dart'; -import 'package:e_receipt_mobile/presentation/auth/logout_view_model.dart'; -import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.dart'; -import 'package:e_receipt_mobile/presentation/home/merchant_paging_view_model.dart'; -import 'package:e_receipt_mobile/presentation/home/widgets/home_drawer.dart'; -import 'package:e_receipt_mobile/presentation/home/widgets/merchant_header.dart'; -import 'package:e_receipt_mobile/presentation/home/widgets/merchant_list_view.dart'; -import 'package:e_receipt_mobile/presentation/settings/settings_screen.dart'; -import 'package:e_receipt_mobile/presentation/terminal/terminal_selection_screen.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class HomeScreen extends ConsumerWidget { - const HomeScreen({required this.user, super.key}); - - final LoginUser user; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pagingState = ref.watch(merchantPagingViewModelProvider); - final pagingViewModel = ref.read(merchantPagingViewModelProvider.notifier); - final logoutState = ref.watch(logoutViewModelProvider); - final colorScheme = Theme.of(context).colorScheme; - final query = ref.watch(merchantSearchQueryProvider); - - return Scaffold( - appBar: AppBar( - title: const Text('Merchants'), - actions: [ - IconButton( - tooltip: 'Refresh', - onPressed: pagingState.isLoading - ? null - : () => pagingViewModel.refresh(), - icon: const Icon(Icons.refresh), - ), - ], - ), - drawer: HomeDrawer( - user: user, - onProfile: () { - Navigator.of(context).pop(); - _showProfile(context, user); - }, - onReports: () { - Navigator.of(context).pop(); - _showComingSoon(context, 'Reports'); - }, - onSettings: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsScreen()), - ); - }, - onHelp: () { - Navigator.of(context).pop(); - _showComingSoon(context, 'Help'); - }, - onLogout: () async { - if (logoutState.isLoading) { - return; - } - - final shouldLogout = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Logout'), - content: const Text('Are you sure you want to logout?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Logout'), - ), - ], - ); - }, - ); - - if (shouldLogout != true || !context.mounted) { - return; - } - - Navigator.of(context).pop(); // close drawer - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Signed out'), - behavior: SnackBarBehavior.floating, - ), - ); - await ref.read(logoutViewModelProvider.notifier).logout(); - }, - ), - body: pagingState.isLoading && pagingState.items.isEmpty - ? const Center(child: CircularProgressIndicator()) - : pagingState.errorMessage != null && pagingState.items.isEmpty - ? Center( - child: Padding( - padding: EdgeInsets.fromLTRB( - 24, - 24, - 24, - MediaQuery.viewPaddingOf(context).bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.wifi_off_outlined, - size: 56, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'Failed to load merchants', - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Text( - pagingState.errorMessage!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 14), - FilledButton.tonal( - onPressed: pagingViewModel.refresh, - child: const Text('Try again'), - ), - ], - ), - ), - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MerchantHeader( - loadedCount: pagingState.items.length, - query: query, - onQueryChanged: (value) { - ref.read(merchantSearchQueryProvider.notifier).state = - value; - pagingViewModel.setSearchTerm(value); - }, - onClear: () { - ref.read(merchantSearchQueryProvider.notifier).state = ''; - pagingViewModel.setSearchTerm(''); - }, - ), - Expanded( - child: MerchantListView( - merchants: pagingState.items, - hasMore: pagingState.hasMore, - isLoadingMore: pagingState.isLoadingMore, - onRefresh: pagingViewModel.refresh, - onMerchantTap: (merchant) { - final id = merchant.id; - if (id == null) { - return; - } - _openTerminalSelection(context, id, merchant.name ?? '-'); - }, - onLoadMore: pagingViewModel.loadMore, - onEndReached: pagingViewModel.loadMore, - ), - ), - if (pagingState.errorMessage != null && - pagingState.items.isNotEmpty) - Padding( - padding: EdgeInsets.fromLTRB( - 16, - 8, - 16, - MediaQuery.viewPaddingOf(context).bottom + 8, - ), - child: Material( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon( - Icons.error_outline, - color: colorScheme.onErrorContainer, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - pagingState.errorMessage!, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.onErrorContainer, - ), - ), - ), - const SizedBox(width: 8), - TextButton( - onPressed: pagingViewModel.loadMore, - child: const Text('Retry'), - ), - ], - ), - ), - ), - ), - ], - ), - ); - } - - void _showComingSoon(BuildContext context, String feature) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$feature is coming soon'))); - } - - void _showProfile(BuildContext context, LoginUser user) { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Profile'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Username: ${user.username}'), - const SizedBox(height: 8), - Text('Role: ${user.role}'), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ); - }, - ); - } - - void _openTerminalSelection( - BuildContext context, - String merchantId, - String merchantName, - ) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TerminalSelectionScreen( - merchantId: merchantId, - merchantName: merchantName, - ), - ), - ); - } -} +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:e_receipt_mobile/presentation/auth/logout_view_model.dart'; +import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.dart'; +import 'package:e_receipt_mobile/presentation/home/merchant_paging_view_model.dart'; +import 'package:e_receipt_mobile/presentation/home/widgets/home_drawer.dart'; +import 'package:e_receipt_mobile/presentation/home/widgets/merchant_header.dart'; +import 'package:e_receipt_mobile/presentation/home/widgets/merchant_list_view.dart'; +import 'package:e_receipt_mobile/presentation/terminal/terminal_selection_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class HomeScreen extends ConsumerWidget { + const HomeScreen({required this.user, super.key}); + + final LoginUser user; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pagingState = ref.watch(merchantPagingViewModelProvider); + final pagingViewModel = ref.read(merchantPagingViewModelProvider.notifier); + final logoutState = ref.watch(logoutViewModelProvider); + final colorScheme = Theme.of(context).colorScheme; + final query = ref.watch(merchantSearchQueryProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Merchants'), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: pagingState.isLoading + ? null + : () => pagingViewModel.refresh(), + icon: const Icon(Icons.refresh), + ), + ], + ), + drawer: HomeDrawer( + user: user, + onLogout: () async { + if (logoutState.isLoading) { + return; + } + final shouldLogout = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Logout'), + content: const Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Logout'), + ), + ], + ); + }, + ); + + if (shouldLogout != true || !context.mounted) { + return; + } + + Navigator.of(context).pop(); // close drawer + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Signed out'), + behavior: SnackBarBehavior.floating, + ), + ); + await ref.read(logoutViewModelProvider.notifier).logout(); + }, + ), + body: pagingState.isLoading && pagingState.items.isEmpty + ? const Center(child: CircularProgressIndicator()) + : pagingState.errorMessage != null && pagingState.items.isEmpty + ? Center( + child: Padding( + padding: EdgeInsets.fromLTRB( + 24, + 24, + 24, + MediaQuery.viewPaddingOf(context).bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_off_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'Failed to load merchants', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + pagingState.errorMessage!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + FilledButton.tonal( + onPressed: pagingViewModel.refresh, + child: const Text('Try again'), + ), + ], + ), + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MerchantHeader( + loadedCount: pagingState.items.length, + query: query, + onQueryChanged: (value) { + ref.read(merchantSearchQueryProvider.notifier).state = + value; + pagingViewModel.setSearchTerm(value); + }, + onClear: () { + ref.read(merchantSearchQueryProvider.notifier).state = ''; + pagingViewModel.setSearchTerm(''); + }, + ), + Expanded( + child: MerchantListView( + merchants: pagingState.items, + hasMore: pagingState.hasMore, + isLoadingMore: pagingState.isLoadingMore, + onRefresh: pagingViewModel.refresh, + onMerchantTap: (merchant) { + final id = merchant.id; + if (id == null) { + return; + } + _openTerminalSelection(context, id, merchant.name ?? '-'); + }, + onLoadMore: pagingViewModel.loadMore, + onEndReached: pagingViewModel.loadMore, + ), + ), + if (pagingState.errorMessage != null && + pagingState.items.isNotEmpty) + Padding( + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + MediaQuery.viewPaddingOf(context).bottom + 8, + ), + child: Material( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + pagingState.errorMessage!, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: pagingViewModel.loadMore, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + + + void _openTerminalSelection( + BuildContext context, + String merchantId, + String merchantName, + ) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TerminalSelectionScreen( + merchantId: merchantId, + merchantName: merchantName, + ), + ), + ); + } +} diff --git a/lib/presentation/home/home_view_model.dart b/lib/presentation/home/home_view_model.dart index 8ccbd89..5508d31 100644 --- a/lib/presentation/home/home_view_model.dart +++ b/lib/presentation/home/home_view_model.dart @@ -1,32 +1,32 @@ -import 'package:e_receipt_mobile/core/config/app_config.dart'; -import 'package:e_receipt_mobile/data/repositories/api_merchant_repository.dart'; -import 'package:e_receipt_mobile/domain/entities/terminal.dart'; -import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final merchantRepositoryProvider = Provider((ref) { - return ApiMerchantRepository( - baseUrl: AppConfig.apiBaseUrl, - apiSecret: AppConfig.apiSecret, - client: ref.watch(authenticatedHttpClientProvider), - ); -}); - -final terminalListProvider = FutureProvider.family, String>(( - ref, - merchantId, -) async { - final sessionUser = ref.watch(sessionControllerProvider); - if (sessionUser == null) { - throw Exception('No active session'); - } - - return ref - .watch(merchantRepositoryProvider) - .getTerminalsByMerchantId( - token: sessionUser.token, - merchantId: merchantId, - ); -}); +import 'package:e_receipt_mobile/core/config/app_config.dart'; +import 'package:e_receipt_mobile/data/repositories/api_merchant_repository.dart'; +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final merchantRepositoryProvider = Provider((ref) { + return ApiMerchantRepository( + baseUrl: AppConfig.apiBaseUrl, + apiSecret: AppConfig.apiSecret, + client: ref.watch(authenticatedHttpClientProvider), + ); +}); + +final terminalListProvider = FutureProvider.family, String>(( + ref, + merchantId, +) async { + final sessionUser = ref.watch(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + return ref + .watch(merchantRepositoryProvider) + .getTerminalsByMerchantId( + token: sessionUser.token, + merchantId: merchantId, + ); +}); diff --git a/lib/presentation/home/merchant_paging_state.dart b/lib/presentation/home/merchant_paging_state.dart index 06c4612..1e011a8 100644 --- a/lib/presentation/home/merchant_paging_state.dart +++ b/lib/presentation/home/merchant_paging_state.dart @@ -1,49 +1,49 @@ -import 'package:e_receipt_mobile/domain/entities/merchant.dart'; -import 'package:flutter/foundation.dart'; - -@immutable -class MerchantPagingState { - const MerchantPagingState({ - this.items = const [], - this.page = 1, - this.limit = 10, - this.searchTerm = '', - this.isLoading = false, - this.isLoadingMore = false, - this.hasMore = true, - this.errorMessage, - }); - - final List items; - final int page; - final int limit; - final String searchTerm; - final bool isLoading; - final bool isLoadingMore; - final bool hasMore; - final String? errorMessage; - - MerchantPagingState copyWith({ - List? items, - int? page, - int? limit, - String? searchTerm, - bool? isLoading, - bool? isLoadingMore, - bool? hasMore, - String? errorMessage, - bool clearError = false, - }) { - return MerchantPagingState( - items: items ?? this.items, - page: page ?? this.page, - limit: limit ?? this.limit, - searchTerm: searchTerm ?? this.searchTerm, - isLoading: isLoading ?? this.isLoading, - isLoadingMore: isLoadingMore ?? this.isLoadingMore, - hasMore: hasMore ?? this.hasMore, - errorMessage: clearError ? null : errorMessage ?? this.errorMessage, - ); - } -} - +import 'package:e_receipt_mobile/domain/entities/merchant.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class MerchantPagingState { + const MerchantPagingState({ + this.items = const [], + this.page = 1, + this.limit = 10, + this.searchTerm = '', + this.isLoading = false, + this.isLoadingMore = false, + this.hasMore = true, + this.errorMessage, + }); + + final List items; + final int page; + final int limit; + final String searchTerm; + final bool isLoading; + final bool isLoadingMore; + final bool hasMore; + final String? errorMessage; + + MerchantPagingState copyWith({ + List? items, + int? page, + int? limit, + String? searchTerm, + bool? isLoading, + bool? isLoadingMore, + bool? hasMore, + String? errorMessage, + bool clearError = false, + }) { + return MerchantPagingState( + items: items ?? this.items, + page: page ?? this.page, + limit: limit ?? this.limit, + searchTerm: searchTerm ?? this.searchTerm, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + hasMore: hasMore ?? this.hasMore, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + ); + } +} + diff --git a/lib/presentation/home/merchant_paging_view_model.dart b/lib/presentation/home/merchant_paging_view_model.dart index bede687..dd4e124 100644 --- a/lib/presentation/home/merchant_paging_view_model.dart +++ b/lib/presentation/home/merchant_paging_view_model.dart @@ -1,130 +1,130 @@ -import 'dart:async'; - -import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.dart'; -import 'package:e_receipt_mobile/presentation/home/home_view_model.dart'; -import 'package:e_receipt_mobile/presentation/home/merchant_paging_state.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final merchantPagingViewModelProvider = - StateNotifierProvider.autoDispose< - MerchantPagingViewModel, - MerchantPagingState - >((ref) { - final sessionUser = ref.watch(sessionControllerProvider); - if (sessionUser == null) { - throw Exception('No active session'); - } - - final limit = ref.watch(merchantPageSizeProvider); - final viewModel = MerchantPagingViewModel( - repository: ref.watch(merchantRepositoryProvider), - token: sessionUser.token, - limit: limit, - searchTerm: '', - ); - ref.onDispose(viewModel.dispose); - viewModel.loadInitial(); - return viewModel; - }); - -class MerchantPagingViewModel extends StateNotifier { - MerchantPagingViewModel({ - required MerchantRepository repository, - required String token, - required int limit, - required String searchTerm, - }) : _repository = repository, - _token = token, - super(MerchantPagingState(limit: limit, searchTerm: searchTerm)); - - final MerchantRepository _repository; - final String _token; - - Timer? _searchDebounce; - - void dispose() { - _searchDebounce?.cancel(); - } - - Future loadInitial() async { - await refresh(searchTerm: state.searchTerm); - } - - Future refresh({String? searchTerm}) async { - final nextSearch = searchTerm ?? state.searchTerm; - state = state.copyWith( - isLoading: true, - isLoadingMore: false, - page: 1, - items: const [], - hasMore: true, - searchTerm: nextSearch, - clearError: true, - ); - - try { - final results = await _repository.getMerchants( - token: _token, - page: 1, - limit: state.limit, - searchTerm: nextSearch, - ); - state = state.copyWith( - isLoading: false, - items: results, - page: 1, - hasMore: results.length >= state.limit, - ); - } catch (error) { - state = state.copyWith( - isLoading: false, - errorMessage: error.toString().replaceFirst('Exception: ', ''), - ); - } - } - - void setSearchTerm(String value) { - final next = value; - if (next == state.searchTerm) { - return; - } - - _searchDebounce?.cancel(); - _searchDebounce = Timer(const Duration(milliseconds: 350), () { - refresh(searchTerm: next); - }); - } - - Future loadMore() async { - if (state.isLoading || state.isLoadingMore || !state.hasMore) { - return; - } - - state = state.copyWith(isLoadingMore: true, clearError: true); - final nextPage = state.page + 1; - - try { - final results = await _repository.getMerchants( - token: _token, - page: nextPage, - limit: state.limit, - searchTerm: state.searchTerm, - ); - - final merged = [...state.items, ...results]; - state = state.copyWith( - isLoadingMore: false, - items: merged, - page: nextPage, - hasMore: results.length >= state.limit, - ); - } catch (error) { - state = state.copyWith( - isLoadingMore: false, - errorMessage: error.toString().replaceFirst('Exception: ', ''), - ); - } - } -} +import 'dart:async'; + +import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.dart'; +import 'package:e_receipt_mobile/presentation/home/home_view_model.dart'; +import 'package:e_receipt_mobile/presentation/home/merchant_paging_state.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final merchantPagingViewModelProvider = + StateNotifierProvider.autoDispose< + MerchantPagingViewModel, + MerchantPagingState + >((ref) { + final sessionUser = ref.watch(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + final limit = ref.watch(merchantPageSizeProvider); + final viewModel = MerchantPagingViewModel( + repository: ref.watch(merchantRepositoryProvider), + token: sessionUser.token, + limit: limit, + searchTerm: '', + ); + ref.onDispose(viewModel.dispose); + viewModel.loadInitial(); + return viewModel; + }); + +class MerchantPagingViewModel extends StateNotifier { + MerchantPagingViewModel({ + required MerchantRepository repository, + required String token, + required int limit, + required String searchTerm, + }) : _repository = repository, + _token = token, + super(MerchantPagingState(limit: limit, searchTerm: searchTerm)); + + final MerchantRepository _repository; + final String _token; + + Timer? _searchDebounce; + + void dispose() { + _searchDebounce?.cancel(); + } + + Future loadInitial() async { + await refresh(searchTerm: state.searchTerm); + } + + Future refresh({String? searchTerm}) async { + final nextSearch = searchTerm ?? state.searchTerm; + state = state.copyWith( + isLoading: true, + isLoadingMore: false, + page: 1, + items: const [], + hasMore: true, + searchTerm: nextSearch, + clearError: true, + ); + + try { + final results = await _repository.getMerchants( + token: _token, + page: 1, + limit: state.limit, + searchTerm: nextSearch, + ); + state = state.copyWith( + isLoading: false, + items: results, + page: 1, + hasMore: results.length >= state.limit, + ); + } catch (error) { + state = state.copyWith( + isLoading: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + void setSearchTerm(String value) { + final next = value; + if (next == state.searchTerm) { + return; + } + + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 350), () { + refresh(searchTerm: next); + }); + } + + Future loadMore() async { + if (state.isLoading || state.isLoadingMore || !state.hasMore) { + return; + } + + state = state.copyWith(isLoadingMore: true, clearError: true); + final nextPage = state.page + 1; + + try { + final results = await _repository.getMerchants( + token: _token, + page: nextPage, + limit: state.limit, + searchTerm: state.searchTerm, + ); + + final merged = [...state.items, ...results]; + state = state.copyWith( + isLoadingMore: false, + items: merged, + page: nextPage, + hasMore: results.length >= state.limit, + ); + } catch (error) { + state = state.copyWith( + isLoadingMore: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } +} diff --git a/lib/presentation/home/widgets/home_drawer.dart b/lib/presentation/home/widgets/home_drawer.dart index 219f865..e422a88 100644 --- a/lib/presentation/home/widgets/home_drawer.dart +++ b/lib/presentation/home/widgets/home_drawer.dart @@ -1,173 +1,139 @@ -import 'package:e_receipt_mobile/core/theme/app_colors.dart'; -import 'package:e_receipt_mobile/domain/entities/login_user.dart'; -import 'package:flutter/material.dart'; - -class HomeDrawer extends StatelessWidget { +import 'package:e_receipt_mobile/core/theme/app_colors.dart'; +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:flutter/material.dart'; + +class HomeDrawer extends StatelessWidget { const HomeDrawer({ required this.user, - required this.onProfile, - required this.onReports, - required this.onSettings, - required this.onHelp, required this.onLogout, super.key, }); final LoginUser user; - final VoidCallback onProfile; - final VoidCallback onReports; - final VoidCallback onSettings; - final VoidCallback onHelp; final VoidCallback onLogout; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Drawer( - child: SafeArea( + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Drawer( + child: SafeArea( child: Column( children: [ _DrawerHeader(user: user), - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - _DrawerItem( - icon: Icons.person_outline, - label: 'Profile', - onTap: onProfile, - ), - _DrawerItem( - icon: Icons.analytics_outlined, - label: 'Reports', - onTap: onReports, - ), - _DrawerItem( - icon: Icons.settings_outlined, - label: 'Settings', - onTap: onSettings, - ), - _DrawerItem( - icon: Icons.help_outline, - label: 'Help', - onTap: onHelp, - ), - ], - ), - ), + const Spacer(), Divider( height: 1, thickness: 1, color: colorScheme.outlineVariant.withOpacity(0.6), ), - Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), - child: _DrawerItem( - icon: Icons.logout, - label: 'Logout', - isDestructive: true, - onTap: onLogout, - ), - ), - ], - ), - ), - ); - } -} - -class _DrawerHeader extends StatelessWidget { - const _DrawerHeader({required this.user}); - - final LoginUser user; - - @override - Widget build(BuildContext context) { - final initials = (user.username.isNotEmpty ? user.username[0] : 'U') - .toUpperCase(); - - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.primary, Color(0xFF1E5FD6)], - ), - ), - child: Row( - children: [ - CircleAvatar( - radius: 22, - backgroundColor: Colors.white, - child: Text( - initials, - style: const TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user.username, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w700, - fontSize: 16, - ), - ), - const SizedBox(height: 2), - Text( - 'Role: ${user.role}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white70), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _DrawerItem extends StatelessWidget { - const _DrawerItem({ - required this.icon, - required this.label, - required this.onTap, - this.isDestructive = false, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - final bool isDestructive; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final foreground = isDestructive - ? colorScheme.error - : colorScheme.onSurface; - - return ListTile( - leading: Icon(icon, color: foreground), - title: Text( - label, - style: TextStyle(fontWeight: FontWeight.w600, color: foreground), - ), - onTap: onTap, - ); - } -} + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + child: _DrawerItem( + icon: Icons.logout, + label: 'Logout', + isDestructive: true, + onTap: onLogout, + ), + ), + ], + ), + ), + ); + } +} + +class _DrawerHeader extends StatelessWidget { + const _DrawerHeader({required this.user}); + + final LoginUser user; + + @override + Widget build(BuildContext context) { + final initials = (user.username.isNotEmpty ? user.username[0] : 'U') + .toUpperCase(); + + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.primary, Color(0xFF1E5FD6)], + ), + ), + child: Row( + children: [ + CircleAvatar( + radius: 22, + backgroundColor: Colors.white, + child: Text( + initials, + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + const SizedBox(height: 2), + Text( + 'Role: ${user.role}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _DrawerItem extends StatelessWidget { + const _DrawerItem({ + required this.icon, + required this.label, + required this.onTap, + this.isDestructive = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool isDestructive; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = isDestructive + ? colorScheme.error + : colorScheme.onSurface; + + return ListTile( + leading: Icon(icon, color: foreground), + title: Text( + label, + style: TextStyle(fontWeight: FontWeight.w600, color: foreground), + ), + onTap: onTap, + ); + } +} diff --git a/lib/presentation/home/widgets/merchant_header.dart b/lib/presentation/home/widgets/merchant_header.dart index 8009d62..9fcb8d1 100644 --- a/lib/presentation/home/widgets/merchant_header.dart +++ b/lib/presentation/home/widgets/merchant_header.dart @@ -1,82 +1,82 @@ -import 'package:flutter/material.dart'; - -class MerchantHeader extends StatelessWidget { - const MerchantHeader({ - required this.loadedCount, - required this.query, - required this.onQueryChanged, - required this.onClear, - super.key, - }); - - final int loadedCount; - final String query; - final ValueChanged onQueryChanged; - final VoidCallback onClear; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Merchants', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - 'Select a merchant to continue', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Chip( - label: Text('$loadedCount'), - side: BorderSide(color: colorScheme.outlineVariant), - backgroundColor: colorScheme.surfaceContainerHigh, - ), - ], - ), - const SizedBox(height: 12), - SearchBar( - leading: const Icon(Icons.search), - hintText: 'Search merchants', - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 14), - ), - backgroundColor: WidgetStatePropertyAll( - colorScheme.surfaceContainerHigh, - ), - elevation: const WidgetStatePropertyAll(0), - constraints: const BoxConstraints(minHeight: 52), - trailing: [ - if (query.isNotEmpty) - IconButton( - tooltip: 'Clear', - onPressed: onClear, - icon: const Icon(Icons.close), - ), - ], - onChanged: onQueryChanged, - ), - ], - ), - ); - } -} +import 'package:flutter/material.dart'; + +class MerchantHeader extends StatelessWidget { + const MerchantHeader({ + required this.loadedCount, + required this.query, + required this.onQueryChanged, + required this.onClear, + super.key, + }); + + final int loadedCount; + final String query; + final ValueChanged onQueryChanged; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Merchants', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + 'Select a merchant to continue', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Chip( + label: Text('$loadedCount'), + side: BorderSide(color: colorScheme.outlineVariant), + backgroundColor: colorScheme.surfaceContainerHigh, + ), + ], + ), + const SizedBox(height: 12), + SearchBar( + leading: const Icon(Icons.search), + hintText: 'Search merchants', + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 14), + ), + backgroundColor: WidgetStatePropertyAll( + colorScheme.surfaceContainerHigh, + ), + elevation: const WidgetStatePropertyAll(0), + constraints: const BoxConstraints(minHeight: 52), + trailing: [ + if (query.isNotEmpty) + IconButton( + tooltip: 'Clear', + onPressed: onClear, + icon: const Icon(Icons.close), + ), + ], + onChanged: onQueryChanged, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/home/widgets/merchant_list_view.dart b/lib/presentation/home/widgets/merchant_list_view.dart index 38f5000..3b19266 100644 --- a/lib/presentation/home/widgets/merchant_list_view.dart +++ b/lib/presentation/home/widgets/merchant_list_view.dart @@ -1,233 +1,233 @@ -import 'package:e_receipt_mobile/domain/entities/merchant.dart'; -import 'package:flutter/material.dart'; - -class MerchantListView extends StatelessWidget { - const MerchantListView({ - required this.merchants, - required this.onRefresh, - required this.onMerchantTap, - required this.hasMore, - required this.isLoadingMore, - this.onLoadMore, - this.onEndReached, - this.endReachedThreshold = 240, - super.key, - }); - - final List merchants; - final Future Function() onRefresh; - final void Function(Merchant merchant) onMerchantTap; - final bool hasMore; - final bool isLoadingMore; - final VoidCallback? onLoadMore; - final VoidCallback? onEndReached; - final double endReachedThreshold; - - @override - Widget build(BuildContext context) { - final bottomInset = MediaQuery.viewPaddingOf(context).bottom; - final colorScheme = Theme.of(context).colorScheme; - - final list = merchants.isEmpty - ? ListView( - padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24), - physics: const AlwaysScrollableScrollPhysics(), - children: [ - Icon( - Icons.storefront_outlined, - size: 56, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'No merchants found', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - 'Try a different search term or pull down to refresh.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ) - : ListView.separated( - padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16), - physics: const AlwaysScrollableScrollPhysics(), - itemCount: merchants.length + (hasMore ? 1 : 0), - separatorBuilder: (_, __) => const SizedBox(height: 10), - itemBuilder: (context, index) { - if (index >= merchants.length) { - return _PaginationFooter( - isLoadingMore: isLoadingMore, - onLoadMore: onLoadMore, - ); - } - - final merchant = merchants[index]; - final name = merchant.name?.trim().isEmpty ?? true - ? '-' - : merchant.name!.trim(); - final address = (merchant.address?.trim().isEmpty ?? true) - ? 'No address' - : merchant.address!.trim(); - final enabled = merchant.id != null; - - return Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: BorderSide( - color: colorScheme.outlineVariant.withOpacity(0.55), - ), - ), - child: InkWell( - borderRadius: BorderRadius.circular(18), - onTap: enabled ? () => onMerchantTap(merchant) : null, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - child: Row( - children: [ - Container( - height: 44, - width: 44, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: enabled - ? colorScheme.primary.withOpacity(0.14) - : colorScheme.surfaceContainerHigh, - ), - child: Icon( - Icons.storefront_outlined, - color: enabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.w700, - color: enabled - ? null - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Text( - address, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: enabled - ? colorScheme.onSurfaceVariant - : colorScheme.onSurfaceVariant - .withOpacity(0.8), - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - color: enabled - ? colorScheme.onSurfaceVariant - : colorScheme.onSurfaceVariant.withOpacity(0.4), - ), - ], - ), - ), - ), - ); - }, - ); - - final wrapped = onEndReached == null || merchants.isEmpty - ? list - : NotificationListener( - onNotification: (notification) { - if (!hasMore || isLoadingMore) { - return false; - } - if (notification.metrics.axis != Axis.vertical) { - return false; - } - if (notification.metrics.maxScrollExtent <= 0) { - return false; - } - if (notification.metrics.pixels >= - notification.metrics.maxScrollExtent - endReachedThreshold) { - onEndReached?.call(); - } - return false; - }, - child: list, - ); - - return RefreshIndicator.adaptive(onRefresh: onRefresh, child: wrapped); - } -} - -class _PaginationFooter extends StatelessWidget { - const _PaginationFooter({ - required this.isLoadingMore, - required this.onLoadMore, - }); - - final bool isLoadingMore; - final VoidCallback? onLoadMore; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Expanded( - child: Text( - isLoadingMore ? 'Loading more...' : 'More results available', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ), - if (isLoadingMore) - const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - FilledButton.tonal( - onPressed: onLoadMore, - child: const Text('Load more'), - ), - ], - ), - ), - ); - } -} +import 'package:e_receipt_mobile/domain/entities/merchant.dart'; +import 'package:flutter/material.dart'; + +class MerchantListView extends StatelessWidget { + const MerchantListView({ + required this.merchants, + required this.onRefresh, + required this.onMerchantTap, + required this.hasMore, + required this.isLoadingMore, + this.onLoadMore, + this.onEndReached, + this.endReachedThreshold = 240, + super.key, + }); + + final List merchants; + final Future Function() onRefresh; + final void Function(Merchant merchant) onMerchantTap; + final bool hasMore; + final bool isLoadingMore; + final VoidCallback? onLoadMore; + final VoidCallback? onEndReached; + final double endReachedThreshold; + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final colorScheme = Theme.of(context).colorScheme; + + final list = merchants.isEmpty + ? ListView( + padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Icon( + Icons.storefront_outlined, + size: 56, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No merchants found', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'Try a different search term or pull down to refresh.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ) + : ListView.separated( + padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16), + physics: const AlwaysScrollableScrollPhysics(), + itemCount: merchants.length + (hasMore ? 1 : 0), + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + if (index >= merchants.length) { + return _PaginationFooter( + isLoadingMore: isLoadingMore, + onLoadMore: onLoadMore, + ); + } + + final merchant = merchants[index]; + final name = merchant.name?.trim().isEmpty ?? true + ? '-' + : merchant.name!.trim(); + final address = (merchant.address?.trim().isEmpty ?? true) + ? 'No address' + : merchant.address!.trim(); + final enabled = merchant.id != null; + + return Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide( + color: colorScheme.outlineVariant.withOpacity(0.55), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: enabled ? () => onMerchantTap(merchant) : null, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Row( + children: [ + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: enabled + ? colorScheme.primary.withOpacity(0.14) + : colorScheme.surfaceContainerHigh, + ), + child: Icon( + Icons.storefront_outlined, + color: enabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + color: enabled + ? null + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: enabled + ? colorScheme.onSurfaceVariant + : colorScheme.onSurfaceVariant + .withOpacity(0.8), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + color: enabled + ? colorScheme.onSurfaceVariant + : colorScheme.onSurfaceVariant.withOpacity(0.4), + ), + ], + ), + ), + ), + ); + }, + ); + + final wrapped = onEndReached == null || merchants.isEmpty + ? list + : NotificationListener( + onNotification: (notification) { + if (!hasMore || isLoadingMore) { + return false; + } + if (notification.metrics.axis != Axis.vertical) { + return false; + } + if (notification.metrics.maxScrollExtent <= 0) { + return false; + } + if (notification.metrics.pixels >= + notification.metrics.maxScrollExtent - endReachedThreshold) { + onEndReached?.call(); + } + return false; + }, + child: list, + ); + + return RefreshIndicator.adaptive(onRefresh: onRefresh, child: wrapped); + } +} + +class _PaginationFooter extends StatelessWidget { + const _PaginationFooter({ + required this.isLoadingMore, + required this.onLoadMore, + }); + + final bool isLoadingMore; + final VoidCallback? onLoadMore; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: Text( + isLoadingMore ? 'Loading more...' : 'More results available', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + if (isLoadingMore) + const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + FilledButton.tonal( + onPressed: onLoadMore, + child: const Text('Load more'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/login/login_form_providers.dart b/lib/presentation/login/login_form_providers.dart index 49af0f9..1033ccc 100644 --- a/lib/presentation/login/login_form_providers.dart +++ b/lib/presentation/login/login_form_providers.dart @@ -1,40 +1,40 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final loginFormKeyProvider = Provider.autoDispose>((ref) { - return GlobalKey(); -}); - -final loginUsernameControllerProvider = - Provider.autoDispose((ref) { - final controller = TextEditingController(); - ref.onDispose(controller.dispose); - return controller; - }); - -final loginPasswordControllerProvider = - Provider.autoDispose((ref) { - final controller = TextEditingController(); - ref.onDispose(controller.dispose); - return controller; - }); - -final loginUsernameFocusNodeProvider = Provider.autoDispose((ref) { - final focusNode = FocusNode(); - ref.onDispose(focusNode.dispose); - return focusNode; -}); - -final loginPasswordFocusNodeProvider = Provider.autoDispose((ref) { - final focusNode = FocusNode(); - ref.onDispose(focusNode.dispose); - return focusNode; -}); - -final loginObscurePasswordProvider = StateProvider.autoDispose( - (ref) => true, -); - -final loginAttemptedSubmitProvider = StateProvider.autoDispose( - (ref) => false, -); +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final loginFormKeyProvider = Provider.autoDispose>((ref) { + return GlobalKey(); +}); + +final loginUsernameControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; + }); + +final loginPasswordControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; + }); + +final loginUsernameFocusNodeProvider = Provider.autoDispose((ref) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final loginPasswordFocusNodeProvider = Provider.autoDispose((ref) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final loginObscurePasswordProvider = StateProvider.autoDispose( + (ref) => true, +); + +final loginAttemptedSubmitProvider = StateProvider.autoDispose( + (ref) => false, +); diff --git a/lib/presentation/login/login_page.dart b/lib/presentation/login/login_page.dart index badbbbe..3f17efb 100644 --- a/lib/presentation/login/login_page.dart +++ b/lib/presentation/login/login_page.dart @@ -1,329 +1,329 @@ -import 'package:e_receipt_mobile/presentation/login/login_form_providers.dart'; -import 'package:e_receipt_mobile/presentation/login/login_state.dart'; -import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; -import 'package:e_receipt_mobile/presentation/login/widgets/login_background.dart'; -import 'package:e_receipt_mobile/presentation/login/widgets/login_error_banner.dart'; -import 'package:e_receipt_mobile/presentation/reset_password/reset_password_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class LoginPage extends ConsumerWidget { - const LoginPage({super.key}); - - void _submit({ - required WidgetRef ref, - required LoginState state, - required GlobalKey formKey, - required TextEditingController usernameController, - required TextEditingController passwordController, - }) { - if (state.isLoading) { - return; - } - - ref.read(loginViewModelProvider.notifier).clearMessages(); - FocusManager.instance.primaryFocus?.unfocus(); - ref.read(loginAttemptedSubmitProvider.notifier).state = true; - - if (!(formKey.currentState?.validate() ?? false)) { - return; - } - - TextInput.finishAutofillContext(); - ref.read(loginViewModelProvider.notifier).login( - username: usernameController.text, - password: passwordController.text, - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(loginViewModelProvider); - final colorScheme = Theme.of(context).colorScheme; - - ref.listen(loginViewModelProvider, (previous, next) { - final action = next.requiredAction; - if (action == null || action == previous?.requiredAction) { - return; - } - - switch (action) { - case LoginRequiredAction.passwordUpdateRequired: - final userId = (next.requiredUserId ?? '').trim(); - if (userId.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Password update required (missing user ID)'), - behavior: SnackBarBehavior.floating, - ), - ); - ref.read(loginViewModelProvider.notifier).clearRequirement(); - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Password update required'), - behavior: SnackBarBehavior.floating, - ), - ); - ref.read(loginViewModelProvider.notifier).clearRequirement(); - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ResetPasswordPage(userId: userId)), - ); - } - }); - - final formKey = ref.watch(loginFormKeyProvider); - final usernameController = ref.watch(loginUsernameControllerProvider); - final passwordController = ref.watch(loginPasswordControllerProvider); - final usernameFocusNode = ref.watch(loginUsernameFocusNodeProvider); - final passwordFocusNode = ref.watch(loginPasswordFocusNodeProvider); - final obscurePassword = ref.watch(loginObscurePasswordProvider); - final attemptedSubmit = ref.watch(loginAttemptedSubmitProvider); - - return Scaffold( - body: AnnotatedRegion( - value: Theme.of(context).brightness == Brightness.dark - ? SystemUiOverlayStyle.light - : SystemUiOverlayStyle.dark, - child: LoginBackground( - child: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 24, - ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 440), - child: Card( - elevation: 0, - color: colorScheme.surface.withOpacity( - Theme.of(context).brightness == Brightness.dark - ? 0.75 - : 0.92, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(28), - side: BorderSide( - color: colorScheme.outlineVariant.withOpacity(0.35), - ), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), - child: AutofillGroup( - child: Form( - key: formKey, - autovalidateMode: attemptedSubmit - ? AutovalidateMode.onUserInteraction - : AutovalidateMode.disabled, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Container( - height: 44, - width: 44, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - colorScheme.primary, - colorScheme.tertiary, - ], - ), - ), - child: Icon( - Icons.receipt_long, - color: colorScheme.onPrimary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - 'E-Receipt', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - Text( - 'Sign in to continue', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme - .onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 18), - LoginErrorBanner(message: state.errorMessage), - if (state.errorMessage != null) - const SizedBox(height: 14), - TextFormField( - controller: usernameController, - focusNode: usernameFocusNode, - enabled: !state.isLoading, - textInputAction: TextInputAction.next, - autofillHints: const [AutofillHints.username], - onFieldSubmitted: (_) => - passwordFocusNode.requestFocus(), - validator: (value) { - if ((value ?? '').trim().isEmpty) { - return 'Username is required'; - } - return null; - }, - decoration: InputDecoration( - labelText: 'Username', - hintText: 'Enter your username', - prefixIcon: const Icon(Icons.person_outline), - filled: true, - fillColor: colorScheme.surfaceContainerHighest - .withOpacity( - Theme.of(context).brightness == - Brightness.dark - ? 0.55 - : 0.9, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(18), - ), - ), - ), - const SizedBox(height: 12), - TextFormField( - controller: passwordController, - focusNode: passwordFocusNode, - enabled: !state.isLoading, - textInputAction: TextInputAction.done, - autofillHints: const [AutofillHints.password], - obscureText: obscurePassword, - onFieldSubmitted: (_) => _submit( - ref: ref, - state: state, - formKey: formKey, - usernameController: usernameController, - passwordController: passwordController, - ), - validator: (value) { - if ((value ?? '').isEmpty) { - return 'Password is required'; - } - if (value!.length < 6) { - return 'Password must be at least 6 characters'; - } - return null; - }, - decoration: InputDecoration( - labelText: 'Password', - hintText: 'Enter your password', - prefixIcon: const Icon(Icons.lock_outline), - filled: true, - fillColor: colorScheme.surfaceContainerHighest - .withOpacity( - Theme.of(context).brightness == - Brightness.dark - ? 0.55 - : 0.9, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(18), - ), - suffixIcon: IconButton( - tooltip: obscurePassword - ? 'Show password' - : 'Hide password', - onPressed: state.isLoading - ? null - : () => ref - .read( - loginObscurePasswordProvider - .notifier, - ) - .state = - !obscurePassword, - icon: Icon( - obscurePassword - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - ), - ), - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: state.isLoading - ? null - : () => _submit( - ref: ref, - state: state, - formKey: formKey, - usernameController: usernameController, - passwordController: passwordController, - ), - icon: state.isLoading - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.login), - label: Padding( - padding: const EdgeInsets.symmetric( - vertical: 12, - ), - child: Text( - state.isLoading - ? 'Signing in...' - : 'Sign in', - style: const TextStyle( - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const SizedBox(height: 12), - Text( - "By continuing, you agree to your organization's policies.", - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ); - } -} +import 'package:e_receipt_mobile/presentation/login/login_form_providers.dart'; +import 'package:e_receipt_mobile/presentation/login/login_state.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:e_receipt_mobile/presentation/login/widgets/login_background.dart'; +import 'package:e_receipt_mobile/presentation/login/widgets/login_error_banner.dart'; +import 'package:e_receipt_mobile/presentation/reset_password/reset_password_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class LoginPage extends ConsumerWidget { + const LoginPage({super.key}); + + void _submit({ + required WidgetRef ref, + required LoginState state, + required GlobalKey formKey, + required TextEditingController usernameController, + required TextEditingController passwordController, + }) { + if (state.isLoading) { + return; + } + + ref.read(loginViewModelProvider.notifier).clearMessages(); + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(loginAttemptedSubmitProvider.notifier).state = true; + + if (!(formKey.currentState?.validate() ?? false)) { + return; + } + + TextInput.finishAutofillContext(); + ref.read(loginViewModelProvider.notifier).login( + username: usernameController.text, + password: passwordController.text, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(loginViewModelProvider); + final colorScheme = Theme.of(context).colorScheme; + + ref.listen(loginViewModelProvider, (previous, next) { + final action = next.requiredAction; + if (action == null || action == previous?.requiredAction) { + return; + } + + switch (action) { + case LoginRequiredAction.passwordUpdateRequired: + final userId = (next.requiredUserId ?? '').trim(); + if (userId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Password update required (missing user ID)'), + behavior: SnackBarBehavior.floating, + ), + ); + ref.read(loginViewModelProvider.notifier).clearRequirement(); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Password update required'), + behavior: SnackBarBehavior.floating, + ), + ); + ref.read(loginViewModelProvider.notifier).clearRequirement(); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ResetPasswordPage(userId: userId)), + ); + } + }); + + final formKey = ref.watch(loginFormKeyProvider); + final usernameController = ref.watch(loginUsernameControllerProvider); + final passwordController = ref.watch(loginPasswordControllerProvider); + final usernameFocusNode = ref.watch(loginUsernameFocusNodeProvider); + final passwordFocusNode = ref.watch(loginPasswordFocusNodeProvider); + final obscurePassword = ref.watch(loginObscurePasswordProvider); + final attemptedSubmit = ref.watch(loginAttemptedSubmitProvider); + + return Scaffold( + body: AnnotatedRegion( + value: Theme.of(context).brightness == Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark, + child: LoginBackground( + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 24, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Card( + elevation: 0, + color: colorScheme.surface.withOpacity( + Theme.of(context).brightness == Brightness.dark + ? 0.75 + : 0.92, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + side: BorderSide( + color: colorScheme.outlineVariant.withOpacity(0.35), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + child: AutofillGroup( + child: Form( + key: formKey, + autovalidateMode: attemptedSubmit + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primary, + colorScheme.tertiary, + ], + ), + ), + child: Icon( + Icons.receipt_long, + color: colorScheme.onPrimary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'E-Receipt', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + 'Sign in to continue', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + LoginErrorBanner(message: state.errorMessage), + if (state.errorMessage != null) + const SizedBox(height: 14), + TextFormField( + controller: usernameController, + focusNode: usernameFocusNode, + enabled: !state.isLoading, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.username], + onFieldSubmitted: (_) => + passwordFocusNode.requestFocus(), + validator: (value) { + if ((value ?? '').trim().isEmpty) { + return 'Username is required'; + } + return null; + }, + decoration: InputDecoration( + labelText: 'Username', + hintText: 'Enter your username', + prefixIcon: const Icon(Icons.person_outline), + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withOpacity( + Theme.of(context).brightness == + Brightness.dark + ? 0.55 + : 0.9, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: passwordController, + focusNode: passwordFocusNode, + enabled: !state.isLoading, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.password], + obscureText: obscurePassword, + onFieldSubmitted: (_) => _submit( + ref: ref, + state: state, + formKey: formKey, + usernameController: usernameController, + passwordController: passwordController, + ), + validator: (value) { + if ((value ?? '').isEmpty) { + return 'Password is required'; + } + if (value!.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + decoration: InputDecoration( + labelText: 'Password', + hintText: 'Enter your password', + prefixIcon: const Icon(Icons.lock_outline), + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withOpacity( + Theme.of(context).brightness == + Brightness.dark + ? 0.55 + : 0.9, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + ), + suffixIcon: IconButton( + tooltip: obscurePassword + ? 'Show password' + : 'Hide password', + onPressed: state.isLoading + ? null + : () => ref + .read( + loginObscurePasswordProvider + .notifier, + ) + .state = + !obscurePassword, + icon: Icon( + obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: state.isLoading + ? null + : () => _submit( + ref: ref, + state: state, + formKey: formKey, + usernameController: usernameController, + passwordController: passwordController, + ), + icon: state.isLoading + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.login), + label: Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + child: Text( + state.isLoading + ? 'Signing in...' + : 'Sign in', + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 12), + Text( + "By continuing, you agree to your organization's policies.", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/login/login_state.dart b/lib/presentation/login/login_state.dart index 1c88300..3d44890 100644 --- a/lib/presentation/login/login_state.dart +++ b/lib/presentation/login/login_state.dart @@ -1,52 +1,52 @@ -import 'package:e_receipt_mobile/domain/entities/login_user.dart'; -import 'package:flutter/foundation.dart'; - -enum LoginRequiredAction { passwordUpdateRequired } - -@immutable -class LoginState { - const LoginState({ - this.isLoading = false, - this.errorMessage, - this.successMessage, - this.user, - this.requiredAction, - this.requiredUserId, - }); - - final bool isLoading; - final String? errorMessage; - final String? successMessage; - final LoginUser? user; - final LoginRequiredAction? requiredAction; - final String? requiredUserId; - - LoginState copyWith({ - bool? isLoading, - String? errorMessage, - String? successMessage, - LoginUser? user, - LoginRequiredAction? requiredAction, - String? requiredUserId, - bool clearError = false, - bool clearSuccess = false, - bool clearUser = false, - bool clearRequired = false, - }) { - return LoginState( - isLoading: isLoading ?? this.isLoading, - errorMessage: clearError ? null : errorMessage ?? this.errorMessage, - successMessage: clearSuccess - ? null - : successMessage ?? this.successMessage, - user: clearUser ? null : user ?? this.user, - requiredAction: clearRequired ? null : requiredAction ?? this.requiredAction, - requiredUserId: clearRequired ? null : requiredUserId ?? this.requiredUserId, - ); - } - - @override - String toString() { - return 'LoginState(isLoading: $isLoading, errorMessage: $errorMessage, successMessage: $successMessage, user: $user, requiredAction: $requiredAction, requiredUserId: $requiredUserId)'; - } -} +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:flutter/foundation.dart'; + +enum LoginRequiredAction { passwordUpdateRequired } + +@immutable +class LoginState { + const LoginState({ + this.isLoading = false, + this.errorMessage, + this.successMessage, + this.user, + this.requiredAction, + this.requiredUserId, + }); + + final bool isLoading; + final String? errorMessage; + final String? successMessage; + final LoginUser? user; + final LoginRequiredAction? requiredAction; + final String? requiredUserId; + + LoginState copyWith({ + bool? isLoading, + String? errorMessage, + String? successMessage, + LoginUser? user, + LoginRequiredAction? requiredAction, + String? requiredUserId, + bool clearError = false, + bool clearSuccess = false, + bool clearUser = false, + bool clearRequired = false, + }) { + return LoginState( + isLoading: isLoading ?? this.isLoading, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + successMessage: clearSuccess + ? null + : successMessage ?? this.successMessage, + user: clearUser ? null : user ?? this.user, + requiredAction: clearRequired ? null : requiredAction ?? this.requiredAction, + requiredUserId: clearRequired ? null : requiredUserId ?? this.requiredUserId, + ); + } + + @override + String toString() { + return 'LoginState(isLoading: $isLoading, errorMessage: $errorMessage, successMessage: $successMessage, user: $user, requiredAction: $requiredAction, requiredUserId: $requiredUserId)'; + } +} diff --git a/lib/presentation/login/login_view_model.dart b/lib/presentation/login/login_view_model.dart index 5e23e4d..048f5c5 100644 --- a/lib/presentation/login/login_view_model.dart +++ b/lib/presentation/login/login_view_model.dart @@ -1,142 +1,142 @@ -import 'package:e_receipt_mobile/core/config/app_config.dart'; -import 'package:e_receipt_mobile/core/network/api_key_http_client.dart'; -import 'package:e_receipt_mobile/core/network/auth_http_client.dart'; -import 'package:e_receipt_mobile/core/network/logging_http_client.dart'; -import 'package:e_receipt_mobile/data/repositories/api_auth_repository.dart'; -import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; -import 'package:e_receipt_mobile/domain/exceptions/auth_exceptions.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/login/login_state.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart' as http; - -final rawHttpClientProvider = Provider((ref) { - final client = ApiKeyHttpClient( - innerClient: LoggingHttpClient(), - apiSecret: AppConfig.apiSecret, - ); - ref.onDispose(client.close); - return client; -}); - -final authenticatedHttpClientProvider = Provider((ref) { - final client = AuthHttpClient( - innerClient: ApiKeyHttpClient( - innerClient: LoggingHttpClient(), - apiSecret: AppConfig.apiSecret, - ), - accessTokenProvider: () => ref.read(sessionControllerProvider)?.token, - shouldRefreshOnUnauthorized: (uri) { - final path = uri.path; - return !(path.endsWith('/auth/login') || - path.endsWith('/auth/refresh-token') || - path.endsWith('/receipt/auth/login') || - path.endsWith('/receipt/auth/refresh-token')); - }, - refreshAccessToken: () async { - final sessionUser = ref.read(sessionControllerProvider); - if (sessionUser == null) { - return null; - } - - try { - final refreshedUser = await ref - .read(authRepositoryProvider) - .refreshSession( - username: sessionUser.username, - refreshToken: sessionUser.refreshToken, - role: sessionUser.role, - ); - ref.read(sessionControllerProvider.notifier).setUser(refreshedUser); - return refreshedUser.token; - } catch (_) { - ref.read(sessionControllerProvider.notifier).clearUser(); - return null; - } - }, - ); - ref.onDispose(client.close); - return client; -}); - -final authRepositoryProvider = Provider((ref) { - return ApiAuthRepository( - baseUrl: AppConfig.apiBaseUrl, - apiSecret: AppConfig.apiSecret, - client: ref.watch(rawHttpClientProvider), - ); -}); - -final loginViewModelProvider = - StateNotifierProvider((ref) { - return LoginViewModel( - ref.watch(authRepositoryProvider), - ref.watch(sessionControllerProvider.notifier), - ); - }); - -class LoginViewModel extends StateNotifier { - LoginViewModel(this._authRepository, this._sessionController) - : super(const LoginState()); - - final AuthRepository _authRepository; - final SessionController _sessionController; - - Future login({ - required String username, - required String password, - }) async { - final trimmedUsername = username.trim(); - if (trimmedUsername.isEmpty || password.isEmpty) { - state = state.copyWith( - isLoading: false, - errorMessage: 'Username and password are required', - clearSuccess: true, - ); - return; - } - - state = state.copyWith( - isLoading: true, - clearError: true, - clearSuccess: true, - clearUser: true, - clearRequired: true, - ); - _sessionController.clearUser(); - - try { - final user = await _authRepository.login( - username: trimmedUsername, - password: password, - ); - _sessionController.setUser(user); - state = state.copyWith( - isLoading: false, - user: user, - successMessage: 'Welcome ${user.username} (${user.role})', - ); - } on PasswordUpdateRequiredException catch (error) { - state = state.copyWith( - isLoading: false, - requiredAction: LoginRequiredAction.passwordUpdateRequired, - requiredUserId: error.userId, - clearError: true, - clearSuccess: true, - ); - } catch (error) { - state = state.copyWith( - isLoading: false, - errorMessage: error.toString().replaceFirst('Exception: ', ''), - ); - } - } - - void clearMessages() { - state = state.copyWith(clearError: true, clearSuccess: true); - } - - void clearRequirement() { - state = state.copyWith(clearRequired: true); - } -} +import 'package:e_receipt_mobile/core/config/app_config.dart'; +import 'package:e_receipt_mobile/core/network/api_key_http_client.dart'; +import 'package:e_receipt_mobile/core/network/auth_http_client.dart'; +import 'package:e_receipt_mobile/core/network/logging_http_client.dart'; +import 'package:e_receipt_mobile/data/repositories/api_auth_repository.dart'; +import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; +import 'package:e_receipt_mobile/domain/exceptions/auth_exceptions.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/login/login_state.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; + +final rawHttpClientProvider = Provider((ref) { + final client = ApiKeyHttpClient( + innerClient: LoggingHttpClient(), + apiSecret: AppConfig.apiSecret, + ); + ref.onDispose(client.close); + return client; +}); + +final authenticatedHttpClientProvider = Provider((ref) { + final client = AuthHttpClient( + innerClient: ApiKeyHttpClient( + innerClient: LoggingHttpClient(), + apiSecret: AppConfig.apiSecret, + ), + accessTokenProvider: () => ref.read(sessionControllerProvider)?.token, + shouldRefreshOnUnauthorized: (uri) { + final path = uri.path; + return !(path.endsWith('/auth/login') || + path.endsWith('/auth/refresh-token') || + path.endsWith('/receipt/auth/login') || + path.endsWith('/receipt/auth/refresh-token')); + }, + refreshAccessToken: () async { + final sessionUser = ref.read(sessionControllerProvider); + if (sessionUser == null) { + return null; + } + + try { + final refreshedUser = await ref + .read(authRepositoryProvider) + .refreshSession( + username: sessionUser.username, + refreshToken: sessionUser.refreshToken, + role: sessionUser.role, + ); + ref.read(sessionControllerProvider.notifier).setUser(refreshedUser); + return refreshedUser.token; + } catch (_) { + ref.read(sessionControllerProvider.notifier).clearUser(); + return null; + } + }, + ); + ref.onDispose(client.close); + return client; +}); + +final authRepositoryProvider = Provider((ref) { + return ApiAuthRepository( + baseUrl: AppConfig.apiBaseUrl, + apiSecret: AppConfig.apiSecret, + client: ref.watch(rawHttpClientProvider), + ); +}); + +final loginViewModelProvider = + StateNotifierProvider((ref) { + return LoginViewModel( + ref.watch(authRepositoryProvider), + ref.watch(sessionControllerProvider.notifier), + ); + }); + +class LoginViewModel extends StateNotifier { + LoginViewModel(this._authRepository, this._sessionController) + : super(const LoginState()); + + final AuthRepository _authRepository; + final SessionController _sessionController; + + Future login({ + required String username, + required String password, + }) async { + final trimmedUsername = username.trim(); + if (trimmedUsername.isEmpty || password.isEmpty) { + state = state.copyWith( + isLoading: false, + errorMessage: 'Username and password are required', + clearSuccess: true, + ); + return; + } + + state = state.copyWith( + isLoading: true, + clearError: true, + clearSuccess: true, + clearUser: true, + clearRequired: true, + ); + _sessionController.clearUser(); + + try { + final user = await _authRepository.login( + username: trimmedUsername, + password: password, + ); + _sessionController.setUser(user); + state = state.copyWith( + isLoading: false, + user: user, + successMessage: 'Welcome ${user.username} (${user.role})', + ); + } on PasswordUpdateRequiredException catch (error) { + state = state.copyWith( + isLoading: false, + requiredAction: LoginRequiredAction.passwordUpdateRequired, + requiredUserId: error.userId, + clearError: true, + clearSuccess: true, + ); + } catch (error) { + state = state.copyWith( + isLoading: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + void clearMessages() { + state = state.copyWith(clearError: true, clearSuccess: true); + } + + void clearRequirement() { + state = state.copyWith(clearRequired: true); + } +} diff --git a/lib/presentation/login/widgets/login_background.dart b/lib/presentation/login/widgets/login_background.dart index 7df812c..30f19b8 100644 --- a/lib/presentation/login/widgets/login_background.dart +++ b/lib/presentation/login/widgets/login_background.dart @@ -1,67 +1,67 @@ -import 'package:flutter/material.dart'; - -class LoginBackground extends StatelessWidget { - const LoginBackground({required this.child, super.key}); - - final Widget child; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Stack( - children: [ - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - colorScheme.primary.withOpacity(0.18), - colorScheme.secondary.withOpacity(0.10), - colorScheme.surface, - ], - stops: const [0.0, 0.45, 1.0], - ), - ), - ), - ), - Positioned( - top: -120, - right: -120, - child: _GlowBlob(color: colorScheme.primary.withOpacity(0.20)), - ), - Positioned( - bottom: -150, - left: -150, - child: _GlowBlob(color: colorScheme.tertiary.withOpacity(0.18)), - ), - child, - ], - ); - } -} - -class _GlowBlob extends StatelessWidget { - const _GlowBlob({required this.color}); - - final Color color; - - @override - Widget build(BuildContext context) { - return IgnorePointer( - child: Container( - height: 280, - width: 280, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: color, - boxShadow: [ - BoxShadow(color: color, blurRadius: 80, spreadRadius: 40), - ], - ), - ), - ); - } -} +import 'package:flutter/material.dart'; + +class LoginBackground extends StatelessWidget { + const LoginBackground({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primary.withOpacity(0.18), + colorScheme.secondary.withOpacity(0.10), + colorScheme.surface, + ], + stops: const [0.0, 0.45, 1.0], + ), + ), + ), + ), + Positioned( + top: -120, + right: -120, + child: _GlowBlob(color: colorScheme.primary.withOpacity(0.20)), + ), + Positioned( + bottom: -150, + left: -150, + child: _GlowBlob(color: colorScheme.tertiary.withOpacity(0.18)), + ), + child, + ], + ); + } +} + +class _GlowBlob extends StatelessWidget { + const _GlowBlob({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + height: 280, + width: 280, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + boxShadow: [ + BoxShadow(color: color, blurRadius: 80, spreadRadius: 40), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/login/widgets/login_error_banner.dart b/lib/presentation/login/widgets/login_error_banner.dart index 8ca00df..3bd05bc 100644 --- a/lib/presentation/login/widgets/login_error_banner.dart +++ b/lib/presentation/login/widgets/login_error_banner.dart @@ -1,44 +1,44 @@ -import 'package:flutter/material.dart'; - -class LoginErrorBanner extends StatelessWidget { - const LoginErrorBanner({required this.message, super.key}); - - final String? message; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: message == null - ? const SizedBox.shrink() - : Container( - key: ValueKey(message), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.errorContainer.withOpacity(0.9), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: colorScheme.error.withOpacity(0.25)), - ), - child: Row( - children: [ - Icon( - Icons.error_outline, - color: colorScheme.onErrorContainer, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - message!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onErrorContainer, - ), - ), - ), - ], - ), - ), - ); - } -} +import 'package:flutter/material.dart'; + +class LoginErrorBanner extends StatelessWidget { + const LoginErrorBanner({required this.message, super.key}); + + final String? message; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: message == null + ? const SizedBox.shrink() + : Container( + key: ValueKey(message), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withOpacity(0.9), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.error.withOpacity(0.25)), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/reset_password/reset_password_form_providers.dart b/lib/presentation/reset_password/reset_password_form_providers.dart index 032a23e..eff1f03 100644 --- a/lib/presentation/reset_password/reset_password_form_providers.dart +++ b/lib/presentation/reset_password/reset_password_form_providers.dart @@ -1,40 +1,40 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final resetPasswordFormKeyProvider = - Provider.autoDispose>((ref) { - return GlobalKey(); -}); - -final resetPasswordControllerProvider = - Provider.autoDispose((ref) { - final controller = TextEditingController(); - ref.onDispose(controller.dispose); - return controller; -}); - -final resetConfirmPasswordControllerProvider = - Provider.autoDispose((ref) { - final controller = TextEditingController(); - ref.onDispose(controller.dispose); - return controller; -}); - -final resetPasswordFocusNodeProvider = Provider.autoDispose((ref) { - final focusNode = FocusNode(); - ref.onDispose(focusNode.dispose); - return focusNode; -}); - -final resetConfirmPasswordFocusNodeProvider = - Provider.autoDispose((ref) { - final focusNode = FocusNode(); - ref.onDispose(focusNode.dispose); - return focusNode; -}); - -final resetObscurePasswordProvider = StateProvider.autoDispose((ref) => true); -final resetObscureConfirmPasswordProvider = - StateProvider.autoDispose((ref) => true); - -final resetAttemptedSubmitProvider = StateProvider.autoDispose((ref) => false); +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final resetPasswordFormKeyProvider = + Provider.autoDispose>((ref) { + return GlobalKey(); +}); + +final resetPasswordControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; +}); + +final resetConfirmPasswordControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; +}); + +final resetPasswordFocusNodeProvider = Provider.autoDispose((ref) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final resetConfirmPasswordFocusNodeProvider = + Provider.autoDispose((ref) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final resetObscurePasswordProvider = StateProvider.autoDispose((ref) => true); +final resetObscureConfirmPasswordProvider = + StateProvider.autoDispose((ref) => true); + +final resetAttemptedSubmitProvider = StateProvider.autoDispose((ref) => false); diff --git a/lib/presentation/reset_password/reset_password_page.dart b/lib/presentation/reset_password/reset_password_page.dart index 410104e..0c402bd 100644 --- a/lib/presentation/reset_password/reset_password_page.dart +++ b/lib/presentation/reset_password/reset_password_page.dart @@ -1,334 +1,334 @@ -import 'package:e_receipt_mobile/presentation/reset_password/reset_password_form_providers.dart'; -import 'package:e_receipt_mobile/presentation/reset_password/reset_password_view_model.dart'; -import 'package:e_receipt_mobile/presentation/login/widgets/login_background.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class ResetPasswordPage extends ConsumerWidget { - const ResetPasswordPage({required this.userId, super.key}); - - final String userId; - - bool _isStrongPassword(String value) { - return value.length >= 8 && - RegExp(r'[a-z]').hasMatch(value) && - RegExp(r'[A-Z]').hasMatch(value) && - RegExp(r'[0-9]').hasMatch(value) && - RegExp(r'[^A-Za-z0-9]').hasMatch(value); - } - - void _submit({ - required BuildContext context, - required WidgetRef ref, - required GlobalKey formKey, - required TextEditingController passwordController, - required TextEditingController confirmController, - required bool isLoading, - }) { - if (isLoading) { - return; - } - - ref.read(resetPasswordViewModelProvider.notifier).clearMessages(); - FocusManager.instance.primaryFocus?.unfocus(); - ref.read(resetAttemptedSubmitProvider.notifier).state = true; - - if (!(formKey.currentState?.validate() ?? false)) { - return; - } - - final password = passwordController.text; - final confirm = confirmController.text; - if (password != confirm) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Passwords do not match'), - behavior: SnackBarBehavior.floating, - ), - ); - return; - } - - ref - .read(resetPasswordViewModelProvider.notifier) - .resetPassword(userId: userId, password: password); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(resetPasswordViewModelProvider); - final colorScheme = Theme.of(context).colorScheme; - - ref.listen(resetPasswordViewModelProvider, (previous, next) { - final message = next.errorMessage; - if (message != null && message != previous?.errorMessage) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - behavior: SnackBarBehavior.floating, - ), - ); - } - final success = next.successMessage; - if (success != null && success != previous?.successMessage) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(success), - behavior: SnackBarBehavior.floating, - ), - ); - Navigator.of(context).pop(); - } - }); - - final formKey = ref.watch(resetPasswordFormKeyProvider); - final passwordController = ref.watch(resetPasswordControllerProvider); - final confirmController = ref.watch(resetConfirmPasswordControllerProvider); - final passwordFocusNode = ref.watch(resetPasswordFocusNodeProvider); - final confirmFocusNode = ref.watch(resetConfirmPasswordFocusNodeProvider); - final obscure = ref.watch(resetObscurePasswordProvider); - final obscureConfirm = ref.watch(resetObscureConfirmPasswordProvider); - final attemptedSubmit = ref.watch(resetAttemptedSubmitProvider); - - return Scaffold( - appBar: AppBar(title: const Text('Reset password')), - body: AnnotatedRegion( - value: Theme.of(context).brightness == Brightness.dark - ? SystemUiOverlayStyle.light - : SystemUiOverlayStyle.dark, - child: LoginBackground( - child: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: - const EdgeInsets.symmetric(horizontal: 20, vertical: 24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 440), - child: Card( - elevation: 0, - color: colorScheme.surface.withOpacity( - Theme.of(context).brightness == Brightness.dark ? 0.75 : 0.92, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(28), - side: BorderSide( - color: colorScheme.outlineVariant.withOpacity(0.35), - ), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), - child: Form( - key: formKey, - autovalidateMode: attemptedSubmit - ? AutovalidateMode.onUserInteraction - : AutovalidateMode.disabled, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Container( - height: 44, - width: 44, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - colorScheme.primary, - colorScheme.tertiary, - ], - ), - ), - child: const Icon( - Icons.lock_reset_outlined, - color: Colors.white, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - 'Update your password', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(fontWeight: FontWeight.w800), - ), - Text( - 'Your account requires a password change.', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 18), - TextFormField( - controller: passwordController, - focusNode: passwordFocusNode, - enabled: !state.isLoading, - textInputAction: TextInputAction.next, - obscureText: obscure, - onFieldSubmitted: (_) => - FocusScope.of(context).requestFocus(confirmFocusNode), - validator: (value) { - final text = (value ?? '').trim(); - if (text.isEmpty) { - return 'New password is required'; - } - if (!_isStrongPassword(text)) { - return 'Use 8+ chars with upper, lower, number, and symbol'; - } - return null; - }, - decoration: InputDecoration( - labelText: 'New password', - hintText: 'Enter a strong password', - prefixIcon: const Icon(Icons.lock_outline), - filled: true, - fillColor: colorScheme.surfaceContainerHighest - .withOpacity( - Theme.of(context).brightness == Brightness.dark - ? 0.55 - : 0.9, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(18), - ), - suffixIcon: IconButton( - tooltip: - obscure ? 'Show password' : 'Hide password', - onPressed: state.isLoading - ? null - : () => ref - .read( - resetObscurePasswordProvider.notifier, - ) - .state = - !obscure, - icon: Icon( - obscure - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - ), - ), - ), - ), - const SizedBox(height: 12), - TextFormField( - controller: confirmController, - focusNode: confirmFocusNode, - enabled: !state.isLoading, - textInputAction: TextInputAction.done, - obscureText: obscureConfirm, - onFieldSubmitted: (_) => _submit( - context: context, - ref: ref, - formKey: formKey, - passwordController: passwordController, - confirmController: confirmController, - isLoading: state.isLoading, - ), - validator: (value) { - if ((value ?? '').trim().isEmpty) { - return 'Confirm password is required'; - } - return null; - }, - decoration: InputDecoration( - labelText: 'Confirm new password', - hintText: 'Re-enter your password', - prefixIcon: const Icon(Icons.lock_outline), - filled: true, - fillColor: colorScheme.surfaceContainerHighest - .withOpacity( - Theme.of(context).brightness == Brightness.dark - ? 0.55 - : 0.9, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(18), - ), - suffixIcon: IconButton( - tooltip: obscureConfirm - ? 'Show password' - : 'Hide password', - onPressed: state.isLoading - ? null - : () => ref - .read( - resetObscureConfirmPasswordProvider - .notifier, - ) - .state = - !obscureConfirm, - icon: Icon( - obscureConfirm - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - ), - ), - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: state.isLoading - ? null - : () => _submit( - context: context, - ref: ref, - formKey: formKey, - passwordController: passwordController, - confirmController: confirmController, - isLoading: state.isLoading, - ), - icon: state.isLoading - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.check), - label: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text( - state.isLoading ? 'Updating...' : 'Reset password', - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ), - ), - const SizedBox(height: 10), - Text( - 'Tip: use a unique password you don’t use elsewhere.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ); - } -} +import 'package:e_receipt_mobile/presentation/reset_password/reset_password_form_providers.dart'; +import 'package:e_receipt_mobile/presentation/reset_password/reset_password_view_model.dart'; +import 'package:e_receipt_mobile/presentation/login/widgets/login_background.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ResetPasswordPage extends ConsumerWidget { + const ResetPasswordPage({required this.userId, super.key}); + + final String userId; + + bool _isStrongPassword(String value) { + return value.length >= 8 && + RegExp(r'[a-z]').hasMatch(value) && + RegExp(r'[A-Z]').hasMatch(value) && + RegExp(r'[0-9]').hasMatch(value) && + RegExp(r'[^A-Za-z0-9]').hasMatch(value); + } + + void _submit({ + required BuildContext context, + required WidgetRef ref, + required GlobalKey formKey, + required TextEditingController passwordController, + required TextEditingController confirmController, + required bool isLoading, + }) { + if (isLoading) { + return; + } + + ref.read(resetPasswordViewModelProvider.notifier).clearMessages(); + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(resetAttemptedSubmitProvider.notifier).state = true; + + if (!(formKey.currentState?.validate() ?? false)) { + return; + } + + final password = passwordController.text; + final confirm = confirmController.text; + if (password != confirm) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Passwords do not match'), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + ref + .read(resetPasswordViewModelProvider.notifier) + .resetPassword(userId: userId, password: password); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(resetPasswordViewModelProvider); + final colorScheme = Theme.of(context).colorScheme; + + ref.listen(resetPasswordViewModelProvider, (previous, next) { + final message = next.errorMessage; + if (message != null && message != previous?.errorMessage) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + ), + ); + } + final success = next.successMessage; + if (success != null && success != previous?.successMessage) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success), + behavior: SnackBarBehavior.floating, + ), + ); + Navigator.of(context).pop(); + } + }); + + final formKey = ref.watch(resetPasswordFormKeyProvider); + final passwordController = ref.watch(resetPasswordControllerProvider); + final confirmController = ref.watch(resetConfirmPasswordControllerProvider); + final passwordFocusNode = ref.watch(resetPasswordFocusNodeProvider); + final confirmFocusNode = ref.watch(resetConfirmPasswordFocusNodeProvider); + final obscure = ref.watch(resetObscurePasswordProvider); + final obscureConfirm = ref.watch(resetObscureConfirmPasswordProvider); + final attemptedSubmit = ref.watch(resetAttemptedSubmitProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Reset password')), + body: AnnotatedRegion( + value: Theme.of(context).brightness == Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark, + child: LoginBackground( + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Card( + elevation: 0, + color: colorScheme.surface.withOpacity( + Theme.of(context).brightness == Brightness.dark ? 0.75 : 0.92, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + side: BorderSide( + color: colorScheme.outlineVariant.withOpacity(0.35), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + child: Form( + key: formKey, + autovalidateMode: attemptedSubmit + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primary, + colorScheme.tertiary, + ], + ), + ), + child: const Icon( + Icons.lock_reset_outlined, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Update your password', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.w800), + ), + Text( + 'Your account requires a password change.', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + TextFormField( + controller: passwordController, + focusNode: passwordFocusNode, + enabled: !state.isLoading, + textInputAction: TextInputAction.next, + obscureText: obscure, + onFieldSubmitted: (_) => + FocusScope.of(context).requestFocus(confirmFocusNode), + validator: (value) { + final text = (value ?? '').trim(); + if (text.isEmpty) { + return 'New password is required'; + } + if (!_isStrongPassword(text)) { + return 'Use 8+ chars with upper, lower, number, and symbol'; + } + return null; + }, + decoration: InputDecoration( + labelText: 'New password', + hintText: 'Enter a strong password', + prefixIcon: const Icon(Icons.lock_outline), + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withOpacity( + Theme.of(context).brightness == Brightness.dark + ? 0.55 + : 0.9, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + ), + suffixIcon: IconButton( + tooltip: + obscure ? 'Show password' : 'Hide password', + onPressed: state.isLoading + ? null + : () => ref + .read( + resetObscurePasswordProvider.notifier, + ) + .state = + !obscure, + icon: Icon( + obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: confirmController, + focusNode: confirmFocusNode, + enabled: !state.isLoading, + textInputAction: TextInputAction.done, + obscureText: obscureConfirm, + onFieldSubmitted: (_) => _submit( + context: context, + ref: ref, + formKey: formKey, + passwordController: passwordController, + confirmController: confirmController, + isLoading: state.isLoading, + ), + validator: (value) { + if ((value ?? '').trim().isEmpty) { + return 'Confirm password is required'; + } + return null; + }, + decoration: InputDecoration( + labelText: 'Confirm new password', + hintText: 'Re-enter your password', + prefixIcon: const Icon(Icons.lock_outline), + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withOpacity( + Theme.of(context).brightness == Brightness.dark + ? 0.55 + : 0.9, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + ), + suffixIcon: IconButton( + tooltip: obscureConfirm + ? 'Show password' + : 'Hide password', + onPressed: state.isLoading + ? null + : () => ref + .read( + resetObscureConfirmPasswordProvider + .notifier, + ) + .state = + !obscureConfirm, + icon: Icon( + obscureConfirm + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: state.isLoading + ? null + : () => _submit( + context: context, + ref: ref, + formKey: formKey, + passwordController: passwordController, + confirmController: confirmController, + isLoading: state.isLoading, + ), + icon: state.isLoading + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + label: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + state.isLoading ? 'Updating...' : 'Reset password', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(height: 10), + Text( + 'Tip: use a unique password you don’t use elsewhere.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/reset_password/reset_password_state.dart b/lib/presentation/reset_password/reset_password_state.dart index 015da9f..d57d821 100644 --- a/lib/presentation/reset_password/reset_password_state.dart +++ b/lib/presentation/reset_password/reset_password_state.dart @@ -1,28 +1,28 @@ -import 'package:flutter/foundation.dart'; - -@immutable -class ResetPasswordState { - const ResetPasswordState({ - this.isLoading = false, - this.errorMessage, - this.successMessage, - }); - - final bool isLoading; - final String? errorMessage; - final String? successMessage; - - ResetPasswordState copyWith({ - bool? isLoading, - String? errorMessage, - String? successMessage, - bool clearError = false, - bool clearSuccess = false, - }) { - return ResetPasswordState( - isLoading: isLoading ?? this.isLoading, - errorMessage: clearError ? null : errorMessage ?? this.errorMessage, - successMessage: clearSuccess ? null : successMessage ?? this.successMessage, - ); - } -} +import 'package:flutter/foundation.dart'; + +@immutable +class ResetPasswordState { + const ResetPasswordState({ + this.isLoading = false, + this.errorMessage, + this.successMessage, + }); + + final bool isLoading; + final String? errorMessage; + final String? successMessage; + + ResetPasswordState copyWith({ + bool? isLoading, + String? errorMessage, + String? successMessage, + bool clearError = false, + bool clearSuccess = false, + }) { + return ResetPasswordState( + isLoading: isLoading ?? this.isLoading, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + successMessage: clearSuccess ? null : successMessage ?? this.successMessage, + ); + } +} diff --git a/lib/presentation/reset_password/reset_password_view_model.dart b/lib/presentation/reset_password/reset_password_view_model.dart index f754d70..ac8db86 100644 --- a/lib/presentation/reset_password/reset_password_view_model.dart +++ b/lib/presentation/reset_password/reset_password_view_model.dart @@ -1,54 +1,54 @@ -import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; -import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; -import 'package:e_receipt_mobile/presentation/reset_password/reset_password_state.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final resetPasswordViewModelProvider = - StateNotifierProvider.autoDispose( - (ref) { - return ResetPasswordViewModel(ref.watch(authRepositoryProvider)); - }, -); - -class ResetPasswordViewModel extends StateNotifier { - ResetPasswordViewModel(this._authRepository) : super(const ResetPasswordState()); - - final AuthRepository _authRepository; - - Future resetPassword({ - required String userId, - required String password, - }) async { - if (userId.trim().isEmpty) { - state = state.copyWith( - isLoading: false, - errorMessage: 'Missing user ID', - clearSuccess: true, - ); - return; - } - - state = state.copyWith( - isLoading: true, - clearError: true, - clearSuccess: true, - ); - - try { - await _authRepository.resetPassword(userId: userId.trim(), password: password); - state = state.copyWith( - isLoading: false, - successMessage: 'Password updated', - ); - } catch (error) { - state = state.copyWith( - isLoading: false, - errorMessage: error.toString().replaceFirst('Exception: ', ''), - ); - } - } - - void clearMessages() { - state = state.copyWith(clearError: true, clearSuccess: true); - } -} +import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:e_receipt_mobile/presentation/reset_password/reset_password_state.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final resetPasswordViewModelProvider = + StateNotifierProvider.autoDispose( + (ref) { + return ResetPasswordViewModel(ref.watch(authRepositoryProvider)); + }, +); + +class ResetPasswordViewModel extends StateNotifier { + ResetPasswordViewModel(this._authRepository) : super(const ResetPasswordState()); + + final AuthRepository _authRepository; + + Future resetPassword({ + required String userId, + required String password, + }) async { + if (userId.trim().isEmpty) { + state = state.copyWith( + isLoading: false, + errorMessage: 'Missing user ID', + clearSuccess: true, + ); + return; + } + + state = state.copyWith( + isLoading: true, + clearError: true, + clearSuccess: true, + ); + + try { + await _authRepository.resetPassword(userId: userId.trim(), password: password); + state = state.copyWith( + isLoading: false, + successMessage: 'Password updated', + ); + } catch (error) { + state = state.copyWith( + isLoading: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + void clearMessages() { + state = state.copyWith(clearError: true, clearSuccess: true); + } +} diff --git a/lib/presentation/settings/settings_screen.dart b/lib/presentation/settings/settings_screen.dart index 5c7ac81..4f002c5 100644 --- a/lib/presentation/settings/settings_screen.dart +++ b/lib/presentation/settings/settings_screen.dart @@ -1,65 +1,65 @@ -import 'package:e_receipt_mobile/presentation/settings/theme_mode_provider.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class SettingsScreen extends ConsumerWidget { - const SettingsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final selectedMode = ref.watch(appThemeModeProvider); - - return Scaffold( - appBar: AppBar( - title: const Text('Settings'), - centerTitle: true, - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - Text( - 'Theme', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Card( - child: Column( - children: [ - RadioListTile( - title: const Text('Light'), - value: ThemeMode.light, - groupValue: selectedMode, - onChanged: (value) { - if (value != null) { - ref.read(appThemeModeProvider.notifier).state = value; - } - }, - ), - RadioListTile( - title: const Text('Dark'), - value: ThemeMode.dark, - groupValue: selectedMode, - onChanged: (value) { - if (value != null) { - ref.read(appThemeModeProvider.notifier).state = value; - } - }, - ), - RadioListTile( - title: const Text('System'), - value: ThemeMode.system, - groupValue: selectedMode, - onChanged: (value) { - if (value != null) { - ref.read(appThemeModeProvider.notifier).state = value; - } - }, - ), - ], - ), - ), - ], - ), - ); - } -} +import 'package:e_receipt_mobile/presentation/settings/theme_mode_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedMode = ref.watch(appThemeModeProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + centerTitle: true, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Theme', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Card( + child: Column( + children: [ + RadioListTile( + title: const Text('Light'), + value: ThemeMode.light, + groupValue: selectedMode, + onChanged: (value) { + if (value != null) { + ref.read(appThemeModeProvider.notifier).state = value; + } + }, + ), + RadioListTile( + title: const Text('Dark'), + value: ThemeMode.dark, + groupValue: selectedMode, + onChanged: (value) { + if (value != null) { + ref.read(appThemeModeProvider.notifier).state = value; + } + }, + ), + RadioListTile( + title: const Text('System'), + value: ThemeMode.system, + groupValue: selectedMode, + onChanged: (value) { + if (value != null) { + ref.read(appThemeModeProvider.notifier).state = value; + } + }, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/settings/theme_mode_provider.dart b/lib/presentation/settings/theme_mode_provider.dart index 040c546..4e2504c 100644 --- a/lib/presentation/settings/theme_mode_provider.dart +++ b/lib/presentation/settings/theme_mode_provider.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final appThemeModeProvider = StateProvider((ref) { - return ThemeMode.system; -}); +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final appThemeModeProvider = StateProvider((ref) { + return ThemeMode.system; +}); diff --git a/lib/presentation/terminal/receipt_view_model.dart b/lib/presentation/terminal/receipt_view_model.dart index e29893c..2e9d72a 100644 --- a/lib/presentation/terminal/receipt_view_model.dart +++ b/lib/presentation/terminal/receipt_view_model.dart @@ -1,78 +1,78 @@ -import 'dart:io'; - -import 'package:e_receipt_mobile/core/config/app_config.dart'; -import 'package:e_receipt_mobile/data/repositories/api_receipt_repository.dart'; -import 'package:e_receipt_mobile/domain/repositories/receipt_repository.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class TransactionReceiptQuery { - const TransactionReceiptQuery({ - required this.transactionId, - required this.copyFor, - }); - - final String transactionId; - final String copyFor; - - @override - bool operator ==(Object other) { - return other is TransactionReceiptQuery && - other.transactionId == transactionId && - other.copyFor == copyFor; - } - - @override - int get hashCode => Object.hash(transactionId, copyFor); -} - -sealed class ReceiptViewData { - const ReceiptViewData(); -} - -class ReceiptPdfViewData extends ReceiptViewData { - const ReceiptPdfViewData(this.file); - - final File file; -} - -final receiptRepositoryProvider = Provider((ref) { - return ApiReceiptRepository( - baseUrl: AppConfig.apiBaseUrl, - apiSecret: AppConfig.apiSecret, - client: ref.watch(authenticatedHttpClientProvider), - ); -}); - -final transactionReceiptViewDataProvider = - FutureProvider.family(( - ref, - query, - ) async { - try { - final sessionUser = ref.watch(sessionControllerProvider); - if (sessionUser == null) { - throw Exception('No active session'); - } - - final bytes = await ref - .watch(receiptRepositoryProvider) - .getTransactionReceiptPdfBytes( - token: sessionUser.token, - transactionId: query.transactionId, - copyFor: query.copyFor, - ); - - final dir = await Directory.systemTemp.createTemp('e_receipt_mobile'); - final file = File( - '${dir.path}/receipt_${query.transactionId}_${query.copyFor}.pdf', - ); - await file.writeAsBytes(bytes, flush: true); - return ReceiptPdfViewData(file); - } catch (e, st) { - debugPrint('[RECEIPT][ERROR] ${Error.safeToString(e)}\n$st'); - throw Exception(Error.safeToString(e)); - } - }); +import 'dart:io'; + +import 'package:e_receipt_mobile/core/config/app_config.dart'; +import 'package:e_receipt_mobile/data/repositories/api_receipt_repository.dart'; +import 'package:e_receipt_mobile/domain/repositories/receipt_repository.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class TransactionReceiptQuery { + const TransactionReceiptQuery({ + required this.transactionId, + required this.copyFor, + }); + + final String transactionId; + final String copyFor; + + @override + bool operator ==(Object other) { + return other is TransactionReceiptQuery && + other.transactionId == transactionId && + other.copyFor == copyFor; + } + + @override + int get hashCode => Object.hash(transactionId, copyFor); +} + +sealed class ReceiptViewData { + const ReceiptViewData(); +} + +class ReceiptPdfViewData extends ReceiptViewData { + const ReceiptPdfViewData(this.file); + + final File file; +} + +final receiptRepositoryProvider = Provider((ref) { + return ApiReceiptRepository( + baseUrl: AppConfig.apiBaseUrl, + apiSecret: AppConfig.apiSecret, + client: ref.watch(authenticatedHttpClientProvider), + ); +}); + +final transactionReceiptViewDataProvider = + FutureProvider.family(( + ref, + query, + ) async { + try { + final sessionUser = ref.watch(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + final bytes = await ref + .watch(receiptRepositoryProvider) + .getTransactionReceiptPdfBytes( + token: sessionUser.token, + transactionId: query.transactionId, + copyFor: query.copyFor, + ); + + final dir = await Directory.systemTemp.createTemp('e_receipt_mobile'); + final file = File( + '${dir.path}/receipt_${query.transactionId}_${query.copyFor}.pdf', + ); + await file.writeAsBytes(bytes, flush: true); + return ReceiptPdfViewData(file); + } catch (e, st) { + debugPrint('[RECEIPT][ERROR] ${Error.safeToString(e)}\n$st'); + throw Exception(Error.safeToString(e)); + } + }); diff --git a/lib/presentation/terminal/terminal_next_screen.dart b/lib/presentation/terminal/terminal_next_screen.dart index 04f1ccd..0f94be3 100644 --- a/lib/presentation/terminal/terminal_next_screen.dart +++ b/lib/presentation/terminal/terminal_next_screen.dart @@ -1,756 +1,756 @@ -import 'package:e_receipt_mobile/domain/entities/terminal.dart'; -import 'package:e_receipt_mobile/presentation/auth/logout_view_model.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_state.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_view_model.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_receipt_screen.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_view_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class TerminalNextScreen extends ConsumerStatefulWidget { - const TerminalNextScreen({ - required this.merchantName, - required this.terminal, - super.key, - }); - - final String merchantName; - final Terminal terminal; - - @override - ConsumerState createState() => _TerminalNextScreenState(); -} - -class _TerminalNextScreenState extends ConsumerState { - static const String _selectedRange = '4m'; - - @override - Widget build(BuildContext context) { - final user = ref.watch(sessionControllerProvider); - final isCashier = (user?.role ?? '').trim().toLowerCase() == 'cashier'; - final logoutState = ref.watch(logoutViewModelProvider); - final serial = widget.terminal.serial?.trim() ?? ''; - final hasSerial = serial.isNotEmpty; - final query = TransactionQuery(serial: serial, range: _selectedRange); - final pagingState = hasSerial - ? ref.watch(transactionPagingViewModelProvider(query)) - : null; - final pagingViewModel = hasSerial - ? ref.read(transactionPagingViewModelProvider(query).notifier) - : null; - final colorScheme = Theme.of(context).colorScheme; - final terminalName = - _sanitizeMultiline(widget.terminal.name) ?? widget.terminal.tid ?? '-'; - - return Scaffold( - appBar: AppBar( - title: Text(terminalName), - actions: [ - if (isCashier) - IconButton( - tooltip: 'Account', - icon: const Icon(Icons.more_vert), - onPressed: logoutState.isLoading - ? null - : () => _showCashierMenu( - context, - username: (user?.username ?? '').trim(), - ), - ), - ], - ), - body: SafeArea( - child: RefreshIndicator.adaptive( - onRefresh: () async { - if (!hasSerial || pagingViewModel == null) { - return; - } - await pagingViewModel.refresh(); - }, - child: NotificationListener( - onNotification: (notification) { - if (!hasSerial || - pagingState == null || - pagingViewModel == null || - !pagingState.hasMore || - pagingState.isLoadingMore) { - return false; - } - if (notification.metrics.axis != Axis.vertical) { - return false; - } - if (notification.metrics.maxScrollExtent <= 0) { - return false; - } - if (notification.metrics.pixels >= - notification.metrics.maxScrollExtent - 240) { - pagingViewModel.loadMore(); - } - return false; - }, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), - side: BorderSide( - color: colorScheme.outlineVariant.withOpacity(0.55), - ), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Container( - height: 44, - width: 44, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: colorScheme.primary.withOpacity( - 0.14, - ), - ), - child: Icon( - Icons.point_of_sale_outlined, - color: colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - widget.merchantName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - Text( - terminalName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: - colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - _InfoRow( - label: 'Serial', - value: widget.terminal.serial, - ), - _InfoRow( - label: 'Address', - value: _sanitizeMultiline( - widget.terminal.address, - ), - ), - ], - ), - ), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - 'Transactions (4M)', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - ), - ), - if (!hasSerial) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.info_outline, - size: 56, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'Serial is required to load transactions', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - 'This terminal does not have a serial number.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ) - else - ..._buildPagingSlivers( - context: context, - colorScheme: colorScheme, - pagingState: pagingState!, - pagingViewModel: pagingViewModel!, - ), - ], - ), - ), - ), - ), - ); - } - - Future _showCashierMenu( - BuildContext context, { - required String username, - }) async { - HapticFeedback.selectionClick(); - - final action = await showModalBottomSheet<_CashierMenuAction>( - context: context, - showDragHandle: true, - isScrollControlled: true, - useSafeArea: true, - builder: (context) { - final bottomInset = MediaQuery.viewPaddingOf(context).bottom; - final displayName = username.isEmpty ? 'Cashier' : username; - final initial = displayName.trim().isEmpty - ? '?' - : displayName.trim().characters.first.toUpperCase(); - - return Padding( - padding: EdgeInsets.fromLTRB(8, 0, 8, 16 + bottomInset), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: CircleAvatar(child: Text(initial)), - title: Text(displayName), - subtitle: const Text('Cashier'), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.logout, color: Colors.redAccent), - title: const Text('Logout'), - onTap: () => Navigator.of(context).pop(_CashierMenuAction.logout), - ), - ], - ), - ); - }, - ); - - if (!mounted || action == null) { - return; - } - - switch (action) { - case _CashierMenuAction.logout: - await _logout(context); - } - } - - Future _logout(BuildContext context) async { - final shouldLogout = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Logout'), - content: const Text('Are you sure you want to logout?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton.tonal( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Logout'), - ), - ], - ); - }, - ); - - if (shouldLogout != true || !context.mounted) { - return; - } - - HapticFeedback.mediumImpact(); - Navigator.of(context).popUntil((route) => route.isFirst); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Signed out'), - behavior: SnackBarBehavior.floating, - ), - ); - - await ref.read(logoutViewModelProvider.notifier).logout(); - } - - List _buildPagingSlivers({ - required BuildContext context, - required ColorScheme colorScheme, - required TransactionPagingState pagingState, - required TransactionPagingViewModel pagingViewModel, - }) { - if (pagingState.isLoading && pagingState.items.isEmpty) { - return const [ - SliverFillRemaining(child: Center(child: CircularProgressIndicator())), - ]; - } - - if (pagingState.errorMessage != null && pagingState.items.isEmpty) { - return [ - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.wifi_off_outlined, - size: 56, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'Failed to load transactions', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - pagingState.errorMessage!, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 14), - FilledButton.tonal( - onPressed: pagingViewModel.refresh, - child: const Text('Try again'), - ), - ], - ), - ), - ), - ), - ]; - } - - if (pagingState.items.isEmpty) { - return [ - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.receipt_long_outlined, - size: 56, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'No transactions found', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - 'Pull down to refresh.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ), - ]; - } - - final bottomInset = MediaQuery.viewPaddingOf(context).bottom; - final items = pagingState.items; - final hasFooter = pagingState.hasMore; - - return [ - SliverPadding( - padding: EdgeInsets.fromLTRB(16, 0, 16, bottomInset + 16), - sliver: SliverList.separated( - itemCount: items.length + (hasFooter ? 1 : 0), - separatorBuilder: (_, __) => const SizedBox(height: 10), - itemBuilder: (context, index) { - if (index >= items.length) { - return _TransactionPaginationFooter( - isLoadingMore: pagingState.isLoadingMore, - onLoadMore: pagingViewModel.loadMore, - ); - } - - final item = items[index]; - final raw = item.raw ?? const {}; - final amount = - _first(raw, const ['DE4']) ?? item.amount?.toString() ?? '-'; - final currency = _first(raw, const ['DE49']) ?? 'MMK'; - final status = _statusLabel( - _first(raw, const ['DE39']) ?? - item.status, - ); - final type = _first(raw, const ['DE3']) ?? item.type ?? '-'; - final rrn = _first(raw, const ['DE37']) ?? item.rrn ?? '-'; - final createdAt = - _first(raw, const ['CREATED_AT', 'de7_date']) ?? item.createdAt; - - return Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: BorderSide( - color: colorScheme.outlineVariant.withOpacity(0.55), - ), - ), - child: InkWell( - borderRadius: BorderRadius.circular(18), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TransactionReceiptScreen( - merchantName: widget.merchantName, - terminal: widget.terminal, - transaction: item, - ), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - child: Row( - children: [ - Container( - height: 44, - width: 44, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: colorScheme.primary.withOpacity(0.14), - ), - child: Icon( - Icons.receipt_long_outlined, - color: colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - '$amount $currency', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.w800), - ), - ), - const SizedBox(width: 8), - _StatusChip(status: status), - ], - ), - const SizedBox(height: 4), - Text( - 'Type: $type • RRN: $rrn', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if ((createdAt ?? '').trim().isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - createdAt!.trim(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ); - }, - ), - ), - if (pagingState.errorMessage != null && pagingState.items.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB( - 16, - 8, - 16, - MediaQuery.viewPaddingOf(context).bottom + 8, - ), - child: Material( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon( - Icons.error_outline, - color: colorScheme.onErrorContainer, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - pagingState.errorMessage!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onErrorContainer, - ), - ), - ), - const SizedBox(width: 8), - TextButton( - onPressed: pagingViewModel.loadMore, - child: const Text('Retry'), - ), - ], - ), - ), - ), - ), - ), - ]; - } - - String? _first(Map raw, List keys) { - for (final key in keys) { - final value = raw[key]; - if (value != null) { - final text = value.toString().trim(); - if (text.isNotEmpty) { - return text; - } - } - } - return null; - } - - String _statusLabel(String? status) { - final normalized = status?.trim().toUpperCase(); - if (normalized == 'A') { - return 'SUCCESS'; - } - if (normalized == 'E' ) { - return 'FAILED'; - } - return 'SUCCESS'; - - } - - String? _sanitizeMultiline(String? value) { - if (value == null) { - return null; - } - final cleaned = value - .replaceAll('\r', ' ') - .replaceAll('\n', ' ') - .replaceAll(r'\n', ' ') - .replaceAll(r'/n', ' ') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - return cleaned.isEmpty ? null : cleaned; - } -} - -class _TransactionPaginationFooter extends StatelessWidget { - const _TransactionPaginationFooter({ - required this.isLoadingMore, - required this.onLoadMore, - }); - - final bool isLoadingMore; - final VoidCallback onLoadMore; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Expanded( - child: Text( - isLoadingMore ? 'Loading more...' : 'More results available', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ), - if (isLoadingMore) - const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - FilledButton.tonal( - onPressed: onLoadMore, - child: const Text('Load more'), - ), - ], - ), - ), - ); - } -} - -class _InfoRow extends StatelessWidget { - const _InfoRow({required this.label, required this.value}); - - final String label; - final String? value; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final text = (value ?? '').trim().isEmpty ? '-' : value!.trim(); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - text, - textAlign: TextAlign.right, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} - -class _StatusChip extends StatelessWidget { - const _StatusChip({required this.status}); - - final String status; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final normalized = status.toUpperCase(); - final isSuccess = normalized == 'SUCCESS'; - - final background = isSuccess - ? Colors.green.withOpacity(0.12) - : colorScheme.errorContainer.withOpacity(0.9); - final foreground = isSuccess - ? Colors.green.shade700 - : colorScheme.onErrorContainer; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: foreground.withOpacity(0.25)), - ), - child: Text( - normalized.isEmpty ? '-' : normalized, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w800, - color: foreground, - ), - ), - ); - } -} - -enum _CashierMenuAction { logout } +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:e_receipt_mobile/presentation/auth/logout_view_model.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_state.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_view_model.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_receipt_screen.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class TerminalNextScreen extends ConsumerStatefulWidget { + const TerminalNextScreen({ + required this.merchantName, + required this.terminal, + super.key, + }); + + final String merchantName; + final Terminal terminal; + + @override + ConsumerState createState() => _TerminalNextScreenState(); +} + +class _TerminalNextScreenState extends ConsumerState { + static const String _selectedRange = '4m'; + + @override + Widget build(BuildContext context) { + final user = ref.watch(sessionControllerProvider); + final isCashier = (user?.role ?? '').trim().toLowerCase() == 'cashier'; + final logoutState = ref.watch(logoutViewModelProvider); + final serial = widget.terminal.serial?.trim() ?? ''; + final hasSerial = serial.isNotEmpty; + final query = TransactionQuery(serial: serial, range: _selectedRange); + final pagingState = hasSerial + ? ref.watch(transactionPagingViewModelProvider(query)) + : null; + final pagingViewModel = hasSerial + ? ref.read(transactionPagingViewModelProvider(query).notifier) + : null; + final colorScheme = Theme.of(context).colorScheme; + final terminalName = + _sanitizeMultiline(widget.terminal.name) ?? widget.terminal.tid ?? '-'; + + return Scaffold( + appBar: AppBar( + title: Text(terminalName), + actions: [ + if (isCashier) + IconButton( + tooltip: 'Account', + icon: const Icon(Icons.more_vert), + onPressed: logoutState.isLoading + ? null + : () => _showCashierMenu( + context, + username: (user?.username ?? '').trim(), + ), + ), + ], + ), + body: SafeArea( + child: RefreshIndicator.adaptive( + onRefresh: () async { + if (!hasSerial || pagingViewModel == null) { + return; + } + await pagingViewModel.refresh(); + }, + child: NotificationListener( + onNotification: (notification) { + if (!hasSerial || + pagingState == null || + pagingViewModel == null || + !pagingState.hasMore || + pagingState.isLoadingMore) { + return false; + } + if (notification.metrics.axis != Axis.vertical) { + return false; + } + if (notification.metrics.maxScrollExtent <= 0) { + return false; + } + if (notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 240) { + pagingViewModel.loadMore(); + } + return false; + }, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), + child: Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + side: BorderSide( + color: colorScheme.outlineVariant.withOpacity(0.55), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: colorScheme.primary.withOpacity( + 0.14, + ), + ), + child: Icon( + Icons.point_of_sale_outlined, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + widget.merchantName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + Text( + terminalName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: + colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + _InfoRow( + label: 'Serial', + value: widget.terminal.serial, + ), + _InfoRow( + label: 'Address', + value: _sanitizeMultiline( + widget.terminal.address, + ), + ), + ], + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'Transactions (4M)', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (!hasSerial) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.info_outline, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'Serial is required to load transactions', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'This terminal does not have a serial number.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ) + else + ..._buildPagingSlivers( + context: context, + colorScheme: colorScheme, + pagingState: pagingState!, + pagingViewModel: pagingViewModel!, + ), + ], + ), + ), + ), + ), + ); + } + + Future _showCashierMenu( + BuildContext context, { + required String username, + }) async { + HapticFeedback.selectionClick(); + + final action = await showModalBottomSheet<_CashierMenuAction>( + context: context, + showDragHandle: true, + isScrollControlled: true, + useSafeArea: true, + builder: (context) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final displayName = username.isEmpty ? 'Cashier' : username; + final initial = displayName.trim().isEmpty + ? '?' + : displayName.trim().characters.first.toUpperCase(); + + return Padding( + padding: EdgeInsets.fromLTRB(8, 0, 8, 16 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: CircleAvatar(child: Text(initial)), + title: Text(displayName), + subtitle: const Text('Cashier'), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.logout, color: Colors.redAccent), + title: const Text('Logout'), + onTap: () => Navigator.of(context).pop(_CashierMenuAction.logout), + ), + ], + ), + ); + }, + ); + + if (!mounted || action == null) { + return; + } + + switch (action) { + case _CashierMenuAction.logout: + await _logout(context); + } + } + + Future _logout(BuildContext context) async { + final shouldLogout = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Logout'), + content: const Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Logout'), + ), + ], + ); + }, + ); + + if (shouldLogout != true || !context.mounted) { + return; + } + + HapticFeedback.mediumImpact(); + Navigator.of(context).popUntil((route) => route.isFirst); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Signed out'), + behavior: SnackBarBehavior.floating, + ), + ); + + await ref.read(logoutViewModelProvider.notifier).logout(); + } + + List _buildPagingSlivers({ + required BuildContext context, + required ColorScheme colorScheme, + required TransactionPagingState pagingState, + required TransactionPagingViewModel pagingViewModel, + }) { + if (pagingState.isLoading && pagingState.items.isEmpty) { + return const [ + SliverFillRemaining(child: Center(child: CircularProgressIndicator())), + ]; + } + + if (pagingState.errorMessage != null && pagingState.items.isEmpty) { + return [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_off_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'Failed to load transactions', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + pagingState.errorMessage!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + FilledButton.tonal( + onPressed: pagingViewModel.refresh, + child: const Text('Try again'), + ), + ], + ), + ), + ), + ), + ]; + } + + if (pagingState.items.isEmpty) { + return [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.receipt_long_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No transactions found', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'Pull down to refresh.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ]; + } + + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final items = pagingState.items; + final hasFooter = pagingState.hasMore; + + return [ + SliverPadding( + padding: EdgeInsets.fromLTRB(16, 0, 16, bottomInset + 16), + sliver: SliverList.separated( + itemCount: items.length + (hasFooter ? 1 : 0), + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + if (index >= items.length) { + return _TransactionPaginationFooter( + isLoadingMore: pagingState.isLoadingMore, + onLoadMore: pagingViewModel.loadMore, + ); + } + + final item = items[index]; + final raw = item.raw ?? const {}; + final amount = + _first(raw, const ['DE4']) ?? item.amount?.toString() ?? '-'; + final currency = _first(raw, const ['DE49']) ?? 'MMK'; + final status = _statusLabel( + _first(raw, const ['DE39']) ?? + item.status, + ); + final type = _first(raw, const ['DE3']) ?? item.type ?? '-'; + final rrn = _first(raw, const ['DE37']) ?? item.rrn ?? '-'; + final createdAt = + _first(raw, const ['CREATED_AT', 'de7_date']) ?? item.createdAt; + + return Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide( + color: colorScheme.outlineVariant.withOpacity(0.55), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TransactionReceiptScreen( + merchantName: widget.merchantName, + terminal: widget.terminal, + transaction: item, + ), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Row( + children: [ + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: colorScheme.primary.withOpacity(0.14), + ), + child: Icon( + Icons.receipt_long_outlined, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '$amount $currency', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w800), + ), + ), + const SizedBox(width: 8), + _StatusChip(status: status), + ], + ), + const SizedBox(height: 4), + Text( + 'Type: $type • RRN: $rrn', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if ((createdAt ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + createdAt!.trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + }, + ), + ), + if (pagingState.errorMessage != null && pagingState.items.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + MediaQuery.viewPaddingOf(context).bottom + 8, + ), + child: Material( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + pagingState.errorMessage!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: pagingViewModel.loadMore, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ), + ), + ]; + } + + String? _first(Map raw, List keys) { + for (final key in keys) { + final value = raw[key]; + if (value != null) { + final text = value.toString().trim(); + if (text.isNotEmpty) { + return text; + } + } + } + return null; + } + + String _statusLabel(String? status) { + final normalized = status?.trim().toUpperCase(); + if (normalized == 'A') { + return 'SUCCESS'; + } + if (normalized == 'E' ) { + return 'FAILED'; + } + return 'SUCCESS'; + + } + + String? _sanitizeMultiline(String? value) { + if (value == null) { + return null; + } + final cleaned = value + .replaceAll('\r', ' ') + .replaceAll('\n', ' ') + .replaceAll(r'\n', ' ') + .replaceAll(r'/n', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + return cleaned.isEmpty ? null : cleaned; + } +} + +class _TransactionPaginationFooter extends StatelessWidget { + const _TransactionPaginationFooter({ + required this.isLoadingMore, + required this.onLoadMore, + }); + + final bool isLoadingMore; + final VoidCallback onLoadMore; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: Text( + isLoadingMore ? 'Loading more...' : 'More results available', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + if (isLoadingMore) + const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + FilledButton.tonal( + onPressed: onLoadMore, + child: const Text('Load more'), + ), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String? value; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final text = (value ?? '').trim().isEmpty ? '-' : value!.trim(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.status}); + + final String status; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final normalized = status.toUpperCase(); + final isSuccess = normalized == 'SUCCESS'; + + final background = isSuccess + ? Colors.green.withOpacity(0.12) + : colorScheme.errorContainer.withOpacity(0.9); + final foreground = isSuccess + ? Colors.green.shade700 + : colorScheme.onErrorContainer; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: foreground.withOpacity(0.25)), + ), + child: Text( + normalized.isEmpty ? '-' : normalized, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w800, + color: foreground, + ), + ), + ); + } +} + +enum _CashierMenuAction { logout } diff --git a/lib/presentation/terminal/terminal_selection_screen.dart b/lib/presentation/terminal/terminal_selection_screen.dart index d504769..e28b680 100644 --- a/lib/presentation/terminal/terminal_selection_screen.dart +++ b/lib/presentation/terminal/terminal_selection_screen.dart @@ -1,118 +1,118 @@ -import 'package:e_receipt_mobile/domain/entities/terminal.dart'; -import 'package:e_receipt_mobile/presentation/home/home_view_model.dart'; -import 'package:e_receipt_mobile/presentation/terminal/terminal_next_screen.dart'; -import 'package:e_receipt_mobile/presentation/terminal/widgets/terminal_header.dart'; -import 'package:e_receipt_mobile/presentation/terminal/widgets/terminal_list_view.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class TerminalSelectionScreen extends ConsumerWidget { - const TerminalSelectionScreen({ - required this.merchantId, - required this.merchantName, - super.key, - }); - - final String merchantId; - final String merchantName; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final terminalsAsync = ref.watch(terminalListProvider(merchantId)); - final colorScheme = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar( - title: Text(merchantName), - actions: [ - IconButton( - tooltip: 'Refresh', - onPressed: terminalsAsync.isLoading - ? null - : () => ref.invalidate(terminalListProvider(merchantId)), - icon: const Icon(Icons.refresh), - ), - ], - ), - body: SafeArea( - bottom: true, - child: terminalsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( - child: Padding( - padding: EdgeInsets.fromLTRB( - 24, - 24, - 24, - MediaQuery.viewPaddingOf(context).bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.wifi_off_outlined, - size: 56, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'Failed to load terminals', - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Text( - '$error', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 14), - FilledButton.tonal( - onPressed: () => - ref.invalidate(terminalListProvider(merchantId)), - child: const Text('Try again'), - ), - ], - ), - ), - ), - data: (terminals) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TerminalHeader( - merchantName: merchantName, - totalCount: terminals.length, - ), - Expanded( - child: TerminalListView( - terminals: terminals, - emptyMessage: 'No terminals found', - onRefresh: () async { - await ref.refresh( - terminalListProvider(merchantId).future, - ); - }, - onTerminalTap: (terminal) => - _openNextScreen(context, terminal), - ), - ), - ], - ); - }, - ), - ), - ); - } - - void _openNextScreen(BuildContext context, Terminal terminal) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - TerminalNextScreen(merchantName: merchantName, terminal: terminal), - ), - ); - } -} +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:e_receipt_mobile/presentation/home/home_view_model.dart'; +import 'package:e_receipt_mobile/presentation/terminal/terminal_next_screen.dart'; +import 'package:e_receipt_mobile/presentation/terminal/widgets/terminal_header.dart'; +import 'package:e_receipt_mobile/presentation/terminal/widgets/terminal_list_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class TerminalSelectionScreen extends ConsumerWidget { + const TerminalSelectionScreen({ + required this.merchantId, + required this.merchantName, + super.key, + }); + + final String merchantId; + final String merchantName; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final terminalsAsync = ref.watch(terminalListProvider(merchantId)); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: Text(merchantName), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: terminalsAsync.isLoading + ? null + : () => ref.invalidate(terminalListProvider(merchantId)), + icon: const Icon(Icons.refresh), + ), + ], + ), + body: SafeArea( + bottom: true, + child: terminalsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Padding( + padding: EdgeInsets.fromLTRB( + 24, + 24, + 24, + MediaQuery.viewPaddingOf(context).bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_off_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'Failed to load terminals', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + '$error', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + FilledButton.tonal( + onPressed: () => + ref.invalidate(terminalListProvider(merchantId)), + child: const Text('Try again'), + ), + ], + ), + ), + ), + data: (terminals) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TerminalHeader( + merchantName: merchantName, + totalCount: terminals.length, + ), + Expanded( + child: TerminalListView( + terminals: terminals, + emptyMessage: 'No terminals found', + onRefresh: () async { + await ref.refresh( + terminalListProvider(merchantId).future, + ); + }, + onTerminalTap: (terminal) => + _openNextScreen(context, terminal), + ), + ), + ], + ); + }, + ), + ), + ); + } + + void _openNextScreen(BuildContext context, Terminal terminal) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + TerminalNextScreen(merchantName: merchantName, terminal: terminal), + ), + ); + } +} diff --git a/lib/presentation/terminal/transaction_pagination_providers.dart b/lib/presentation/terminal/transaction_pagination_providers.dart index aa14f09..e623148 100644 --- a/lib/presentation/terminal/transaction_pagination_providers.dart +++ b/lib/presentation/terminal/transaction_pagination_providers.dart @@ -1,4 +1,4 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final transactionPageSizeProvider = Provider((ref) => 8); - +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final transactionPageSizeProvider = Provider((ref) => 8); + diff --git a/lib/presentation/terminal/transaction_paging_state.dart b/lib/presentation/terminal/transaction_paging_state.dart index 311435d..e19f64f 100644 --- a/lib/presentation/terminal/transaction_paging_state.dart +++ b/lib/presentation/terminal/transaction_paging_state.dart @@ -1,45 +1,45 @@ -import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; -import 'package:flutter/foundation.dart'; - -@immutable -class TransactionPagingState { - const TransactionPagingState({ - this.items = const [], - this.page = 1, - this.limit = 8, - this.isLoading = false, - this.isLoadingMore = false, - this.hasMore = true, - this.errorMessage, - }); - - final List items; - final int page; - final int limit; - final bool isLoading; - final bool isLoadingMore; - final bool hasMore; - final String? errorMessage; - - TransactionPagingState copyWith({ - List? items, - int? page, - int? limit, - bool? isLoading, - bool? isLoadingMore, - bool? hasMore, - String? errorMessage, - bool clearError = false, - }) { - return TransactionPagingState( - items: items ?? this.items, - page: page ?? this.page, - limit: limit ?? this.limit, - isLoading: isLoading ?? this.isLoading, - isLoadingMore: isLoadingMore ?? this.isLoadingMore, - hasMore: hasMore ?? this.hasMore, - errorMessage: clearError ? null : errorMessage ?? this.errorMessage, - ); - } -} - +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class TransactionPagingState { + const TransactionPagingState({ + this.items = const [], + this.page = 1, + this.limit = 8, + this.isLoading = false, + this.isLoadingMore = false, + this.hasMore = true, + this.errorMessage, + }); + + final List items; + final int page; + final int limit; + final bool isLoading; + final bool isLoadingMore; + final bool hasMore; + final String? errorMessage; + + TransactionPagingState copyWith({ + List? items, + int? page, + int? limit, + bool? isLoading, + bool? isLoadingMore, + bool? hasMore, + String? errorMessage, + bool clearError = false, + }) { + return TransactionPagingState( + items: items ?? this.items, + page: page ?? this.page, + limit: limit ?? this.limit, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + hasMore: hasMore ?? this.hasMore, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + ); + } +} + diff --git a/lib/presentation/terminal/transaction_paging_view_model.dart b/lib/presentation/terminal/transaction_paging_view_model.dart index 0296fc9..e69da0a 100644 --- a/lib/presentation/terminal/transaction_paging_view_model.dart +++ b/lib/presentation/terminal/transaction_paging_view_model.dart @@ -1,151 +1,151 @@ -import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; -import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_pagination_providers.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_state.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_view_model.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final transactionPagingViewModelProvider = - StateNotifierProvider.autoDispose.family< - TransactionPagingViewModel, - TransactionPagingState, - TransactionQuery - >((ref, query) { - final sessionUser = ref.watch(sessionControllerProvider); - if (sessionUser == null) { - throw Exception('No active session'); - } - - final limit = ref.watch(transactionPageSizeProvider); - final viewModel = TransactionPagingViewModel( - repository: ref.watch(transactionRepositoryProvider), - token: sessionUser.token, - serial: query.serial, - range: query.range, - limit: limit, - ); - viewModel.loadInitial(); - return viewModel; - }); - -class TransactionPagingViewModel extends StateNotifier { - TransactionPagingViewModel({ - required TransactionRepository repository, - required String token, - required String serial, - required String range, - required int limit, - }) : _repository = repository, - _token = token, - _serial = serial, - _range = range, - super(TransactionPagingState(limit: limit)); - - final TransactionRepository _repository; - final String _token; - final String _serial; - final String _range; - - Future loadInitial() async { - await refresh(); - } - - Future refresh() async { - state = state.copyWith( - isLoading: true, - isLoadingMore: false, - page: 1, - items: const [], - hasMore: true, - clearError: true, - ); - - try { - final results = await _repository.getTransactions( - token: _token, - serial: _serial, - range: _range, - page: 1, - limit: state.limit, - ); - state = state.copyWith( - isLoading: false, - items: results, - page: 1, - hasMore: results.length >= state.limit, - ); - } catch (error) { - state = state.copyWith( - isLoading: false, - errorMessage: error.toString().replaceFirst('Exception: ', ''), - ); - } - } - - Future loadMore() async { - if (state.isLoading || state.isLoadingMore || !state.hasMore) { - return; - } - - state = state.copyWith(isLoadingMore: true, clearError: true); - final nextPage = state.page + 1; - - try { - final results = await _repository.getTransactions( - token: _token, - serial: _serial, - range: _range, - page: nextPage, - limit: state.limit, - ); - - final merged = _mergeUnique(state.items, results); - final addedCount = merged.length - state.items.length; - final hasMore = results.length >= state.limit && addedCount > 0; - - state = state.copyWith( - isLoadingMore: false, - items: merged, - page: nextPage, - hasMore: hasMore, - ); - } catch (error) { - state = state.copyWith( - isLoadingMore: false, - errorMessage: error.toString().replaceFirst('Exception: ', ''), - ); - } - } - - List _mergeUnique( - List existing, - List incoming, - ) { - if (existing.isEmpty) { - return incoming; - } - - final keys = existing.map(_keyFor).toSet(); - final merged = [...existing]; - for (final item in incoming) { - final key = _keyFor(item); - if (keys.add(key)) { - merged.add(item); - } - } - return merged; - } - - String _keyFor(TransactionRecord item) { - return [ - item.id, - item.rrn, - item.createdAt, - item.amount?.toString(), - item.status, - item.type, - ].join('|'); - } -} - +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_pagination_providers.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_state.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_view_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final transactionPagingViewModelProvider = + StateNotifierProvider.autoDispose.family< + TransactionPagingViewModel, + TransactionPagingState, + TransactionQuery + >((ref, query) { + final sessionUser = ref.watch(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + final limit = ref.watch(transactionPageSizeProvider); + final viewModel = TransactionPagingViewModel( + repository: ref.watch(transactionRepositoryProvider), + token: sessionUser.token, + serial: query.serial, + range: query.range, + limit: limit, + ); + viewModel.loadInitial(); + return viewModel; + }); + +class TransactionPagingViewModel extends StateNotifier { + TransactionPagingViewModel({ + required TransactionRepository repository, + required String token, + required String serial, + required String range, + required int limit, + }) : _repository = repository, + _token = token, + _serial = serial, + _range = range, + super(TransactionPagingState(limit: limit)); + + final TransactionRepository _repository; + final String _token; + final String _serial; + final String _range; + + Future loadInitial() async { + await refresh(); + } + + Future refresh() async { + state = state.copyWith( + isLoading: true, + isLoadingMore: false, + page: 1, + items: const [], + hasMore: true, + clearError: true, + ); + + try { + final results = await _repository.getTransactions( + token: _token, + serial: _serial, + range: _range, + page: 1, + limit: state.limit, + ); + state = state.copyWith( + isLoading: false, + items: results, + page: 1, + hasMore: results.length >= state.limit, + ); + } catch (error) { + state = state.copyWith( + isLoading: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + Future loadMore() async { + if (state.isLoading || state.isLoadingMore || !state.hasMore) { + return; + } + + state = state.copyWith(isLoadingMore: true, clearError: true); + final nextPage = state.page + 1; + + try { + final results = await _repository.getTransactions( + token: _token, + serial: _serial, + range: _range, + page: nextPage, + limit: state.limit, + ); + + final merged = _mergeUnique(state.items, results); + final addedCount = merged.length - state.items.length; + final hasMore = results.length >= state.limit && addedCount > 0; + + state = state.copyWith( + isLoadingMore: false, + items: merged, + page: nextPage, + hasMore: hasMore, + ); + } catch (error) { + state = state.copyWith( + isLoadingMore: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + List _mergeUnique( + List existing, + List incoming, + ) { + if (existing.isEmpty) { + return incoming; + } + + final keys = existing.map(_keyFor).toSet(); + final merged = [...existing]; + for (final item in incoming) { + final key = _keyFor(item); + if (keys.add(key)) { + merged.add(item); + } + } + return merged; + } + + String _keyFor(TransactionRecord item) { + return [ + item.id, + item.rrn, + item.createdAt, + item.amount?.toString(), + item.status, + item.type, + ].join('|'); + } +} + diff --git a/lib/presentation/terminal/transaction_receipt_screen.dart b/lib/presentation/terminal/transaction_receipt_screen.dart index d67fbd8..5da41b9 100644 --- a/lib/presentation/terminal/transaction_receipt_screen.dart +++ b/lib/presentation/terminal/transaction_receipt_screen.dart @@ -1,403 +1,403 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:e_receipt_mobile/domain/entities/terminal.dart'; -import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/terminal/receipt_view_model.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_pdfview/flutter_pdfview.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:printing/printing.dart'; - -class TransactionReceiptScreen extends ConsumerWidget { - const TransactionReceiptScreen({ - required this.merchantName, - required this.terminal, - required this.transaction, - super.key, - }); - - final String merchantName; - final Terminal terminal; - final TransactionRecord transaction; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final transactionId = transaction.id?.trim(); - if (transactionId == null || transactionId.isEmpty) { - return Scaffold( - appBar: AppBar(title: const Text('Receipt')), - body: const SafeArea( - child: Center(child: Text('Missing transaction id')), - ), - ); - } - - final pdfAsync = ref.watch( - transactionReceiptViewDataProvider( - TransactionReceiptQuery( - transactionId: transactionId, - copyFor: 'Merchant', - ), - ), - ); - - return Scaffold( - appBar: AppBar( - title: const Text('Receipt'), - actions: [ - IconButton( - tooltip: 'Reload', - onPressed: () => ref.invalidate( - transactionReceiptViewDataProvider( - TransactionReceiptQuery( - transactionId: transactionId, - copyFor: 'Merchant', - ), - ), - ), - icon: const Icon(Icons.refresh), - ), - ], - ), - body: SafeArea( - bottom: true, - child: pdfAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( - child: Padding( - padding: EdgeInsets.fromLTRB( - 24, - 24, - 24, - MediaQuery.viewPaddingOf(context).bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.picture_as_pdf_outlined, size: 56), - const SizedBox(height: 12), - Text( - 'Failed to load receipt PDF', - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Text( - Error.safeToString(error), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 14), - FilledButton.tonal( - onPressed: () => ref.invalidate( - transactionReceiptViewDataProvider( - TransactionReceiptQuery( - transactionId: transactionId, - copyFor: 'Merchant', - ), - ), - ), - child: const Text('Try again'), - ), - ], - ), - ), - ), - data: (viewData) => Column( - children: [ - Expanded( - child: switch (viewData) { - ReceiptPdfViewData() => _ReceiptViewport( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), - child: PDFView( - filePath: viewData.file.path, - enableSwipe: true, - swipeHorizontal: false, - autoSpacing: false, - pageFling: true, - pageSnap: true, - fitEachPage: true, - fitPolicy: FitPolicy.WIDTH, - backgroundColor: Theme.of(context).colorScheme.primary, - onError: (error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'PDF error: ${Error.safeToString(error)}', - ), - ), - ); - }, - onPageError: (page, error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'PDF page $page error: ${Error.safeToString(error)}', - ), - ), - ); - }, - ), - ), - }, - ), - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: () => _downloadReceipt( - context: context, - ref: ref, - transactionId: transactionId, - ), - icon: const Icon(Icons.download_outlined), - label: const Text('Download PDF'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: FilledButton.tonal( - onPressed: () => _printReceipt( - context: context, - ref: ref, - transactionId: transactionId, - ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.print_outlined), - SizedBox(width: 8), - Text('Print PDF'), - ], - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -class _ReceiptViewport extends StatelessWidget { - const _ReceiptViewport({required this.child, this.padding}); - - final Widget child; - final EdgeInsetsGeometry? padding; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final viewPadding = MediaQuery.viewPaddingOf(context); - final size = MediaQuery.sizeOf(context); - final maxHeight = size.height * 0.60; - - return Padding( - padding: - padding ?? EdgeInsets.fromLTRB(16, 16, 16, viewPadding.bottom + 16), - child: LayoutBuilder( - builder: (context, constraints) { - final double targetHeight = maxHeight - .clamp(0, constraints.maxHeight) - .toDouble(); - - return Align( - alignment: Alignment.center, - child: SizedBox( - width: 230, - height: targetHeight, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: DecoratedBox( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withOpacity(0.7), - border: Border.all(color: colorScheme.outlineVariant), - borderRadius: BorderRadius.circular(16), - ), - child: SizedBox.expand(child: child), - ), - ), - ), - ); - }, - ), - ); - } -} - -Future _downloadReceipt({ - required BuildContext context, - required WidgetRef ref, - required String transactionId, -}) async { - try { - final sanitizedId = transactionId.replaceAll( - RegExp(r'[^A-Za-z0-9_-]'), - '_', - ); - final stamp = DateTime.now().toIso8601String().replaceAll(':', '-'); - - final Uint8List bytes = await _runWithProgress( - context, - message: 'Preparing receipt…', - task: () => _fetchReceiptPdfBytes( - ref: ref, - transactionId: transactionId, - copyFor: 'Merchant', - ), - ); - - final candidateBaseDirs = []; - if (!kIsWeb) { - if (Platform.isAndroid) { - final dir = await getExternalStorageDirectory(); - if (dir != null) { - candidateBaseDirs.add(dir); - } - } - final downloads = await getDownloadsDirectory(); - if (downloads != null) { - candidateBaseDirs.add(downloads); - } - } - candidateBaseDirs.add(await getApplicationDocumentsDirectory()); - - File? outFile; - Object? lastError; - for (final baseDir in candidateBaseDirs) { - try { - final receiptsDir = Directory( - '${baseDir.path}${Platform.pathSeparator}receipts', - ); - if (!await receiptsDir.exists()) { - await receiptsDir.create(recursive: true); - } - - final candidate = File( - '${receiptsDir.path}${Platform.pathSeparator}receipt_${sanitizedId}_merchant_$stamp.pdf', - ); - await candidate.writeAsBytes(bytes, flush: true); - outFile = candidate; - break; - } catch (e) { - lastError = e; - } - } - - if (outFile == null) { - throw lastError ?? Exception('Failed to save receipt PDF'); - } - - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Saved: ${outFile.path}'))); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Download failed: $e'))); - } - } -} - -Future _printReceipt({ - required BuildContext context, - required WidgetRef ref, - required String transactionId, -}) async { - try { - final Uint8List bytes = await _runWithProgress( - context, - message: 'Preparing print…', - task: () => _fetchReceiptPdfBytes( - ref: ref, - transactionId: transactionId, - copyFor: 'Merchant', - ), - ); - - await Printing.layoutPdf(onLayout: (_) async => bytes); - } catch (e) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Print failed: $e'))); - } -} - -Future _fetchReceiptPdfBytes({ - required WidgetRef ref, - required String transactionId, - required String copyFor, -}) async { - final sessionUser = ref.read(sessionControllerProvider); - if (sessionUser == null) { - throw Exception('No active session'); - } - - return ref - .read(receiptRepositoryProvider) - .getTransactionReceiptPdfBytes( - token: sessionUser.token, - transactionId: transactionId, - copyFor: copyFor, - ); -} - -Future _runWithProgress( - BuildContext context, { - required String message, - required Future Function() task, -}) async { - if (!context.mounted) { - return task(); - } - - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: (context) { - return PopScope( - canPop: false, - child: AlertDialog( - content: Row( - children: [ - const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 12), - Expanded(child: Text(message)), - ], - ), - ), - ); - }, - ), - ); - - try { - return await task(); - } finally { - if (context.mounted) { - Navigator.of(context, rootNavigator: true).pop(); - } - } -} +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/terminal/receipt_view_model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_pdfview/flutter_pdfview.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:printing/printing.dart'; + +class TransactionReceiptScreen extends ConsumerWidget { + const TransactionReceiptScreen({ + required this.merchantName, + required this.terminal, + required this.transaction, + super.key, + }); + + final String merchantName; + final Terminal terminal; + final TransactionRecord transaction; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final transactionId = transaction.id?.trim(); + if (transactionId == null || transactionId.isEmpty) { + return Scaffold( + appBar: AppBar(title: const Text('Receipt')), + body: const SafeArea( + child: Center(child: Text('Missing transaction id')), + ), + ); + } + + final pdfAsync = ref.watch( + transactionReceiptViewDataProvider( + TransactionReceiptQuery( + transactionId: transactionId, + copyFor: 'Merchant', + ), + ), + ); + + return Scaffold( + appBar: AppBar( + title: const Text('Receipt'), + actions: [ + IconButton( + tooltip: 'Reload', + onPressed: () => ref.invalidate( + transactionReceiptViewDataProvider( + TransactionReceiptQuery( + transactionId: transactionId, + copyFor: 'Merchant', + ), + ), + ), + icon: const Icon(Icons.refresh), + ), + ], + ), + body: SafeArea( + bottom: true, + child: pdfAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Padding( + padding: EdgeInsets.fromLTRB( + 24, + 24, + 24, + MediaQuery.viewPaddingOf(context).bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.picture_as_pdf_outlined, size: 56), + const SizedBox(height: 12), + Text( + 'Failed to load receipt PDF', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + Error.safeToString(error), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 14), + FilledButton.tonal( + onPressed: () => ref.invalidate( + transactionReceiptViewDataProvider( + TransactionReceiptQuery( + transactionId: transactionId, + copyFor: 'Merchant', + ), + ), + ), + child: const Text('Try again'), + ), + ], + ), + ), + ), + data: (viewData) => Column( + children: [ + Expanded( + child: switch (viewData) { + ReceiptPdfViewData() => _ReceiptViewport( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: PDFView( + filePath: viewData.file.path, + enableSwipe: true, + swipeHorizontal: false, + autoSpacing: false, + pageFling: true, + pageSnap: true, + fitEachPage: true, + fitPolicy: FitPolicy.WIDTH, + backgroundColor: Theme.of(context).colorScheme.primary, + onError: (error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'PDF error: ${Error.safeToString(error)}', + ), + ), + ); + }, + onPageError: (page, error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'PDF page $page error: ${Error.safeToString(error)}', + ), + ), + ); + }, + ), + ), + }, + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: () => _downloadReceipt( + context: context, + ref: ref, + transactionId: transactionId, + ), + icon: const Icon(Icons.download_outlined), + label: const Text('Download PDF'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.tonal( + onPressed: () => _printReceipt( + context: context, + ref: ref, + transactionId: transactionId, + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.print_outlined), + SizedBox(width: 8), + Text('Print PDF'), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ReceiptViewport extends StatelessWidget { + const _ReceiptViewport({required this.child, this.padding}); + + final Widget child; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final viewPadding = MediaQuery.viewPaddingOf(context); + final size = MediaQuery.sizeOf(context); + final maxHeight = size.height * 0.60; + + return Padding( + padding: + padding ?? EdgeInsets.fromLTRB(16, 16, 16, viewPadding.bottom + 16), + child: LayoutBuilder( + builder: (context, constraints) { + final double targetHeight = maxHeight + .clamp(0, constraints.maxHeight) + .toDouble(); + + return Align( + alignment: Alignment.center, + child: SizedBox( + width: 230, + height: targetHeight, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withOpacity(0.7), + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(16), + ), + child: SizedBox.expand(child: child), + ), + ), + ), + ); + }, + ), + ); + } +} + +Future _downloadReceipt({ + required BuildContext context, + required WidgetRef ref, + required String transactionId, +}) async { + try { + final sanitizedId = transactionId.replaceAll( + RegExp(r'[^A-Za-z0-9_-]'), + '_', + ); + final stamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + + final Uint8List bytes = await _runWithProgress( + context, + message: 'Preparing receipt…', + task: () => _fetchReceiptPdfBytes( + ref: ref, + transactionId: transactionId, + copyFor: 'Merchant', + ), + ); + + final candidateBaseDirs = []; + if (!kIsWeb) { + if (Platform.isAndroid) { + final dir = await getExternalStorageDirectory(); + if (dir != null) { + candidateBaseDirs.add(dir); + } + } + final downloads = await getDownloadsDirectory(); + if (downloads != null) { + candidateBaseDirs.add(downloads); + } + } + candidateBaseDirs.add(await getApplicationDocumentsDirectory()); + + File? outFile; + Object? lastError; + for (final baseDir in candidateBaseDirs) { + try { + final receiptsDir = Directory( + '${baseDir.path}${Platform.pathSeparator}receipts', + ); + if (!await receiptsDir.exists()) { + await receiptsDir.create(recursive: true); + } + + final candidate = File( + '${receiptsDir.path}${Platform.pathSeparator}receipt_${sanitizedId}_merchant_$stamp.pdf', + ); + await candidate.writeAsBytes(bytes, flush: true); + outFile = candidate; + break; + } catch (e) { + lastError = e; + } + } + + if (outFile == null) { + throw lastError ?? Exception('Failed to save receipt PDF'); + } + + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Saved: ${outFile.path}'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Download failed: $e'))); + } + } +} + +Future _printReceipt({ + required BuildContext context, + required WidgetRef ref, + required String transactionId, +}) async { + try { + final Uint8List bytes = await _runWithProgress( + context, + message: 'Preparing print…', + task: () => _fetchReceiptPdfBytes( + ref: ref, + transactionId: transactionId, + copyFor: 'Merchant', + ), + ); + + await Printing.layoutPdf(onLayout: (_) async => bytes); + } catch (e) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Print failed: $e'))); + } +} + +Future _fetchReceiptPdfBytes({ + required WidgetRef ref, + required String transactionId, + required String copyFor, +}) async { + final sessionUser = ref.read(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + return ref + .read(receiptRepositoryProvider) + .getTransactionReceiptPdfBytes( + token: sessionUser.token, + transactionId: transactionId, + copyFor: copyFor, + ); +} + +Future _runWithProgress( + BuildContext context, { + required String message, + required Future Function() task, +}) async { + if (!context.mounted) { + return task(); + } + + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return PopScope( + canPop: false, + child: AlertDialog( + content: Row( + children: [ + const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + ), + ); + }, + ), + ); + + try { + return await task(); + } finally { + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + } +} diff --git a/lib/presentation/terminal/transaction_view_model.dart b/lib/presentation/terminal/transaction_view_model.dart index 0b0bdb5..daad999 100644 --- a/lib/presentation/terminal/transaction_view_model.dart +++ b/lib/presentation/terminal/transaction_view_model.dart @@ -1,51 +1,51 @@ -import 'package:e_receipt_mobile/core/config/app_config.dart'; -import 'package:e_receipt_mobile/data/repositories/api_transaction_repository.dart'; -import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; -import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final transactionRepositoryProvider = Provider((ref) { - return ApiTransactionRepository( - baseUrl: AppConfig.apiBaseUrl, - apiSecret: AppConfig.apiSecret, - client: ref.watch(authenticatedHttpClientProvider), - ); -}); - -class TransactionQuery { - const TransactionQuery({required this.serial, required this.range}); - - final String serial; - final String range; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - return other is TransactionQuery && - other.serial == serial && - other.range == range; - } - - @override - int get hashCode => Object.hash(serial, range); -} - -final transactionListProvider = - FutureProvider.family, TransactionQuery>( - (ref, query) async { - final sessionUser = ref.watch(sessionControllerProvider); - if (sessionUser == null) { - throw Exception('No active session'); - } - - return ref.watch(transactionRepositoryProvider).getTransactions( - token: sessionUser.token, - serial: query.serial, - range: query.range, - ); - }, - ); +import 'package:e_receipt_mobile/core/config/app_config.dart'; +import 'package:e_receipt_mobile/data/repositories/api_transaction_repository.dart'; +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final transactionRepositoryProvider = Provider((ref) { + return ApiTransactionRepository( + baseUrl: AppConfig.apiBaseUrl, + apiSecret: AppConfig.apiSecret, + client: ref.watch(authenticatedHttpClientProvider), + ); +}); + +class TransactionQuery { + const TransactionQuery({required this.serial, required this.range}); + + final String serial; + final String range; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is TransactionQuery && + other.serial == serial && + other.range == range; + } + + @override + int get hashCode => Object.hash(serial, range); +} + +final transactionListProvider = + FutureProvider.family, TransactionQuery>( + (ref, query) async { + final sessionUser = ref.watch(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + return ref.watch(transactionRepositoryProvider).getTransactions( + token: sessionUser.token, + serial: query.serial, + range: query.range, + ); + }, + ); diff --git a/lib/presentation/terminal/widgets/terminal_header.dart b/lib/presentation/terminal/widgets/terminal_header.dart index 026a4d5..fdfb0f2 100644 --- a/lib/presentation/terminal/widgets/terminal_header.dart +++ b/lib/presentation/terminal/widgets/terminal_header.dart @@ -1,58 +1,58 @@ -import 'package:flutter/material.dart'; - -class TerminalHeader extends StatelessWidget { - const TerminalHeader({ - required this.merchantName, - required this.totalCount, - super.key, - }); - - final String merchantName; - final int totalCount; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Terminals', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 2), - Text( - merchantName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Chip( - label: Text('$totalCount'), - side: BorderSide(color: colorScheme.outlineVariant), - backgroundColor: colorScheme.surfaceContainerHigh, - ), - ], - ), - ], - ), - ); - } -} +import 'package:flutter/material.dart'; + +class TerminalHeader extends StatelessWidget { + const TerminalHeader({ + required this.merchantName, + required this.totalCount, + super.key, + }); + + final String merchantName; + final int totalCount; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Terminals', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + merchantName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Chip( + label: Text('$totalCount'), + side: BorderSide(color: colorScheme.outlineVariant), + backgroundColor: colorScheme.surfaceContainerHigh, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/presentation/terminal/widgets/terminal_list_view.dart b/lib/presentation/terminal/widgets/terminal_list_view.dart index b74fd55..19edb66 100644 --- a/lib/presentation/terminal/widgets/terminal_list_view.dart +++ b/lib/presentation/terminal/widgets/terminal_list_view.dart @@ -1,208 +1,208 @@ -import 'dart:convert'; -import 'dart:math'; -import 'dart:ui'; - -import 'package:e_receipt_mobile/domain/entities/terminal.dart'; -import 'package:flutter/material.dart'; - -class TerminalListView extends StatelessWidget { - const TerminalListView({ - required this.terminals, - required this.onRefresh, - required this.onTerminalTap, - required this.emptyMessage, - super.key, - }); - - final List terminals; - final Future Function() onRefresh; - final void Function(Terminal terminal) onTerminalTap; - final String emptyMessage; - - @override - Widget build(BuildContext context) { - final bottomInset = MediaQuery.viewPaddingOf(context).bottom; - final colorScheme = Theme.of(context).colorScheme; - - return RefreshIndicator.adaptive( - onRefresh: onRefresh, - child: terminals.isEmpty - ? ListView( - padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24), - physics: const AlwaysScrollableScrollPhysics(), - children: [ - Icon( - Icons.point_of_sale_outlined, - size: 56, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - emptyMessage, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - 'Pull down to refresh.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ) - : ListView.separated( - padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16), - physics: const AlwaysScrollableScrollPhysics(), - itemCount: terminals.length, - separatorBuilder: (_, __) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final terminal = terminals[index]; - print('this is terminal ${terminal.status}'); - final enabled = terminal.tid != null || terminal.id != null; - final title = - (_sanitizeMultiline(terminal.name)?.isNotEmpty ?? false) - ? _sanitizeMultiline(terminal.name)! - : (terminal.tid?.trim().isNotEmpty ?? false) - ? terminal.tid!.trim() - : '-'; - final status = (terminal.status ?? '').trim(); - final serial = (_sanitizeMultiline(terminal.serial) ?? '') - .trim(); - - return Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: BorderSide( - color: colorScheme.outlineVariant.withOpacity(0.55), - ), - ), - child: InkWell( - borderRadius: BorderRadius.circular(18), - onTap: enabled ? () => onTerminalTap(terminal) : null, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - child: Row( - children: [ - Container( - height: 44, - width: 44, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: enabled - ? colorScheme.primary.withOpacity(0.14) - : colorScheme.surfaceContainerHigh, - ), - child: Icon( - Icons.point_of_sale_outlined, - color: enabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - if (status.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - 999, - ), - color: - colorScheme.surfaceContainerHigh, - border: Border.all( - color: colorScheme.outlineVariant, - ), - ), - child: Text( - status.toUpperCase(), - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 0.2, - ), - ), - ), - ], - ), - if (serial.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - 'Serial: $serial', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - fontFeatures: const [ - FontFeature.tabularFigures(), - ], - ), - ), - ], - ], - ), - ), - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - color: enabled - ? colorScheme.onSurfaceVariant - : colorScheme.onSurfaceVariant.withOpacity(0.4), - ), - ], - ), - ), - ), - ); - }, - ), - ); - } -} - -String? _sanitizeMultiline(String? value) { - if (value == null) { - return null; - } - final cleaned = value - .replaceAll('\r', ' ') - .replaceAll('\n', ' ') - .replaceAll(r'\n', ' ') - .replaceAll(r'/n', ' ') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - return cleaned.isEmpty ? null : cleaned; -} +import 'dart:convert'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:flutter/material.dart'; + +class TerminalListView extends StatelessWidget { + const TerminalListView({ + required this.terminals, + required this.onRefresh, + required this.onTerminalTap, + required this.emptyMessage, + super.key, + }); + + final List terminals; + final Future Function() onRefresh; + final void Function(Terminal terminal) onTerminalTap; + final String emptyMessage; + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final colorScheme = Theme.of(context).colorScheme; + + return RefreshIndicator.adaptive( + onRefresh: onRefresh, + child: terminals.isEmpty + ? ListView( + padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Icon( + Icons.point_of_sale_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + emptyMessage, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'Pull down to refresh.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ) + : ListView.separated( + padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16), + physics: const AlwaysScrollableScrollPhysics(), + itemCount: terminals.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final terminal = terminals[index]; + print('this is terminal ${terminal.status}'); + final enabled = terminal.tid != null || terminal.id != null; + final title = + (_sanitizeMultiline(terminal.name)?.isNotEmpty ?? false) + ? _sanitizeMultiline(terminal.name)! + : (terminal.tid?.trim().isNotEmpty ?? false) + ? terminal.tid!.trim() + : '-'; + final status = (terminal.status ?? '').trim(); + final serial = (_sanitizeMultiline(terminal.serial) ?? '') + .trim(); + + return Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide( + color: colorScheme.outlineVariant.withOpacity(0.55), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: enabled ? () => onTerminalTap(terminal) : null, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Row( + children: [ + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: enabled + ? colorScheme.primary.withOpacity(0.14) + : colorScheme.surfaceContainerHigh, + ), + child: Icon( + Icons.point_of_sale_outlined, + color: enabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (status.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 999, + ), + color: + colorScheme.surfaceContainerHigh, + border: Border.all( + color: colorScheme.outlineVariant, + ), + ), + child: Text( + status.toUpperCase(), + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ), + ], + ), + if (serial.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Serial: $serial', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + color: enabled + ? colorScheme.onSurfaceVariant + : colorScheme.onSurfaceVariant.withOpacity(0.4), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +String? _sanitizeMultiline(String? value) { + if (value == null) { + return null; + } + final cleaned = value + .replaceAll('\r', ' ') + .replaceAll('\n', ' ') + .replaceAll(r'\n', ' ') + .replaceAll(r'/n', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + return cleaned.isEmpty ? null : cleaned; +} diff --git a/pubspec.lock b/pubspec.lock index da875ed..aad91f1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,570 +1,570 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - archive: - dependency: transitive - description: - name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff - url: "https://pub.dev" - source: hosted - version: "4.0.9" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - barcode: - dependency: transitive - description: - name: barcode - sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" - url: "https://pub.dev" - source: hosted - version: "2.2.9" - bidi: - dependency: transitive - description: - name: bidi - sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" - url: "https://pub.dev" - source: hosted - version: "2.0.13" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.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" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_pdfview: - dependency: "direct main" - description: - name: flutter_pdfview - sha256: "5b80e89f3ba6e478d1e897543c9634508284ad73476807febc188378986b69ee" - url: "https://pub.dev" - source: hosted - version: "1.4.4" - flutter_riverpod: - dependency: "direct main" - description: - name: flutter_riverpod - sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" - url: "https://pub.dev" - source: hosted - version: "2.6.1" - 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" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - hooks: - dependency: transitive - description: - name: hooks - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 - url: "https://pub.dev" - source: hosted - version: "1.0.2" - http: - dependency: "direct main" - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce - url: "https://pub.dev" - source: hosted - version: "4.8.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "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" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" - url: "https://pub.dev" - source: hosted - version: "1.17.0" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" - url: "https://pub.dev" - source: hosted - version: "0.17.6" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" - 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: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" - url: "https://pub.dev" - source: hosted - version: "2.2.23" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - 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" - pdf: - dependency: transitive - description: - name: pdf - sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b - url: "https://pub.dev" - source: hosted - version: "3.12.0" - pdf_widget_wrapper: - dependency: transitive - description: - name: pdf_widget_wrapper - sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 - url: "https://pub.dev" - source: hosted - version: "1.0.4" - 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" - posix: - dependency: transitive - description: - name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.dev" - source: hosted - version: "6.5.0" - printing: - dependency: "direct main" - description: - name: printing - sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692" - url: "https://pub.dev" - source: hosted - version: "5.14.3" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - qr: - dependency: transitive - description: - name: qr - sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - riverpod: - dependency: transitive - description: - name: riverpod - sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" - url: "https://pub.dev" - source: hosted - version: "2.6.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - state_notifier: - dependency: transitive - description: - name: state_notifier - sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.dev" - source: hosted - version: "1.0.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" - url: "https://pub.dev" - source: hosted - version: "0.7.10" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.dev" - source: hosted - version: "15.0.2" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - webview_flutter: - dependency: "direct main" - description: - name: webview_flutter - sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 - url: "https://pub.dev" - source: hosted - version: "4.13.1" - webview_flutter_android: - dependency: transitive - description: - name: webview_flutter_android - sha256: "0f7fcd2c86bf36bdcf94881f7941ce0cbc4f8d104b9fdcd5fcbef90e2199db76" - url: "https://pub.dev" - source: hosted - version: "4.10.15" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" - url: "https://pub.dev" - source: hosted - version: "2.15.1" - webview_flutter_wkwebview: - dependency: transitive - description: - name: webview_flutter_wkwebview - sha256: d7219cfabc6f5fc2032e0fa980ec36d71f308a35a823395af1abc34d9a2ede83 - url: "https://pub.dev" - source: hosted - version: "3.24.2" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.10.7 <4.0.0" - flutter: ">=3.38.4" +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.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" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_pdfview: + dependency: "direct main" + description: + name: flutter_pdfview + sha256: "5b80e89f3ba6e478d1e897543c9634508284ad73476807febc188378986b69ee" + url: "https://pub.dev" + source: hosted + version: "1.4.4" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + 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" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "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" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + 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: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + url: "https://pub.dev" + source: hosted + version: "2.2.23" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + 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" + pdf: + dependency: transitive + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + 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" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + printing: + dependency: "direct main" + description: + name: printing + sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692" + url: "https://pub.dev" + source: hosted + version: "5.14.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.dev" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "0f7fcd2c86bf36bdcf94881f7941ce0cbc4f8d104b9fdcd5fcbef90e2199db76" + url: "https://pub.dev" + source: hosted + version: "4.10.15" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: d7219cfabc6f5fc2032e0fa980ec36d71f308a35a823395af1abc34d9a2ede83 + url: "https://pub.dev" + source: hosted + version: "3.24.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.7 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index c7cdf68..f4856ea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,95 +1,95 @@ -name: e_receipt_mobile -description: "E-Receipt portal moble version" -# 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. -version: 1.0.0+1 - -environment: - sdk: ^3.10.7 - -# 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. - cupertino_icons: ^1.0.8 - flutter_riverpod: ^2.6.1 - http: ^1.2.2 - flutter_pdfview: ^1.4.0 - path_provider: ^2.1.5 - webview_flutter: ^4.0.0 - printing: ^5.12.0 - -dev_dependencies: - 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 - - # 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: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # 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 +name: e_receipt_mobile +description: "E-Receipt portal moble version" +# 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 hused as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 2.0.0+1 + +environment: + sdk: ^3.10.7 + +# 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. + cupertino_icons: ^1.0.8 + flutter_riverpod: ^2.6.1 + http: ^1.2.2 + flutter_pdfview: ^1.4.0 + path_provider: ^2.1.5 + webview_flutter: ^4.0.0 + printing: ^5.12.0 + +dev_dependencies: + 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 + + # 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: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # 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 diff --git a/test/widget_test.dart b/test/widget_test.dart index fb6f436..06a1ac5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,14 +1,14 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:e_receipt_mobile/main.dart'; - -void main() { - testWidgets('Login page smoke test', (WidgetTester tester) async { - await tester.pumpWidget(const ProviderScope(child: EReceiptApp())); - - expect(find.text('Login'), findsOneWidget); - expect(find.text('Sign In'), findsOneWidget); - expect(find.text('Username'), findsOneWidget); - expect(find.text('Password'), findsOneWidget); - }); -} +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:e_receipt_mobile/main.dart'; + +void main() { + testWidgets('Login page smoke test', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: EReceiptApp())); + + expect(find.text('Login'), findsOneWidget); + expect(find.text('Sign In'), findsOneWidget); + expect(find.text('Username'), findsOneWidget); + expect(find.text('Password'), findsOneWidget); + }); +}