This commit is contained in:
Kyaw Khant Win 2026-04-02 00:28:59 +06:30
parent a436c85ba6
commit 4ee6b709d2
103 changed files with 7738 additions and 7764 deletions

90
.gitignore vendored
View File

@ -1,45 +1,45 @@
# Miscellaneous # Miscellaneous
*.class *.class
*.log *.log
*.pyc *.pyc
*.swp *.swp
.DS_Store .DS_Store
.atom/ .atom/
.build/ .build/
.buildlog/ .buildlog/
.history .history
.svn/ .svn/
.swiftpm/ .swiftpm/
migrate_working_dir/ migrate_working_dir/
# IntelliJ related # IntelliJ related
*.iml *.iml
*.ipr *.ipr
*.iws *.iws
.idea/ .idea/
# The .vscode folder contains launch configuration and tasks you configure in # 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 # VS Code which you may wish to be included in version control, so this line
# is commented out by default. # is commented out by default.
#.vscode/ #.vscode/
# Flutter/Dart/Pub related # Flutter/Dart/Pub related
**/doc/api/ **/doc/api/
**/ios/Flutter/.last_build_id **/ios/Flutter/.last_build_id
.dart_tool/ .dart_tool/
.flutter-plugins-dependencies .flutter-plugins-dependencies
.pub-cache/ .pub-cache/
.pub/ .pub/
/build/ /build/
/coverage/ /coverage/
# Symbolication related # Symbolication related
app.*.symbols app.*.symbols
# Obfuscation related # Obfuscation related
app.*.map.json app.*.map.json
# Android Studio will place build artifacts here # Android Studio will place build artifacts here
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release

View File

@ -1,33 +1,33 @@
# This file tracks properties of this Flutter project. # This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc. # Used by Flutter tool to assess capabilities and perform upgrades etc.
# #
# This file should be version controlled and should not be manually edited. # This file should be version controlled and should not be manually edited.
version: version:
revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6"
channel: "stable" channel: "stable"
project_type: app project_type: app
# Tracks metadata for the flutter migrate command # Tracks metadata for the flutter migrate command
migration: migration:
platforms: platforms:
- platform: root - platform: root
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
- platform: android - platform: android
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
- platform: ios - platform: ios
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
# User provided section # User provided section
# List of Local paths (relative to this file) that should be # List of Local paths (relative to this file) that should be
# ignored by the migrate tool. # ignored by the migrate tool.
# #
# Files that are not part of the templates will be ignored by default. # Files that are not part of the templates will be ignored by default.
unmanaged_files: unmanaged_files:
- 'lib/main.dart' - 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj' - 'ios/Runner.xcodeproj/project.pbxproj'

View File

@ -1,16 +1,16 @@
# e_receipt_mobile # e_receipt_mobile
E-Receipt portal moble version E-Receipt portal moble version
## Getting Started ## Getting Started
This project is a starting point for a Flutter application. This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project: 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) - [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials, [online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference. samples, guidance on mobile development, and a full API reference.

View File

@ -1,28 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to # This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints. # check for errors, warnings, and lints.
# #
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled # 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 # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`. # invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # 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 # included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints. # and their documentation is published at https://dart.dev/lints.
# #
# Instead of disabling a lint rule for the entire project in the # 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 # 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 # 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 # `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint. # producing the lint.
rules: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options

28
android/.gitignore vendored
View File

@ -1,14 +1,14 @@
gradle-wrapper.jar gradle-wrapper.jar
/.gradle /.gradle
/captures/ /captures/
/gradlew /gradlew
/gradlew.bat /gradlew.bat
/local.properties /local.properties
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
.cxx/ .cxx/
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore # See https://flutter.dev/to/reference-keystore
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks

View File

@ -1,44 +1,54 @@
plugins { import java.util.Properties
id("com.android.application") import java.io.FileInputStream
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. plugins {
id("dev.flutter.flutter-gradle-plugin") id("com.android.application")
} id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
android { id("dev.flutter.flutter-gradle-plugin")
namespace = "com.example.e_receipt_mobile" }
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
compileOptions { if (keystorePropertiesFile.exists()) {
sourceCompatibility = JavaVersion.VERSION_17 keystoreProperties.load(FileInputStream(keystorePropertiesFile))
targetCompatibility = JavaVersion.VERSION_17 }
}
kotlinOptions { android {
jvmTarget = JavaVersion.VERSION_17.toString() namespace = "com.example.e_receipt_mobile"
} compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). compileOptions {
applicationId = "com.example.e_receipt_mobile" sourceCompatibility = JavaVersion.VERSION_17
// You can update the following values to match your application needs. targetCompatibility = JavaVersion.VERSION_17
// For more information, see: https://flutter.dev/to/review-gradle-config. }
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion kotlinOptions {
versionCode = flutter.versionCode jvmTarget = JavaVersion.VERSION_17.toString()
versionName = flutter.versionName }
}
defaultConfig {
buildTypes { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
release { applicationId = "com.example.e_receipt_mobile"
// TODO: Add your own signing config for the release build. // You can update the following values to match your application needs.
// Signing with the debug keys for now, so `flutter run --release` works. // For more information, see: https://flutter.dev/to/review-gradle-config.
signingConfig = signingConfigs.getByName("debug") minSdk = flutter.minSdkVersion
} targetSdk = flutter.targetSdkVersion
} versionCode = flutter.versionCode
} versionName = flutter.versionName
}
flutter {
source = "../.." 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 = "../.."
}

View File

@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
</manifest> </manifest>

View File

@ -1,45 +1,48 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application <uses-permission android:name="android.permission.INTERNET" />
android:label="e_receipt_mobile" <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> <application
<activity android:label="E Receipt"
android:name=".MainActivity" android:name="${applicationName}"
android:exported="true" android:icon="@mipmap/ic_launcher">
android:launchMode="singleTop" <activity
android:taskAffinity="" android:name=".MainActivity"
android:theme="@style/LaunchTheme" android:exported="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:launchMode="singleTop"
android:hardwareAccelerated="true" android:taskAffinity=""
android:windowSoftInputMode="adjustResize"> android:theme="@style/LaunchTheme"
<!-- Specifies an Android theme to apply to this Activity as soon as android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
the Android process has started. This theme is visible to the user android:hardwareAccelerated="true"
while the Flutter UI initializes. After that, this theme continues android:windowSoftInputMode="adjustResize">
to determine the Window background behind the Flutter UI. --> <!-- Specifies an Android theme to apply to this Activity as soon as
<meta-data the Android process has started. This theme is visible to the user
android:name="io.flutter.embedding.android.NormalTheme" while the Flutter UI initializes. After that, this theme continues
android:resource="@style/NormalTheme" to determine the Window background behind the Flutter UI. -->
/> <meta-data
<intent-filter> android:name="io.flutter.embedding.android.NormalTheme"
<action android:name="android.intent.action.MAIN"/> android:resource="@style/NormalTheme"
<category android:name="android.intent.category.LAUNCHER"/> />
</intent-filter> <intent-filter>
</activity> <action android:name="android.intent.action.MAIN"/>
<!-- Don't delete the meta-data below. <category android:name="android.intent.category.LAUNCHER"/>
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> </intent-filter>
<meta-data </activity>
android:name="flutterEmbedding" <!-- Don't delete the meta-data below.
android:value="2" /> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
</application> <meta-data
<!-- Required to query activities that can process text, see: android:name="flutterEmbedding"
https://developer.android.com/training/package-visibility and android:value="2" />
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. </application>
<!-- Required to query activities that can process text, see:
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> https://developer.android.com/training/package-visibility and
<queries> https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<data android:mimeType="text/plain"/> <queries>
</intent> <intent>
</queries> <action android:name="android.intent.action.PROCESS_TEXT"/>
</manifest> <data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -1,5 +1,5 @@
package com.example.e_receipt_mobile package com.example.e_receipt_mobile
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity()

View File

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" /> <item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here --> <!-- You can insert your own image assets here -->
<!-- <item> <!-- <item>
<bitmap <bitmap
android:gravity="center" android:gravity="center"
android:src="@mipmap/launch_image" /> android:src="@mipmap/launch_image" />
</item> --> </item> -->
</layer-list> </layer-list>

View File

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" /> <item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here --> <!-- You can insert your own image assets here -->
<!-- <item> <!-- <item>
<bitmap <bitmap
android:gravity="center" android:gravity="center"
android:src="@mipmap/launch_image" /> android:src="@mipmap/launch_image" />
</item> --> </item> -->
</layer-list> </layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on --> <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its Flutter UI initializes, as well as behind your Flutter UI while its
running. running.
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
</style> </style>
</resources> </resources>

View File

@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off --> <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its Flutter UI initializes, as well as behind your Flutter UI while its
running. running.
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
</style> </style>
</resources> </resources>

View File

@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
</manifest> </manifest>

View File

@ -1,24 +1,24 @@
allprojects { allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
} }
val newBuildDir: Directory = val newBuildDir: Directory =
rootProject.layout.buildDirectory rootProject.layout.buildDirectory
.dir("../../build") .dir("../../build")
.get() .get()
rootProject.layout.buildDirectory.value(newBuildDir) rootProject.layout.buildDirectory.value(newBuildDir)
subprojects { subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir) project.layout.buildDirectory.value(newSubprojectBuildDir)
} }
subprojects { subprojects {
project.evaluationDependsOn(":app") project.evaluationDependsOn(":app")
} }
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory) delete(rootProject.layout.buildDirectory)
} }

View File

@ -1,2 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@ -1,26 +1,26 @@
pluginManagement { pluginManagement {
val flutterSdkPath = val flutterSdkPath =
run { run {
val properties = java.util.Properties() val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) } file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk") val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath flutterSdkPath
} }
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
} }
} }
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }
include(":app") include(":app")

68
ios/.gitignore vendored
View File

@ -1,34 +1,34 @@
**/dgph **/dgph
*.mode1v3 *.mode1v3
*.mode2v3 *.mode2v3
*.moved-aside *.moved-aside
*.pbxuser *.pbxuser
*.perspectivev3 *.perspectivev3
**/*sync/ **/*sync/
.sconsign.dblite .sconsign.dblite
.tags* .tags*
**/.vagrant/ **/.vagrant/
**/DerivedData/ **/DerivedData/
Icon? Icon?
**/Pods/ **/Pods/
**/.symlinks/ **/.symlinks/
profile profile
xcuserdata xcuserdata
**/.generated/ **/.generated/
Flutter/App.framework Flutter/App.framework
Flutter/Flutter.framework Flutter/Flutter.framework
Flutter/Flutter.podspec Flutter/Flutter.podspec
Flutter/Generated.xcconfig Flutter/Generated.xcconfig
Flutter/ephemeral/ Flutter/ephemeral/
Flutter/app.flx Flutter/app.flx
Flutter/app.zip Flutter/app.zip
Flutter/flutter_assets/ Flutter/flutter_assets/
Flutter/flutter_export_environment.sh Flutter/flutter_export_environment.sh
ServiceDefinitions.json ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.* Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules. # Exceptions to above rules.
!default.mode1v3 !default.mode1v3
!default.mode2v3 !default.mode2v3
!default.pbxuser !default.pbxuser
!default.perspectivev3 !default.perspectivev3

View File

@ -1,26 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>App</string> <string>App</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string> <string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>App</string> <string>App</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>FMWK</string> <string>FMWK</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>1.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>13.0</string> <string>13.0</string>
</dict> </dict>
</plist> </plist>

View File

@ -1 +1,2 @@
#include "Generated.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@ -1 +1,2 @@
#include "Generated.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
ios/Podfile Normal file
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Workspace <Workspace
version = "1.0"> version = "1.0">
<FileRef <FileRef
location = "self:"> location = "self:">
</FileRef> </FileRef>
</Workspace> </Workspace>

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>IDEDidComputeMac32BitWarning</key> <key>IDEDidComputeMac32BitWarning</key>
<true/> <true/>
</dict> </dict>
</plist> </plist>

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>PreviewsEnabled</key> <key>PreviewsEnabled</key>
<false/> <false/>
</dict> </dict>
</plist> </plist>

View File

@ -1,101 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1510" LastUpgradeVersion = "1510"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
buildForRunning = "YES" buildForRunning = "YES"
buildForProfiling = "YES" buildForProfiling = "YES"
buildForArchiving = "YES" buildForArchiving = "YES"
buildForAnalyzing = "YES"> buildForAnalyzing = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Runner.app"
BlueprintName = "Runner" BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
</BuildAction> </BuildAction>
<TestAction <TestAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion> <MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Runner.app"
BlueprintName = "Runner" BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</MacroExpansion> </MacroExpansion>
<Testables> <Testables>
<TestableReference <TestableReference
skipped = "NO" skipped = "NO"
parallelizable = "YES"> parallelizable = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5" BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest" BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests" BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
enableGPUValidationMode = "1" enableGPUValidationMode = "1"
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Runner.app"
BlueprintName = "Runner" BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Profile" buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES" shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = "" savedToolIdentifier = ""
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"> debugDocumentVersioning = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Runner.app"
BlueprintName = "Runner" BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</ProfileAction> </ProfileAction>
<AnalyzeAction <AnalyzeAction
buildConfiguration = "Debug"> buildConfiguration = "Debug">
</AnalyzeAction> </AnalyzeAction>
<ArchiveAction <ArchiveAction
buildConfiguration = "Release" buildConfiguration = "Release"
revealArchiveInOrganizer = "YES"> revealArchiveInOrganizer = "YES">
</ArchiveAction> </ArchiveAction>
</Scheme> </Scheme>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Workspace <Workspace
version = "1.0"> version = "1.0">
<FileRef <FileRef
location = "group:Runner.xcodeproj"> location = "group:Runner.xcodeproj">
</FileRef> </FileRef>
</Workspace> </Workspace>

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>IDEDidComputeMac32BitWarning</key> <key>IDEDidComputeMac32BitWarning</key>
<true/> <true/>
</dict> </dict>
</plist> </plist>

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>PreviewsEnabled</key> <key>PreviewsEnabled</key>
<false/> <false/>
</dict> </dict>
</plist> </plist>

View File

@ -1,13 +1,13 @@
import Flutter import Flutter
import UIKit import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
} }

View File

@ -1,122 +1,122 @@
{ {
"images" : [ "images" : [
{ {
"size" : "20x20", "size" : "20x20",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png", "filename" : "Icon-App-20x20@2x.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"size" : "20x20", "size" : "20x20",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png", "filename" : "Icon-App-20x20@3x.png",
"scale" : "3x" "scale" : "3x"
}, },
{ {
"size" : "29x29", "size" : "29x29",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png", "filename" : "Icon-App-29x29@1x.png",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"size" : "29x29", "size" : "29x29",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png", "filename" : "Icon-App-29x29@2x.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"size" : "29x29", "size" : "29x29",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png", "filename" : "Icon-App-29x29@3x.png",
"scale" : "3x" "scale" : "3x"
}, },
{ {
"size" : "40x40", "size" : "40x40",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png", "filename" : "Icon-App-40x40@2x.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"size" : "40x40", "size" : "40x40",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png", "filename" : "Icon-App-40x40@3x.png",
"scale" : "3x" "scale" : "3x"
}, },
{ {
"size" : "60x60", "size" : "60x60",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png", "filename" : "Icon-App-60x60@2x.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"size" : "60x60", "size" : "60x60",
"idiom" : "iphone", "idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png", "filename" : "Icon-App-60x60@3x.png",
"scale" : "3x" "scale" : "3x"
}, },
{ {
"size" : "20x20", "size" : "20x20",
"idiom" : "ipad", "idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png", "filename" : "Icon-App-20x20@1x.png",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"size" : "20x20", "size" : "20x20",
"idiom" : "ipad", "idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png", "filename" : "Icon-App-20x20@2x.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"size" : "29x29", "size" : "29x29",
"idiom" : "ipad", "idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png", "filename" : "Icon-App-29x29@1x.png",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"size" : "29x29", "size" : "29x29",
"idiom" : "ipad", "idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png", "filename" : "Icon-App-29x29@2x.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"size" : "40x40", "size" : "40x40",
"idiom" : "ipad", "idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png", "filename" : "Icon-App-40x40@1x.png",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"size" : "40x40", "size" : "40x40",
"idiom" : "ipad", "idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png", "filename" : "Icon-App-40x40@2x.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"size" : "76x76", "size" : "76x76",
"idiom" : "ipad", "idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png", "filename" : "Icon-App-76x76@1x.png",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"size" : "76x76", "size" : "76x76",
"idiom" : "ipad", "idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png", "filename" : "Icon-App-76x76@2x.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"size" : "83.5x83.5", "size" : "83.5x83.5",
"idiom" : "ipad", "idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png", "filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"size" : "1024x1024", "size" : "1024x1024",
"idiom" : "ios-marketing", "idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png", "filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x" "scale" : "1x"
} }
], ],
"info" : { "info" : {
"version" : 1, "version" : 1,
"author" : "xcode" "author" : "xcode"
} }
} }

View File

@ -1,23 +1,23 @@
{ {
"images" : [ "images" : [
{ {
"idiom" : "universal", "idiom" : "universal",
"filename" : "LaunchImage.png", "filename" : "LaunchImage.png",
"scale" : "1x" "scale" : "1x"
}, },
{ {
"idiom" : "universal", "idiom" : "universal",
"filename" : "LaunchImage@2x.png", "filename" : "LaunchImage@2x.png",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"idiom" : "universal", "idiom" : "universal",
"filename" : "LaunchImage@3x.png", "filename" : "LaunchImage@3x.png",
"scale" : "3x" "scale" : "3x"
} }
], ],
"info" : { "info" : {
"version" : 1, "version" : 1,
"author" : "xcode" "author" : "xcode"
} }
} }

View File

@ -1,5 +1,5 @@
# Launch Screen Assets # Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory. 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. 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.

View File

@ -1,37 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies> </dependencies>
<scenes> <scenes>
<!--View Controller--> <!--View Controller-->
<scene sceneID="EHf-IW-A2E"> <scene sceneID="EHf-IW-A2E">
<objects> <objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController"> <viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides> <layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/> <viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/> <viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides> </layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"> <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView> </imageView>
</subviews> </subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints> <constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints> </constraints>
</view> </view>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects> </objects>
<point key="canvasLocation" x="53" y="375"/> <point key="canvasLocation" x="53" y="375"/>
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="LaunchImage" width="168" height="185"/> <image name="LaunchImage" width="168" height="185"/>
</resources> </resources>
</document> </document>

View File

@ -1,26 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies> </dependencies>
<scenes> <scenes>
<!--Flutter View Controller--> <!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu"> <scene sceneID="tne-QT-ifu">
<objects> <objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController"> <viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides> <layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/> <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/> <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides> </layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC"> <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/> <rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/> <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view> </view>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects> </objects>
</scene> </scene>
</scenes> </scenes>
</document> </document>

View File

@ -1,49 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>E Receipt Mobile</string> <string>E Receipt Mobile</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>e_receipt_mobile</string> <string>e_receipt_mobile</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
</dict> </dict>
</plist> </plist>

View File

@ -1 +1 @@
#import "GeneratedPluginRegistrant.h" #import "GeneratedPluginRegistrant.h"

View File

@ -1,12 +1,12 @@
import Flutter import Flutter
import UIKit import UIKit
import XCTest import XCTest
class RunnerTests: XCTestCase { class RunnerTests: XCTestCase {
func testExample() { func testExample() {
// If you add code to the Runner application, consider adding tests here. // 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. // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
} }
} }

View File

@ -1,7 +1,7 @@
class AppConfig { class AppConfig {
const AppConfig._(); const AppConfig._();
// static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443'; // static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443';
static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com'; static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com';
static const String apiSecret = 'y812J21lhha11OS'; static const String apiSecret = 'y812J21lhha11OS';
} }

View File

@ -1,24 +1,24 @@
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class ApiKeyHttpClient extends http.BaseClient { class ApiKeyHttpClient extends http.BaseClient {
ApiKeyHttpClient({required http.Client innerClient, required this.apiSecret}) ApiKeyHttpClient({required http.Client innerClient, required this.apiSecret})
: _innerClient = innerClient; : _innerClient = innerClient;
final http.Client _innerClient; final http.Client _innerClient;
final String apiSecret; final String apiSecret;
@override @override
Future<http.StreamedResponse> send(http.BaseRequest request) { Future<http.StreamedResponse> send(http.BaseRequest request) {
if (apiSecret.trim().isNotEmpty && if (apiSecret.trim().isNotEmpty &&
!request.headers.keys.any((k) => k.toLowerCase() == 'x-api-key')) { !request.headers.keys.any((k) => k.toLowerCase() == 'x-api-key')) {
request.headers['x-api-key'] = apiSecret.trim(); request.headers['x-api-key'] = apiSecret.trim();
} }
return _innerClient.send(request); return _innerClient.send(request);
} }
@override @override
void close() { void close() {
_innerClient.close(); _innerClient.close();
super.close(); super.close();
} }
} }

View File

@ -1,93 +1,93 @@
import 'dart:async'; import 'dart:async';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
typedef AccessTokenProvider = String? Function(); typedef AccessTokenProvider = String? Function();
typedef RefreshAccessToken = Future<String?> Function(); typedef RefreshAccessToken = Future<String?> Function();
typedef RequestUriPredicate = bool Function(Uri uri); typedef RequestUriPredicate = bool Function(Uri uri);
class AuthHttpClient extends http.BaseClient { class AuthHttpClient extends http.BaseClient {
AuthHttpClient({ AuthHttpClient({
required http.Client innerClient, required http.Client innerClient,
required AccessTokenProvider accessTokenProvider, required AccessTokenProvider accessTokenProvider,
required RefreshAccessToken refreshAccessToken, required RefreshAccessToken refreshAccessToken,
RequestUriPredicate? shouldAttachToken, RequestUriPredicate? shouldAttachToken,
RequestUriPredicate? shouldRefreshOnUnauthorized, RequestUriPredicate? shouldRefreshOnUnauthorized,
}) : _innerClient = innerClient, }) : _innerClient = innerClient,
_accessTokenProvider = accessTokenProvider, _accessTokenProvider = accessTokenProvider,
_refreshAccessToken = refreshAccessToken, _refreshAccessToken = refreshAccessToken,
_shouldAttachToken = shouldAttachToken ?? _always, _shouldAttachToken = shouldAttachToken ?? _always,
_shouldRefreshOnUnauthorized = _shouldRefreshOnUnauthorized =
shouldRefreshOnUnauthorized ?? _always; shouldRefreshOnUnauthorized ?? _always;
final http.Client _innerClient; final http.Client _innerClient;
final AccessTokenProvider _accessTokenProvider; final AccessTokenProvider _accessTokenProvider;
final RefreshAccessToken _refreshAccessToken; final RefreshAccessToken _refreshAccessToken;
final RequestUriPredicate _shouldAttachToken; final RequestUriPredicate _shouldAttachToken;
final RequestUriPredicate _shouldRefreshOnUnauthorized; final RequestUriPredicate _shouldRefreshOnUnauthorized;
Future<String?>? _ongoingRefresh; Future<String?>? _ongoingRefresh;
static bool _always(Uri _) => true; static bool _always(Uri _) => true;
@override @override
Future<http.StreamedResponse> send(http.BaseRequest request) async { Future<http.StreamedResponse> send(http.BaseRequest request) async {
if (_shouldAttachToken(request.url)) { if (_shouldAttachToken(request.url)) {
final token = _accessTokenProvider(); final token = _accessTokenProvider();
if (token != null && if (token != null &&
token.trim().isNotEmpty && token.trim().isNotEmpty &&
!request.headers.containsKey('Authorization')) { !request.headers.containsKey('Authorization')) {
request.headers['Authorization'] = 'Bearer ${token.trim()}'; request.headers['Authorization'] = 'Bearer ${token.trim()}';
} }
} }
final firstResponse = await _innerClient.send(request); final firstResponse = await _innerClient.send(request);
if (firstResponse.statusCode != 401 || if (firstResponse.statusCode != 401 ||
!_shouldRefreshOnUnauthorized(request.url) || !_shouldRefreshOnUnauthorized(request.url) ||
request is! http.Request) { request is! http.Request) {
return firstResponse; return firstResponse;
} }
final refreshedToken = await _refreshTokenWithLock(); final refreshedToken = await _refreshTokenWithLock();
if (refreshedToken == null || refreshedToken.trim().isEmpty) { if (refreshedToken == null || refreshedToken.trim().isEmpty) {
return firstResponse; return firstResponse;
} }
await firstResponse.stream.drain(); await firstResponse.stream.drain();
final retryRequest = _cloneRequest(request); final retryRequest = _cloneRequest(request);
retryRequest.headers['Authorization'] = 'Bearer ${refreshedToken.trim()}'; retryRequest.headers['Authorization'] = 'Bearer ${refreshedToken.trim()}';
return _innerClient.send(retryRequest); return _innerClient.send(retryRequest);
} }
@override @override
void close() { void close() {
_innerClient.close(); _innerClient.close();
super.close(); super.close();
} }
Future<String?> _refreshTokenWithLock() async { Future<String?> _refreshTokenWithLock() async {
final inFlight = _ongoingRefresh; final inFlight = _ongoingRefresh;
if (inFlight != null) { if (inFlight != null) {
return inFlight; return inFlight;
} }
final nextRefresh = _refreshAccessToken(); final nextRefresh = _refreshAccessToken();
_ongoingRefresh = nextRefresh; _ongoingRefresh = nextRefresh;
try { try {
return await nextRefresh; return await nextRefresh;
} finally { } finally {
_ongoingRefresh = null; _ongoingRefresh = null;
} }
} }
http.Request _cloneRequest(http.Request original) { http.Request _cloneRequest(http.Request original) {
final cloned = http.Request(original.method, original.url) final cloned = http.Request(original.method, original.url)
..followRedirects = original.followRedirects ..followRedirects = original.followRedirects
..maxRedirects = original.maxRedirects ..maxRedirects = original.maxRedirects
..persistentConnection = original.persistentConnection ..persistentConnection = original.persistentConnection
..encoding = original.encoding ..encoding = original.encoding
..bodyBytes = original.bodyBytes; ..bodyBytes = original.bodyBytes;
cloned.headers.addAll(original.headers); cloned.headers.addAll(original.headers);
return cloned; return cloned;
} }
} }

View File

@ -1,118 +1,118 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class LoggingHttpClient extends http.BaseClient { class LoggingHttpClient extends http.BaseClient {
LoggingHttpClient([http.Client? innerClient]) LoggingHttpClient([http.Client? innerClient])
: _innerClient = innerClient ?? http.Client(); : _innerClient = innerClient ?? http.Client();
final http.Client _innerClient; final http.Client _innerClient;
@override @override
Future<http.StreamedResponse> send(http.BaseRequest request) async { Future<http.StreamedResponse> send(http.BaseRequest request) async {
final requestBody = request is http.Request ? request.body : null; final requestBody = request is http.Request ? request.body : null;
_logRequest(request, requestBody); _logRequest(request, requestBody);
try { try {
final response = await _innerClient.send(request); final response = await _innerClient.send(request);
final responseBytes = await response.stream.toBytes(); final responseBytes = await response.stream.toBytes();
final responseBody = _formatBodyForLogging( final responseBody = _formatBodyForLogging(
responseBytes: responseBytes, responseBytes: responseBytes,
contentType: response.headers['content-type'], contentType: response.headers['content-type'],
); );
_logResponse(response, responseBody); _logResponse(response, responseBody);
return http.StreamedResponse( return http.StreamedResponse(
Stream<List<int>>.fromIterable([responseBytes]), Stream<List<int>>.fromIterable([responseBytes]),
response.statusCode, response.statusCode,
contentLength: response.contentLength, contentLength: response.contentLength,
request: response.request, request: response.request,
headers: response.headers, headers: response.headers,
isRedirect: response.isRedirect, isRedirect: response.isRedirect,
persistentConnection: response.persistentConnection, persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase, reasonPhrase: response.reasonPhrase,
); );
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrint('[API][ERROR] ${request.method} ${request.url}'); debugPrint('[API][ERROR] ${request.method} ${request.url}');
debugPrint('[API][ERROR] $error'); debugPrint('[API][ERROR] $error');
debugPrint('[API][ERROR] $stackTrace'); debugPrint('[API][ERROR] $stackTrace');
rethrow; rethrow;
} }
} }
@override @override
void close() { void close() {
_innerClient.close(); _innerClient.close();
super.close(); super.close();
} }
void _logRequest(http.BaseRequest request, String? requestBody) { void _logRequest(http.BaseRequest request, String? requestBody) {
final safeHeaders = _sanitizeHeaders(request.headers); final safeHeaders = _sanitizeHeaders(request.headers);
debugPrint('[API][REQUEST] ${request.method} ${request.url}'); debugPrint('[API][REQUEST] ${request.method} ${request.url}');
debugPrint('[API][REQUEST][HEADERS] $safeHeaders'); debugPrint('[API][REQUEST][HEADERS] $safeHeaders');
if (requestBody != null && requestBody.isNotEmpty) { if (requestBody != null && requestBody.isNotEmpty) {
debugPrint('[API][REQUEST][BODY] $requestBody'); debugPrint('[API][REQUEST][BODY] $requestBody');
} }
} }
void _logResponse(http.StreamedResponse response, String responseBody) { void _logResponse(http.StreamedResponse response, String responseBody) {
final safeHeaders = _sanitizeHeaders(response.headers); final safeHeaders = _sanitizeHeaders(response.headers);
debugPrint( debugPrint(
'[API][RESPONSE] ${response.statusCode} ${response.request?.method} ${response.request?.url}', '[API][RESPONSE] ${response.statusCode} ${response.request?.method} ${response.request?.url}',
); );
debugPrint('[API][RESPONSE][HEADERS] $safeHeaders'); debugPrint('[API][RESPONSE][HEADERS] $safeHeaders');
if (responseBody.isNotEmpty) { if (responseBody.isNotEmpty) {
debugPrint('[API][RESPONSE][BODY] $responseBody'); debugPrint('[API][RESPONSE][BODY] $responseBody');
} }
} }
Map<String, String> _sanitizeHeaders(Map<String, String> headers) { Map<String, String> _sanitizeHeaders(Map<String, String> headers) {
final safeHeaders = Map<String, String>.from(headers); final safeHeaders = Map<String, String>.from(headers);
const sensitiveKeys = { const sensitiveKeys = {
'authorization', 'authorization',
'x-api-key', 'x-api-key',
'api-key', 'api-key',
'x-signature', 'x-signature',
'cookie', 'cookie',
'set-cookie', 'set-cookie',
}; };
for (final key in safeHeaders.keys.toList()) { for (final key in safeHeaders.keys.toList()) {
if (sensitiveKeys.contains(key.toLowerCase())) { if (sensitiveKeys.contains(key.toLowerCase())) {
safeHeaders[key] = '***'; safeHeaders[key] = '***';
} }
} }
return safeHeaders; return safeHeaders;
} }
String _formatBodyForLogging({ String _formatBodyForLogging({
required List<int> responseBytes, required List<int> responseBytes,
required String? contentType, required String? contentType,
}) { }) {
final normalizedType = contentType?.toLowerCase() ?? ''; final normalizedType = contentType?.toLowerCase() ?? '';
final isBinary = final isBinary =
normalizedType.contains('application/pdf') || normalizedType.contains('application/pdf') ||
normalizedType.contains('application/octet-stream') || normalizedType.contains('application/octet-stream') ||
normalizedType.startsWith('image/') || normalizedType.startsWith('image/') ||
normalizedType.startsWith('audio/') || normalizedType.startsWith('audio/') ||
normalizedType.startsWith('video/'); normalizedType.startsWith('video/');
if (isBinary) { if (isBinary) {
return '<binary ${responseBytes.length} bytes>'; return '<binary ${responseBytes.length} bytes>';
} }
final decoded = utf8.decode(responseBytes, allowMalformed: true).trim(); final decoded = utf8.decode(responseBytes, allowMalformed: true).trim();
if (decoded.isEmpty) { if (decoded.isEmpty) {
return ''; return '';
} }
const maxChars = 4000; const maxChars = 4000;
if (decoded.length <= maxChars) { if (decoded.length <= maxChars) {
return decoded; return decoded;
} }
return '${decoded.substring(0, maxChars)}'; return '${decoded.substring(0, maxChars)}';
} }
} }

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AppColors { class AppColors {
const AppColors._(); const AppColors._();
static const Color primary = Color(0xFF2A60AF); static const Color primary = Color(0xFF2A60AF);
} }

View File

@ -1,242 +1,242 @@
import 'dart:convert'; import 'dart:convert';
import 'package:e_receipt_mobile/domain/entities/login_user.dart'; 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/exceptions/auth_exceptions.dart';
import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class ApiAuthRepository implements AuthRepository { class ApiAuthRepository implements AuthRepository {
ApiAuthRepository({ ApiAuthRepository({
required this.baseUrl, required this.baseUrl,
required this.apiSecret, required this.apiSecret,
http.Client? client, http.Client? client,
}) : _client = client ?? http.Client(); }) : _client = client ?? http.Client();
final String baseUrl; final String baseUrl;
final String apiSecret; final String apiSecret;
final http.Client _client; final http.Client _client;
@override @override
Future<LoginUser> login({ Future<LoginUser> login({
required String username, required String username,
required String password, required String password,
}) async { }) async {
final uri = Uri.parse('$baseUrl/auth/login'); final uri = Uri.parse('$baseUrl/auth/login');
final requestBody = jsonEncode({ final requestBody = jsonEncode({
'username': username.trim().toLowerCase(), 'username': username.trim().toLowerCase(),
'password': password, 'password': password,
}); });
final response = await _client.post( final response = await _client.post(
uri, uri,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': apiSecret, 'x-api-key': apiSecret,
}, },
body: requestBody, body: requestBody,
); );
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
return _parseUserFromResponse( return _parseUserFromResponse(
response.body, response.body,
fallbackUsername: username.trim(), fallbackUsername: username.trim(),
); );
} }
final data = _asMap(response.body); final data = _asMap(response.body);
final payload = _extractPayload(data); final payload = _extractPayload(data);
final require = final require =
_pickString(data, const ['require', 'required']) ?? _pickString(data, const ['require', 'required']) ??
_pickString(payload, const ['require', 'required']); _pickString(payload, const ['require', 'required']);
if (require == 'PASSWORD_UPDATE_REQUIRED') { if (require == 'PASSWORD_UPDATE_REQUIRED') {
final userId = _extractUserId(data); final userId = _extractUserId(data);
if (userId != null) { if (userId != null) {
throw PasswordUpdateRequiredException( throw PasswordUpdateRequiredException(
userId: userId, userId: userId,
message: _extractErrorMessage(response.body), message: _extractErrorMessage(response.body),
); );
} }
} }
throw Exception(_extractErrorMessage(response.body)); throw Exception(_extractErrorMessage(response.body));
} }
@override @override
Future<LoginUser> refreshSession({ Future<LoginUser> refreshSession({
required String username, required String username,
required String refreshToken, required String refreshToken,
required String role, required String role,
}) async { }) async {
final uri = Uri.parse('$baseUrl/auth/refresh-token'); final uri = Uri.parse('$baseUrl/auth/refresh-token');
final requestBody = jsonEncode({ final requestBody = jsonEncode({
'refreshToken': refreshToken, 'refreshToken': refreshToken,
}); });
final response = await _client.post( final response = await _client.post(
uri, uri,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': apiSecret, 'x-api-key': apiSecret,
}, },
body: requestBody, body: requestBody,
); );
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
return _parseUserFromResponse( return _parseUserFromResponse(
response.body, response.body,
fallbackUsername: username, fallbackUsername: username,
fallbackRefreshToken: refreshToken, fallbackRefreshToken: refreshToken,
fallbackRole: role, fallbackRole: role,
); );
} }
throw Exception(_extractErrorMessage(response.body)); throw Exception(_extractErrorMessage(response.body));
} }
@override @override
Future<void> resetPassword({ Future<void> resetPassword({
required String userId, required String userId,
required String password, required String password,
}) async { }) async {
final uri = Uri.parse('$baseUrl/auth/reset-password'); final uri = Uri.parse('$baseUrl/auth/reset-password');
final requestBody = jsonEncode({ final requestBody = jsonEncode({
'userId': userId, 'userId': userId,
'password': password, 'password': password,
}); });
final response = await _client.put( final response = await _client.put(
uri, uri,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': apiSecret, 'x-api-key': apiSecret,
}, },
body: requestBody, body: requestBody,
); );
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
return; return;
} }
throw Exception(_extractErrorMessage(response.body)); throw Exception(_extractErrorMessage(response.body));
} }
@override @override
Future<void> logout({required String refreshToken}) async { Future<void> logout({required String refreshToken}) async {
final uri = Uri.parse('$baseUrl/auth/logout'); final uri = Uri.parse('$baseUrl/auth/logout');
final requestBody = jsonEncode({ final requestBody = jsonEncode({
'refreshToken': refreshToken, 'refreshToken': refreshToken,
}); });
final response = await _client.post( final response = await _client.post(
uri, uri,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': apiSecret, 'x-api-key': apiSecret,
}, },
body: requestBody, body: requestBody,
); );
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
return; return;
} }
throw Exception(_extractErrorMessage(response.body)); throw Exception(_extractErrorMessage(response.body));
} }
Map<String, dynamic> _asMap(String body) { Map<String, dynamic> _asMap(String body) {
try { try {
final decoded = jsonDecode(body); final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
return decoded; return decoded;
} }
return <String, dynamic>{}; return <String, dynamic>{};
} catch (_) { } catch (_) {
return <String, dynamic>{}; return <String, dynamic>{};
} }
} }
String _extractErrorMessage(String body) { String _extractErrorMessage(String body) {
final data = _asMap(body); final data = _asMap(body);
final message = data['message'] ?? data['error'] ?? data['detail']; final message = data['message'] ?? data['error'] ?? data['detail'];
if (message == null) { if (message == null) {
return 'Login failed'; return 'Login failed';
} }
return message.toString(); return message.toString();
} }
String? _extractUserId(Map<String, dynamic> data) { String? _extractUserId(Map<String, dynamic> data) {
final rootUser = data['user']; final rootUser = data['user'];
if (rootUser is Map<String, dynamic>) { if (rootUser is Map<String, dynamic>) {
final id = _pickString(rootUser, const ['id', 'userId', 'user_id']); final id = _pickString(rootUser, const ['id', 'userId', 'user_id']);
if (id != null) { if (id != null) {
return id; return id;
} }
} }
final payload = _extractPayload(data); final payload = _extractPayload(data);
final payloadUser = payload['user']; final payloadUser = payload['user'];
if (payloadUser is Map<String, dynamic>) { if (payloadUser is Map<String, dynamic>) {
return _pickString(payloadUser, const ['id', 'userId', 'user_id']); return _pickString(payloadUser, const ['id', 'userId', 'user_id']);
} }
return _pickString(payload, const ['userId', 'user_id', 'id']); return _pickString(payload, const ['userId', 'user_id', 'id']);
} }
LoginUser _parseUserFromResponse( LoginUser _parseUserFromResponse(
String body, { String body, {
required String fallbackUsername, required String fallbackUsername,
String? fallbackRefreshToken, String? fallbackRefreshToken,
String? fallbackRole, String? fallbackRole,
}) { }) {
final data = _asMap(body); final data = _asMap(body);
final payload = _extractPayload(data); final payload = _extractPayload(data);
final apiUsername = _pickString(payload, const ['username', 'email']); final apiUsername = _pickString(payload, const ['username', 'email']);
final token = _pickString(payload, const ['token', 'accessToken']); final token = _pickString(payload, const ['token', 'accessToken']);
final refreshToken = _pickString( final refreshToken = _pickString(
payload, payload,
const ['refreshToken', 'refresh_token'], const ['refreshToken', 'refresh_token'],
); );
final role = _pickString(payload, const ['role']); final role = _pickString(payload, const ['role']);
if (token == null) { if (token == null) {
throw Exception('Auth response is missing token'); throw Exception('Auth response is missing token');
} }
final effectiveRefreshToken = refreshToken ?? fallbackRefreshToken; final effectiveRefreshToken = refreshToken ?? fallbackRefreshToken;
if (effectiveRefreshToken == null) { if (effectiveRefreshToken == null) {
throw Exception('Auth response is missing refresh token'); throw Exception('Auth response is missing refresh token');
} }
final effectiveRole = role ?? fallbackRole; final effectiveRole = role ?? fallbackRole;
if (effectiveRole == null || effectiveRole.trim().isEmpty) { if (effectiveRole == null || effectiveRole.trim().isEmpty) {
throw Exception('Auth response is missing role'); throw Exception('Auth response is missing role');
} }
return LoginUser( return LoginUser(
username: apiUsername?.trim().isNotEmpty == true username: apiUsername?.trim().isNotEmpty == true
? apiUsername!.trim() ? apiUsername!.trim()
: fallbackUsername.trim(), : fallbackUsername.trim(),
token: token, token: token,
refreshToken: effectiveRefreshToken, refreshToken: effectiveRefreshToken,
role: effectiveRole, role: effectiveRole,
); );
} }
Map<String, dynamic> _extractPayload(Map<String, dynamic> data) { Map<String, dynamic> _extractPayload(Map<String, dynamic> data) {
final dynamic nested = data['data'] ?? data['result'] ?? data['payload']; final dynamic nested = data['data'] ?? data['result'] ?? data['payload'];
if (nested is Map<String, dynamic>) { if (nested is Map<String, dynamic>) {
return nested; return nested;
} }
return data; return data;
} }
String? _pickString(Map<String, dynamic> data, List<String> keys) { String? _pickString(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) { for (final key in keys) {
final value = data[key]; final value = data[key];
if (value != null && value.toString().trim().isNotEmpty) { if (value != null && value.toString().trim().isNotEmpty) {
return value.toString().trim(); return value.toString().trim();
} }
} }
return null; return null;
} }
} }

View File

@ -1,270 +1,270 @@
import 'dart:convert'; import 'dart:convert';
import 'package:e_receipt_mobile/domain/entities/merchant.dart'; 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/repositories/merchant_repository.dart';
import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class ApiMerchantRepository implements MerchantRepository { class ApiMerchantRepository implements MerchantRepository {
ApiMerchantRepository({ ApiMerchantRepository({
required this.baseUrl, required this.baseUrl,
required this.apiSecret, required this.apiSecret,
required http.Client client, required http.Client client,
}) : _client = client; }) : _client = client;
final String baseUrl; final String baseUrl;
final String apiSecret; final String apiSecret;
final http.Client _client; final http.Client _client;
@override @override
Future<List<Merchant>> getMerchants({ Future<List<Merchant>> getMerchants({
required String token, required String token,
int page = 1, int page = 1,
int limit = 10, int limit = 10,
String searchTerm = '', String searchTerm = '',
}) async { }) async {
final queryParameters = <String, String>{ final queryParameters = <String, String>{
'page': page.toString(), 'page': page.toString(),
'limit': limit.toString(), 'limit': limit.toString(),
if (searchTerm.trim().isNotEmpty) 'searchTerm': searchTerm.trim(), if (searchTerm.trim().isNotEmpty) 'searchTerm': searchTerm.trim(),
}; };
final uri = Uri.parse( final uri = Uri.parse(
'$baseUrl/merchant', '$baseUrl/merchant',
).replace(queryParameters: queryParameters); ).replace(queryParameters: queryParameters);
final response = await _client.get( final response = await _client.get(
uri, uri,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': apiSecret, 'x-api-key': apiSecret,
'Authorization': 'Bearer ${token.trim()}', 'Authorization': 'Bearer ${token.trim()}',
}, },
); );
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
return _parseMerchants(response.body); return _parseMerchants(response.body);
} }
throw Exception(_extractErrorMessage(response.body)); throw Exception(_extractErrorMessage(response.body));
} }
@override @override
Future<List<Terminal>> getTerminalsByMerchantId({ Future<List<Terminal>> getTerminalsByMerchantId({
required String token, required String token,
required String merchantId, required String merchantId,
}) async { }) async {
final uri = Uri.parse('$baseUrl/terminal/list/$merchantId'); final uri = Uri.parse('$baseUrl/terminal/list/$merchantId');
final response = await _client.get( final response = await _client.get(
uri, uri,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': apiSecret, 'x-api-key': apiSecret,
'Authorization': 'Bearer ${token.trim()}', 'Authorization': 'Bearer ${token.trim()}',
}, },
); );
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
return _parseTerminals(response.body); return _parseTerminals(response.body);
} }
throw Exception(_extractErrorMessage(response.body)); throw Exception(_extractErrorMessage(response.body));
} }
List<Merchant> _parseMerchants(String body) { List<Merchant> _parseMerchants(String body) {
final decoded = _tryDecode(body); final decoded = _tryDecode(body);
final list = _extractList(decoded); final list = _extractList(decoded);
return list.map(_merchantFromItem).whereType<Merchant>().toList(); return list.map(_merchantFromItem).whereType<Merchant>().toList();
} }
List<Terminal> _parseTerminals(String body) { List<Terminal> _parseTerminals(String body) {
final decoded = _tryDecode(body); final decoded = _tryDecode(body);
final list = _extractTerminalList(decoded); final list = _extractTerminalList(decoded);
return list.map(_terminalFromItem).whereType<Terminal>().toList(); return list.map(_terminalFromItem).whereType<Terminal>().toList();
} }
dynamic _tryDecode(String body) { dynamic _tryDecode(String body) {
try { try {
return jsonDecode(body); return jsonDecode(body);
} catch (_) { } catch (_) {
return null; return null;
} }
} }
List<dynamic> _extractList(dynamic decoded) { List<dynamic> _extractList(dynamic decoded) {
if (decoded is List<dynamic>) { if (decoded is List<dynamic>) {
return decoded; return decoded;
} }
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
final directMerchants = decoded['merchants']; final directMerchants = decoded['merchants'];
if (directMerchants is List<dynamic>) { if (directMerchants is List<dynamic>) {
return directMerchants; return directMerchants;
} }
final data = final data =
decoded['data'] ?? decoded['data'] ??
decoded['result'] ?? decoded['result'] ??
decoded['payload'] ?? decoded['payload'] ??
decoded['merchantData']; decoded['merchantData'];
if (data is List<dynamic>) { if (data is List<dynamic>) {
return data; return data;
} }
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final nestedMerchants = data['merchants']; final nestedMerchants = data['merchants'];
if (nestedMerchants is List<dynamic>) { if (nestedMerchants is List<dynamic>) {
return nestedMerchants; return nestedMerchants;
} }
} }
} }
return const <dynamic>[]; return const <dynamic>[];
} }
List<dynamic> _extractTerminalList(dynamic decoded) { List<dynamic> _extractTerminalList(dynamic decoded) {
if (decoded is List<dynamic>) { if (decoded is List<dynamic>) {
return decoded; return decoded;
} }
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
final directTerminals = decoded['terminals']; final directTerminals = decoded['terminals'];
if (directTerminals is List<dynamic>) { if (directTerminals is List<dynamic>) {
return directTerminals; return directTerminals;
} }
final data = final data =
decoded['data'] ?? decoded['data'] ??
decoded['result'] ?? decoded['result'] ??
decoded['payload'] ?? decoded['payload'] ??
decoded['terminalData']; decoded['terminalData'];
if (data is List<dynamic>) { if (data is List<dynamic>) {
return data; return data;
} }
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final nestedTerminals = data['terminals']; final nestedTerminals = data['terminals'];
if (nestedTerminals is List<dynamic>) { if (nestedTerminals is List<dynamic>) {
return nestedTerminals; return nestedTerminals;
} }
} }
} }
return const <dynamic>[]; return const <dynamic>[];
} }
Merchant? _merchantFromItem(dynamic item) { Merchant? _merchantFromItem(dynamic item) {
if (item is! Map<String, dynamic>) { if (item is! Map<String, dynamic>) {
return null; return null;
} }
return Merchant( return Merchant(
id: _pickString(item, const ['id', 'merchantId', 'merchant_id']), id: _pickString(item, const ['id', 'merchantId', 'merchant_id']),
name: _pickString(item, const ['name', 'merchantName', 'merchant_name']), name: _pickString(item, const ['name', 'merchantName', 'merchant_name']),
mobile: _pickString(item, const ['mobile']), mobile: _pickString(item, const ['mobile']),
description: _pickString(item, const ['description']), description: _pickString(item, const ['description']),
address: _pickString(item, const ['address']), address: _pickString(item, const ['address']),
address2: _pickString(item, const ['address2']), address2: _pickString(item, const ['address2']),
address3: _pickString(item, const ['address3']), address3: _pickString(item, const ['address3']),
phone: _pickString(item, const ['phone']), phone: _pickString(item, const ['phone']),
mids: _pickString(item, const ['mids']), mids: _pickString(item, const ['mids']),
createdBy: _pickString(item, const ['createdBy', 'created_by']), createdBy: _pickString(item, const ['createdBy', 'created_by']),
updatedBy: _pickString(item, const ['updatedBy', 'updated_by']), updatedBy: _pickString(item, const ['updatedBy', 'updated_by']),
createdAt: _pickString(item, const ['createdAt', 'created_at']), createdAt: _pickString(item, const ['createdAt', 'created_at']),
updatedAt: _pickString(item, const ['updatedAt', 'updated_at']), updatedAt: _pickString(item, const ['updatedAt', 'updated_at']),
); );
} }
Terminal? _terminalFromItem(dynamic item) { Terminal? _terminalFromItem(dynamic item) {
if (item is! Map<String, dynamic>) { if (item is! Map<String, dynamic>) {
return null; return null;
} }
return Terminal( return Terminal(
id: _pickString(item, const ['id', 'terminalId', 'terminal_id']), id: _pickString(item, const ['id', 'terminalId', 'terminal_id']),
serial: _pickString(item, const ['serial']), serial: _pickString(item, const ['serial']),
address: _pickString(item, const ['address']), address: _pickString(item, const ['address']),
address2: _pickString(item, const ['address2']), address2: _pickString(item, const ['address2']),
address3: _pickString(item, const ['address3']), address3: _pickString(item, const ['address3']),
name: _pickString(item, const ['name', 'terminalName', 'terminal_name']), name: _pickString(item, const ['name', 'terminalName', 'terminal_name']),
appId: _pickString(item, const ['appId', 'app_id']), appId: _pickString(item, const ['appId', 'app_id']),
tid: _pickString(item, const ['tid', 'terminalNo', 'terminal_no']), tid: _pickString(item, const ['tid', 'terminalNo', 'terminal_no']),
mids: _pickStringList(item, const ['mid', 'mids']), mids: _pickStringList(item, const ['mid', 'mids']),
merchantId: _pickString(item, const ['merchantId', 'merchant_id']), merchantId: _pickString(item, const ['merchantId', 'merchant_id']),
status: _pickString(item, const ['status']), status: _pickString(item, const ['status']),
totalTransactions: _pickInt(item, const [ totalTransactions: _pickInt(item, const [
'totalTransactions', 'totalTransactions',
'total_transactions', 'total_transactions',
]), ]),
totalAmount: _pickNum(item, const ['totalAmount', 'total_amount']), totalAmount: _pickNum(item, const ['totalAmount', 'total_amount']),
createdAt: _pickString(item, const ['createdAt', 'created_at']), createdAt: _pickString(item, const ['createdAt', 'created_at']),
updatedAt: _pickString(item, const ['updatedAt', 'updated_at']), updatedAt: _pickString(item, const ['updatedAt', 'updated_at']),
); );
} }
String? _pickString(Map<String, dynamic> data, List<String> keys) { String? _pickString(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) { for (final key in keys) {
final value = data[key]; final value = data[key];
if (value != null && value.toString().trim().isNotEmpty) { if (value != null && value.toString().trim().isNotEmpty) {
return value.toString().trim(); return value.toString().trim();
} }
} }
return null; return null;
} }
List<String>? _pickStringList(Map<String, dynamic> data, List<String> keys) { List<String>? _pickStringList(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) { for (final key in keys) {
final value = data[key]; final value = data[key];
if (value is List) { if (value is List) {
final results = value final results = value
.where((e) => e != null && e.toString().trim().isNotEmpty) .where((e) => e != null && e.toString().trim().isNotEmpty)
.map((e) => e.toString().trim()) .map((e) => e.toString().trim())
.toList(); .toList();
return results.isEmpty ? null : results; return results.isEmpty ? null : results;
} }
if (value != null && value.toString().trim().isNotEmpty) { if (value != null && value.toString().trim().isNotEmpty) {
return <String>[value.toString().trim()]; return <String>[value.toString().trim()];
} }
} }
return null; return null;
} }
int? _pickInt(Map<String, dynamic> data, List<String> keys) { int? _pickInt(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) { for (final key in keys) {
final value = data[key]; final value = data[key];
if (value is int) { if (value is int) {
return value; return value;
} }
if (value is num) { if (value is num) {
return value.toInt(); return value.toInt();
} }
if (value != null) { if (value != null) {
final parsed = int.tryParse(value.toString().trim()); final parsed = int.tryParse(value.toString().trim());
if (parsed != null) { if (parsed != null) {
return parsed; return parsed;
} }
} }
} }
return null; return null;
} }
num? _pickNum(Map<String, dynamic> data, List<String> keys) { num? _pickNum(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) { for (final key in keys) {
final value = data[key]; final value = data[key];
if (value is num) { if (value is num) {
return value; return value;
} }
if (value != null) { if (value != null) {
final parsed = num.tryParse(value.toString().trim()); final parsed = num.tryParse(value.toString().trim());
if (parsed != null) { if (parsed != null) {
return parsed; return parsed;
} }
} }
} }
return null; return null;
} }
String _extractErrorMessage(String body) { String _extractErrorMessage(String body) {
final decoded = _tryDecode(body); final decoded = _tryDecode(body);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
final message = final message =
decoded['message'] ?? decoded['error'] ?? decoded['detail']; decoded['message'] ?? decoded['error'] ?? decoded['detail'];
if (message != null) { if (message != null) {
return message.toString(); return message.toString();
} }
} }
return 'Failed to load merchants'; return 'Failed to load merchants';
} }
} }

View File

@ -1,139 +1,139 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; import 'package:e_receipt_mobile/domain/entities/receipt_content.dart';
import 'package:e_receipt_mobile/domain/repositories/receipt_repository.dart'; import 'package:e_receipt_mobile/domain/repositories/receipt_repository.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class ApiReceiptRepository implements ReceiptRepository { class ApiReceiptRepository implements ReceiptRepository {
ApiReceiptRepository({ ApiReceiptRepository({
required this.baseUrl, required this.baseUrl,
required this.apiSecret, required this.apiSecret,
required http.Client client, required http.Client client,
}) : _client = client; }) : _client = client;
final String baseUrl; final String baseUrl;
final String apiSecret; final String apiSecret;
final http.Client _client; final http.Client _client;
@override @override
Future<ReceiptContent> getTransactionReceipt({ Future<ReceiptContent> getTransactionReceipt({
required String token, required String token,
required String transactionId, required String transactionId,
required String copyFor, required String copyFor,
}) async { }) async {
final uri = _normalizeLocalhostForAndroid( final uri = _normalizeLocalhostForAndroid(
Uri.parse('$baseUrl/transaction/pdf').replace( Uri.parse('$baseUrl/transaction/pdf').replace(
queryParameters: <String, String>{ queryParameters: <String, String>{
'transactionId': transactionId, 'transactionId': transactionId,
'copyFor': copyFor, 'copyFor': copyFor,
}, },
), ),
); );
final request = http.Request('GET', uri); final request = http.Request('GET', uri);
request.headers.addAll(<String, String>{ request.headers.addAll(<String, String>{
'Accept': 'application/pdf,text/html', 'Accept': 'application/pdf,text/html',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': apiSecret, 'x-api-key': apiSecret,
if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}',
}); });
final streamed = await _client.send(request); final streamed = await _client.send(request);
final bytes = await streamed.stream.toBytes(); final bytes = await streamed.stream.toBytes();
final contentType = streamed.headers['content-type']?.trim() ?? ''; final contentType = streamed.headers['content-type']?.trim() ?? '';
if (streamed.statusCode < 200 || streamed.statusCode >= 300) { if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
final body = const Utf8Decoder( final body = const Utf8Decoder(
allowMalformed: true, allowMalformed: true,
).convert(bytes).trim(); ).convert(bytes).trim();
throw Exception( throw Exception(
body.isNotEmpty && body.length <= 500 body.isNotEmpty && body.length <= 500
? body ? body
: 'Failed to load receipt (${streamed.statusCode}) $contentType', : 'Failed to load receipt (${streamed.statusCode}) $contentType',
); );
} }
if (_looksLikePdf(bytes: bytes, contentType: contentType)) { if (_looksLikePdf(bytes: bytes, contentType: contentType)) {
return ReceiptPdfContent(bytes); return ReceiptPdfContent(bytes);
} }
final html = const Utf8Decoder(allowMalformed: true).convert(bytes); final html = const Utf8Decoder(allowMalformed: true).convert(bytes);
return ReceiptHtmlContent(html: html, contentType: contentType); return ReceiptHtmlContent(html: html, contentType: contentType);
} }
@override @override
Future<Uint8List> getTransactionReceiptPdfBytes({ Future<Uint8List> getTransactionReceiptPdfBytes({
required String token, required String token,
required String transactionId, required String transactionId,
required String copyFor, required String copyFor,
}) async { }) async {
final uri = _normalizeLocalhostForAndroid( final uri = _normalizeLocalhostForAndroid(
Uri.parse('$baseUrl/transaction/pdf').replace( Uri.parse('$baseUrl/transaction/pdf').replace(
queryParameters: <String, String>{ queryParameters: <String, String>{
'transactionId': transactionId, 'transactionId': transactionId,
'copyFor': copyFor, 'copyFor': copyFor,
}, },
), ),
); );
final request = http.Request('GET', uri); final request = http.Request('GET', uri);
request.headers.addAll(<String, String>{ request.headers.addAll(<String, String>{
'Accept': 'application/pdf', 'Accept': 'application/pdf',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': apiSecret, 'x-api-key': apiSecret,
if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}',
}); });
final streamed = await _client.send(request); final streamed = await _client.send(request);
final bytes = await streamed.stream.toBytes(); final bytes = await streamed.stream.toBytes();
final contentType = streamed.headers['content-type']?.trim() ?? ''; final contentType = streamed.headers['content-type']?.trim() ?? '';
if (streamed.statusCode < 200 || streamed.statusCode >= 300) { if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
final body = const Utf8Decoder( final body = const Utf8Decoder(
allowMalformed: true, allowMalformed: true,
).convert(bytes).trim(); ).convert(bytes).trim();
throw Exception( throw Exception(
body.isNotEmpty && body.length <= 500 body.isNotEmpty && body.length <= 500
? body ? body
: 'Failed to load receipt PDF (${streamed.statusCode}) $contentType', : 'Failed to load receipt PDF (${streamed.statusCode}) $contentType',
); );
} }
if (_looksLikePdf(bytes: bytes, contentType: contentType)) { if (_looksLikePdf(bytes: bytes, contentType: contentType)) {
return bytes; return bytes;
} }
final body = const Utf8Decoder(allowMalformed: true).convert(bytes).trim(); final body = const Utf8Decoder(allowMalformed: true).convert(bytes).trim();
throw Exception( throw Exception(
body.isNotEmpty && body.length <= 500 body.isNotEmpty && body.length <= 500
? body ? body
: 'Server did not return a PDF ($contentType)', : 'Server did not return a PDF ($contentType)',
); );
} }
Uri _normalizeLocalhostForAndroid(Uri uri) { Uri _normalizeLocalhostForAndroid(Uri uri) {
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return uri; return uri;
} }
if (uri.host != 'localhost') { if (uri.host != 'localhost') {
return uri; return uri;
} }
return uri.replace(host: '10.0.2.2'); return uri.replace(host: '10.0.2.2');
} }
bool _looksLikePdf({required List<int> bytes, required String contentType}) { bool _looksLikePdf({required List<int> bytes, required String contentType}) {
final type = contentType.toLowerCase(); final type = contentType.toLowerCase();
if (type.contains('application/pdf')) { if (type.contains('application/pdf')) {
return true; return true;
} }
if (bytes.length < 4) { if (bytes.length < 4) {
return false; return false;
} }
return bytes[0] == 0x25 && // % return bytes[0] == 0x25 && // %
bytes[1] == 0x50 && // P bytes[1] == 0x50 && // P
bytes[2] == 0x44 && // D bytes[2] == 0x44 && // D
bytes[3] == 0x46; // F bytes[3] == 0x46; // F
} }
} }

View File

@ -1,148 +1,148 @@
import 'dart:convert'; import 'dart:convert';
import 'package:e_receipt_mobile/domain/entities/transaction_record.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/domain/repositories/transaction_repository.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class ApiTransactionRepository implements TransactionRepository { class ApiTransactionRepository implements TransactionRepository {
ApiTransactionRepository({ ApiTransactionRepository({
required this.baseUrl, required this.baseUrl,
required this.apiSecret, required this.apiSecret,
required http.Client client, required http.Client client,
}) : _client = client; }) : _client = client;
final String baseUrl; final String baseUrl;
final String apiSecret; final String apiSecret;
final http.Client _client; final http.Client _client;
@override @override
Future<List<TransactionRecord>> getTransactions({ Future<List<TransactionRecord>> getTransactions({
required String token, required String token,
required String serial, required String serial,
required String range, required String range,
int page = 1, int page = 1,
int limit = 8, int limit = 8,
String searchTerm = '', String searchTerm = '',
String sort = '', String sort = '',
}) async { }) async {
final uri = Uri.parse('$baseUrl/transaction').replace( final uri = Uri.parse('$baseUrl/transaction').replace(
queryParameters: { queryParameters: {
'page': page.toString(), 'page': page.toString(),
'limit': limit.toString(), 'limit': limit.toString(),
'serial': serial, 'serial': serial,
'range': range, 'range': range,
if (searchTerm.trim().isNotEmpty) 'search': searchTerm.trim(), if (searchTerm.trim().isNotEmpty) 'search': searchTerm.trim(),
if (sort.trim().isNotEmpty) 'sort': sort.trim(), if (sort.trim().isNotEmpty) 'sort': sort.trim(),
}, },
); );
final response = await _client.get( final response = await _client.get(
uri, uri,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': apiSecret, 'x-api-key': apiSecret,
'Authorization': 'Bearer ${token.trim()}', 'Authorization': 'Bearer ${token.trim()}',
}, },
); );
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
return _parseTransactions(response.body); return _parseTransactions(response.body);
} }
throw Exception(_extractErrorMessage(response.body)); throw Exception(_extractErrorMessage(response.body));
} }
List<TransactionRecord> _parseTransactions(String body) { List<TransactionRecord> _parseTransactions(String body) {
final decoded = _tryDecode(body); final decoded = _tryDecode(body);
final list = _extractTransactionList(decoded); final list = _extractTransactionList(decoded);
return list return list
.map((item) => _transactionFromItem(item)) .map((item) => _transactionFromItem(item))
.whereType<TransactionRecord>() .whereType<TransactionRecord>()
.toList(); .toList();
} }
dynamic _tryDecode(String body) { dynamic _tryDecode(String body) {
try { try {
return jsonDecode(body); return jsonDecode(body);
} catch (_) { } catch (_) {
return null; return null;
} }
} }
List<dynamic> _extractTransactionList(dynamic decoded) { List<dynamic> _extractTransactionList(dynamic decoded) {
if (decoded is List<dynamic>) { if (decoded is List<dynamic>) {
return decoded; return decoded;
} }
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
final direct = decoded['transactions']; final direct = decoded['transactions'];
if (direct is List<dynamic>) { if (direct is List<dynamic>) {
return direct; return direct;
} }
final data = decoded['data'] ?? decoded['result'] ?? decoded['payload']; final data = decoded['data'] ?? decoded['result'] ?? decoded['payload'];
if (data is List<dynamic>) { if (data is List<dynamic>) {
return data; return data;
} }
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final nested = data['transactions']; final nested = data['transactions'];
if (nested is List<dynamic>) { if (nested is List<dynamic>) {
return nested; return nested;
} }
} }
} }
return const <dynamic>[]; return const <dynamic>[];
} }
TransactionRecord? _transactionFromItem(dynamic item) { TransactionRecord? _transactionFromItem(dynamic item) {
if (item is! Map<String, dynamic>) { if (item is! Map<String, dynamic>) {
return null; return null;
} }
return TransactionRecord( return TransactionRecord(
id: _pickString(item, const ['id', 'transactionId', 'transaction_id']), id: _pickString(item, const ['id', 'transactionId', 'transaction_id']),
rrn: _pickString(item, const ['rrn', 'referenceNo', 'reference_no', 'DE37']), rrn: _pickString(item, const ['rrn', 'referenceNo', 'reference_no', 'DE37']),
amount: _pickNum(item, const ['amount', 'totalAmount', 'total_amount', 'DE4']), amount: _pickNum(item, const ['amount', 'totalAmount', 'total_amount', 'DE4']),
status: _pickString(item, const ['status', 'description', 'DE39']), status: _pickString(item, const ['status', 'description', 'DE39']),
type: _pickString(item, const ['type', 'transactionType', 'DE3']), type: _pickString(item, const ['type', 'transactionType', 'DE3']),
createdAt: _pickString(item, const ['createdAt', 'created_at', 'CREATED_AT']), createdAt: _pickString(item, const ['createdAt', 'created_at', 'CREATED_AT']),
raw: item, raw: item,
); );
} }
String? _pickString(Map<String, dynamic> data, List<String> keys) { String? _pickString(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) { for (final key in keys) {
final value = data[key]; final value = data[key];
if (value != null && value.toString().trim().isNotEmpty) { if (value != null && value.toString().trim().isNotEmpty) {
return value.toString().trim(); return value.toString().trim();
} }
} }
return null; return null;
} }
num? _pickNum(Map<String, dynamic> data, List<String> keys) { num? _pickNum(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) { for (final key in keys) {
final value = data[key]; final value = data[key];
if (value is num) { if (value is num) {
return value; return value;
} }
if (value != null) { if (value != null) {
final parsed = num.tryParse(value.toString().trim()); final parsed = num.tryParse(value.toString().trim());
if (parsed != null) { if (parsed != null) {
return parsed; return parsed;
} }
} }
} }
return null; return null;
} }
String _extractErrorMessage(String body) { String _extractErrorMessage(String body) {
final decoded = _tryDecode(body); final decoded = _tryDecode(body);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
final message = decoded['message'] ?? decoded['error'] ?? decoded['detail']; final message = decoded['message'] ?? decoded['error'] ?? decoded['detail'];
if (message != null) { if (message != null) {
return message.toString(); return message.toString();
} }
} }
return 'Failed to load transactions'; return 'Failed to load transactions';
} }
} }

View File

@ -1,70 +1,70 @@
import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:e_receipt_mobile/domain/entities/login_user.dart';
import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart';
class MockAuthRepository implements AuthRepository { class MockAuthRepository implements AuthRepository {
@override @override
Future<LoginUser> login({ Future<LoginUser> login({
required String username, required String username,
required String password, required String password,
}) async { }) async {
await Future<void>.delayed(const Duration(milliseconds: 800)); await Future<void>.delayed(const Duration(milliseconds: 800));
if (username.trim().isEmpty || password.isEmpty) { if (username.trim().isEmpty || password.isEmpty) {
throw Exception('Username and password are required.'); throw Exception('Username and password are required.');
} }
if (password.length < 6) { if (password.length < 6) {
throw Exception('Password must be at least 6 characters.'); throw Exception('Password must be at least 6 characters.');
} }
return LoginUser( return LoginUser(
username: username.trim(), username: username.trim(),
token: 'mock-access-token', token: 'mock-access-token',
refreshToken: 'mock-refresh-token', refreshToken: 'mock-refresh-token',
role: username.trim().toLowerCase() == 'admin' ? 'admin' : 'user', role: username.trim().toLowerCase() == 'admin' ? 'admin' : 'user',
); );
} }
@override @override
Future<LoginUser> refreshSession({ Future<LoginUser> refreshSession({
required String username, required String username,
required String refreshToken, required String refreshToken,
required String role, required String role,
}) async { }) async {
await Future<void>.delayed(const Duration(milliseconds: 300)); await Future<void>.delayed(const Duration(milliseconds: 300));
if (refreshToken.trim().isEmpty) { if (refreshToken.trim().isEmpty) {
throw Exception('Refresh token is required.'); throw Exception('Refresh token is required.');
} }
return LoginUser( return LoginUser(
username: username.trim(), username: username.trim(),
token: 'mock-access-token-refreshed', token: 'mock-access-token-refreshed',
refreshToken: 'mock-refresh-token-refreshed', refreshToken: 'mock-refresh-token-refreshed',
role: role, role: role,
); );
} }
@override @override
Future<void> resetPassword({ Future<void> resetPassword({
required String userId, required String userId,
required String password, required String password,
}) async { }) async {
await Future<void>.delayed(const Duration(milliseconds: 500)); await Future<void>.delayed(const Duration(milliseconds: 500));
if (userId.trim().isEmpty) { if (userId.trim().isEmpty) {
throw Exception('User ID is required.'); throw Exception('User ID is required.');
} }
if (password.length < 8) { if (password.length < 8) {
throw Exception('Password must be at least 8 characters.'); throw Exception('Password must be at least 8 characters.');
} }
} }
@override @override
Future<void> logout({required String refreshToken}) async { Future<void> logout({required String refreshToken}) async {
await Future<void>.delayed(const Duration(milliseconds: 250)); await Future<void>.delayed(const Duration(milliseconds: 250));
if (refreshToken.trim().isEmpty) { if (refreshToken.trim().isEmpty) {
throw Exception('Refresh token is required.'); throw Exception('Refresh token is required.');
} }
} }
} }

View File

@ -1,17 +1,17 @@
class LoginUser { class LoginUser {
const LoginUser({ const LoginUser({
required this.username, required this.username,
required this.token, required this.token,
required this.refreshToken, required this.refreshToken,
required this.role, required this.role,
}); });
final String username; final String username;
final String token; final String token;
final String refreshToken; final String refreshToken;
final String role; final String role;
bool get isAdmin => role.toLowerCase() == 'admin'; bool get isAdmin => role.toLowerCase() == 'admin';
bool get isCashier => role.toLowerCase() == 'cashier'; bool get isCashier => role.toLowerCase() == 'cashier';
} }

View File

@ -1,31 +1,31 @@
class Merchant { class Merchant {
const Merchant({ const Merchant({
this.id, this.id,
this.name, this.name,
this.mobile, this.mobile,
this.description, this.description,
this.address, this.address,
this.address2, this.address2,
this.address3, this.address3,
this.phone, this.phone,
this.mids, this.mids,
this.createdBy, this.createdBy,
this.updatedBy, this.updatedBy,
this.createdAt, this.createdAt,
this.updatedAt, this.updatedAt,
}); });
final String? id; final String? id;
final String? name; final String? name;
final String? mobile; final String? mobile;
final String? description; final String? description;
final String? address; final String? address;
final String? address2; final String? address2;
final String? address3; final String? address3;
final String? phone; final String? phone;
final String? mids; final String? mids;
final String? createdBy; final String? createdBy;
final String? updatedBy; final String? updatedBy;
final String? createdAt; final String? createdAt;
final String? updatedAt; final String? updatedAt;
} }

View File

@ -1,18 +1,18 @@
import 'dart:typed_data'; import 'dart:typed_data';
sealed class ReceiptContent { sealed class ReceiptContent {
const ReceiptContent(); const ReceiptContent();
} }
class ReceiptPdfContent extends ReceiptContent { class ReceiptPdfContent extends ReceiptContent {
const ReceiptPdfContent(this.bytes); const ReceiptPdfContent(this.bytes);
final Uint8List bytes; final Uint8List bytes;
} }
class ReceiptHtmlContent extends ReceiptContent { class ReceiptHtmlContent extends ReceiptContent {
const ReceiptHtmlContent({required this.html, required this.contentType}); const ReceiptHtmlContent({required this.html, required this.contentType});
final String html; final String html;
final String contentType; final String contentType;
} }

View File

@ -1,35 +1,35 @@
class Terminal { class Terminal {
const Terminal({ const Terminal({
this.id, this.id,
this.serial, this.serial,
this.address, this.address,
this.address2, this.address2,
this.address3, this.address3,
this.name, this.name,
this.appId, this.appId,
this.tid, this.tid,
this.mids, this.mids,
this.merchantId, this.merchantId,
this.status, this.status,
this.totalTransactions, this.totalTransactions,
this.totalAmount, this.totalAmount,
this.createdAt, this.createdAt,
this.updatedAt, this.updatedAt,
}); });
final String? id; final String? id;
final String? serial; final String? serial;
final String? address; final String? address;
final String? address2; final String? address2;
final String? address3; final String? address3;
final String? name; final String? name;
final String? appId; final String? appId;
final String? tid; final String? tid;
final List<String>? mids; final List<String>? mids;
final String? merchantId; final String? merchantId;
final String? status; final String? status;
final int? totalTransactions; final int? totalTransactions;
final num? totalAmount; final num? totalAmount;
final String? createdAt; final String? createdAt;
final String? updatedAt; final String? updatedAt;
} }

View File

@ -1,19 +1,19 @@
class TransactionRecord { class TransactionRecord {
const TransactionRecord({ const TransactionRecord({
this.id, this.id,
this.rrn, this.rrn,
this.amount, this.amount,
this.status, this.status,
this.type, this.type,
this.createdAt, this.createdAt,
this.raw, this.raw,
}); });
final String? id; final String? id;
final String? rrn; final String? rrn;
final num? amount; final num? amount;
final String? status; final String? status;
final String? type; final String? type;
final String? createdAt; final String? createdAt;
final Map<String, dynamic>? raw; final Map<String, dynamic>? raw;
} }

View File

@ -1,13 +1,13 @@
class PasswordUpdateRequiredException implements Exception { class PasswordUpdateRequiredException implements Exception {
const PasswordUpdateRequiredException({ const PasswordUpdateRequiredException({
required this.userId, required this.userId,
this.message = 'Password update required', this.message = 'Password update required',
}); });
final String userId; final String userId;
final String message; final String message;
@override @override
String toString() => message; String toString() => message;
} }

View File

@ -1,15 +1,15 @@
import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:e_receipt_mobile/domain/entities/login_user.dart';
abstract class AuthRepository { abstract class AuthRepository {
Future<LoginUser> login({required String username, required String password}); Future<LoginUser> login({required String username, required String password});
Future<LoginUser> refreshSession({ Future<LoginUser> refreshSession({
required String username, required String username,
required String refreshToken, required String refreshToken,
required String role, required String role,
}); });
Future<void> resetPassword({required String userId, required String password}); Future<void> resetPassword({required String userId, required String password});
Future<void> logout({required String refreshToken}); Future<void> logout({required String refreshToken});
} }

View File

@ -1,16 +1,16 @@
import 'package:e_receipt_mobile/domain/entities/merchant.dart'; import 'package:e_receipt_mobile/domain/entities/merchant.dart';
import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart';
abstract class MerchantRepository { abstract class MerchantRepository {
Future<List<Merchant>> getMerchants({ Future<List<Merchant>> getMerchants({
required String token, required String token,
int page = 1, int page = 1,
int limit = 10, int limit = 10,
String searchTerm = '', String searchTerm = '',
}); });
Future<List<Terminal>> getTerminalsByMerchantId({ Future<List<Terminal>> getTerminalsByMerchantId({
required String token, required String token,
required String merchantId, required String merchantId,
}); });
} }

View File

@ -1,17 +1,17 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; import 'package:e_receipt_mobile/domain/entities/receipt_content.dart';
abstract class ReceiptRepository { abstract class ReceiptRepository {
Future<ReceiptContent> getTransactionReceipt({ Future<ReceiptContent> getTransactionReceipt({
required String token, required String token,
required String transactionId, required String transactionId,
required String copyFor, required String copyFor,
}); });
Future<Uint8List> getTransactionReceiptPdfBytes({ Future<Uint8List> getTransactionReceiptPdfBytes({
required String token, required String token,
required String transactionId, required String transactionId,
required String copyFor, required String copyFor,
}); });
} }

View File

@ -1,13 +1,13 @@
import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; import 'package:e_receipt_mobile/domain/entities/transaction_record.dart';
abstract class TransactionRepository { abstract class TransactionRepository {
Future<List<TransactionRecord>> getTransactions({ Future<List<TransactionRecord>> getTransactions({
required String token, required String token,
required String serial, required String serial,
required String range, required String range,
int page = 1, int page = 1,
int limit = 8, int limit = 8,
String searchTerm = '', String searchTerm = '',
String sort = '', String sort = '',
}); });
} }

View File

@ -1,96 +1,96 @@
import 'package:e_receipt_mobile/core/theme/app_colors.dart'; import 'package:e_receipt_mobile/core/theme/app_colors.dart';
import 'package:e_receipt_mobile/domain/entities/terminal.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/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/home/home_screen.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/login/login_page.dart';
import 'package:e_receipt_mobile/presentation/settings/theme_mode_provider.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:e_receipt_mobile/presentation/terminal/terminal_next_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setEnabledSystemUIMode( SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual, SystemUiMode.manual,
overlays: SystemUiOverlay.values, overlays: SystemUiOverlay.values,
); );
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle( const SystemUiOverlayStyle(
statusBarColor: AppColors.primary, statusBarColor: AppColors.primary,
statusBarIconBrightness: Brightness.light, statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark, statusBarBrightness: Brightness.dark,
), ),
); );
runApp(const ProviderScope(child: EReceiptApp())); runApp(const ProviderScope(child: EReceiptApp()));
} }
class EReceiptApp extends ConsumerWidget { class EReceiptApp extends ConsumerWidget {
const EReceiptApp({super.key}); const EReceiptApp({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(appThemeModeProvider); final themeMode = ref.watch(appThemeModeProvider);
return MaterialApp( return MaterialApp(
title: 'E-Receipt', title: 'E-Receipt',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
themeMode: themeMode, themeMode: themeMode,
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.primary, seedColor: AppColors.primary,
), ),
primaryColor: AppColors.primary, primaryColor: AppColors.primary,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle( systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: AppColors.primary, statusBarColor: AppColors.primary,
statusBarIconBrightness: Brightness.light, statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark, statusBarBrightness: Brightness.dark,
), ),
), ),
useMaterial3: true, useMaterial3: true,
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.primary, seedColor: AppColors.primary,
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle( systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: AppColors.primary, statusBarColor: AppColors.primary,
statusBarIconBrightness: Brightness.light, statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark, statusBarBrightness: Brightness.dark,
), ),
), ),
useMaterial3: true, useMaterial3: true,
), ),
home: const AppRoot(), home: const AppRoot(),
); );
} }
} }
class AppRoot extends ConsumerWidget { class AppRoot extends ConsumerWidget {
const AppRoot({super.key}); const AppRoot({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(sessionControllerProvider); final user = ref.watch(sessionControllerProvider);
if (user == null) { if (user == null) {
return const LoginPage(); return const LoginPage();
} }
final role = user.role.trim().toLowerCase(); final role = user.role.trim().toLowerCase();
if (role == 'cashier') { if (role == 'cashier') {
final serial = user.username.trim().toUpperCase(); final serial = user.username.trim().toUpperCase();
final terminal = Terminal(serial: serial, name: serial); final terminal = Terminal(serial: serial, name: serial);
return TerminalNextScreen(merchantName: serial, terminal: terminal); return TerminalNextScreen(merchantName: serial, terminal: terminal);
} }
return HomeScreen(user: user); return HomeScreen(user: user);
} }
} }

View File

@ -1,24 +1,24 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@immutable @immutable
class LogoutState { class LogoutState {
const LogoutState({ const LogoutState({
this.isLoading = false, this.isLoading = false,
this.lastError, this.lastError,
}); });
final bool isLoading; final bool isLoading;
final Object? lastError; final Object? lastError;
LogoutState copyWith({ LogoutState copyWith({
bool? isLoading, bool? isLoading,
Object? lastError, Object? lastError,
bool clearError = false, bool clearError = false,
}) { }) {
return LogoutState( return LogoutState(
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
lastError: clearError ? null : lastError ?? this.lastError, lastError: clearError ? null : lastError ?? this.lastError,
); );
} }
} }

View File

@ -1,54 +1,54 @@
import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; 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/domain/entities/login_user.dart';
import 'package:e_receipt_mobile/presentation/auth/logout_state.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/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; import 'package:e_receipt_mobile/presentation/login/login_view_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final logoutViewModelProvider = final logoutViewModelProvider =
StateNotifierProvider<LogoutViewModel, LogoutState>((ref) { StateNotifierProvider<LogoutViewModel, LogoutState>((ref) {
return LogoutViewModel( return LogoutViewModel(
authRepository: ref.watch(authRepositoryProvider), authRepository: ref.watch(authRepositoryProvider),
sessionController: ref.watch(sessionControllerProvider.notifier), sessionController: ref.watch(sessionControllerProvider.notifier),
sessionUserProvider: () => ref.read(sessionControllerProvider), sessionUserProvider: () => ref.read(sessionControllerProvider),
); );
}); });
class LogoutViewModel extends StateNotifier<LogoutState> { class LogoutViewModel extends StateNotifier<LogoutState> {
LogoutViewModel({ LogoutViewModel({
required AuthRepository authRepository, required AuthRepository authRepository,
required SessionController sessionController, required SessionController sessionController,
required SessionUserProvider sessionUserProvider, required SessionUserProvider sessionUserProvider,
}) : _authRepository = authRepository, }) : _authRepository = authRepository,
_sessionController = sessionController, _sessionController = sessionController,
_sessionUserProvider = sessionUserProvider, _sessionUserProvider = sessionUserProvider,
super(const LogoutState()); super(const LogoutState());
final AuthRepository _authRepository; final AuthRepository _authRepository;
final SessionController _sessionController; final SessionController _sessionController;
final SessionUserProvider _sessionUserProvider; final SessionUserProvider _sessionUserProvider;
Future<void> logout() async { Future<void> logout() async {
if (state.isLoading) { if (state.isLoading) {
return; return;
} }
state = state.copyWith(isLoading: true, clearError: true); state = state.copyWith(isLoading: true, clearError: true);
final sessionUser = _sessionUserProvider(); final sessionUser = _sessionUserProvider();
final refreshToken = sessionUser?.refreshToken.trim() ?? ''; final refreshToken = sessionUser?.refreshToken.trim() ?? '';
try { try {
if (refreshToken.isNotEmpty) { if (refreshToken.isNotEmpty) {
await _authRepository.logout(refreshToken: refreshToken); await _authRepository.logout(refreshToken: refreshToken);
} }
} catch (error) { } catch (error) {
state = state.copyWith(lastError: error); state = state.copyWith(lastError: error);
} finally { } finally {
_sessionController.clearUser(); _sessionController.clearUser();
state = state.copyWith(isLoading: false); state = state.copyWith(isLoading: false);
} }
} }
} }
typedef SessionUserProvider = LoginUser? Function(); typedef SessionUserProvider = LoginUser? Function();

View File

@ -1,19 +1,19 @@
import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:e_receipt_mobile/domain/entities/login_user.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final sessionControllerProvider = final sessionControllerProvider =
StateNotifierProvider<SessionController, LoginUser?>((ref) { StateNotifierProvider<SessionController, LoginUser?>((ref) {
return SessionController(); return SessionController();
}); });
class SessionController extends StateNotifier<LoginUser?> { class SessionController extends StateNotifier<LoginUser?> {
SessionController() : super(null); SessionController() : super(null);
void setUser(LoginUser user) { void setUser(LoginUser user) {
state = user; state = user;
} }
void clearUser() { void clearUser() {
state = null; state = null;
} }
} }

View File

@ -1,80 +1,80 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
Widget roundedInput({ Widget roundedInput({
required TextEditingController controller, required TextEditingController controller,
required String hint, required String hint,
required IconData icon, required IconData icon,
bool isPassword = false, bool isPassword = false,
String? Function(String?)? validator, String? Function(String?)? validator,
}) { }) {
return RoundedInput( return RoundedInput(
controller: controller, controller: controller,
hint: hint, hint: hint,
icon: icon, icon: icon,
isPassword: isPassword, isPassword: isPassword,
validator: validator, validator: validator,
); );
} }
class RoundedInput extends StatefulWidget { class RoundedInput extends StatefulWidget {
const RoundedInput({ const RoundedInput({
super.key, super.key,
required this.controller, required this.controller,
required this.hint, required this.hint,
required this.icon, required this.icon,
this.isPassword = false, this.isPassword = false,
this.validator, this.validator,
}); });
final TextEditingController controller; final TextEditingController controller;
final String hint; final String hint;
final IconData icon; final IconData icon;
final bool isPassword; final bool isPassword;
final String? Function(String?)? validator; final String? Function(String?)? validator;
@override @override
State<RoundedInput> createState() => _RoundedInputState(); State<RoundedInput> createState() => _RoundedInputState();
} }
class _RoundedInputState extends State<RoundedInput> { class _RoundedInputState extends State<RoundedInput> {
late bool _obscured; late bool _obscured;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_obscured = widget.isPassword; _obscured = widget.isPassword;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextFormField( return TextFormField(
controller: widget.controller, controller: widget.controller,
obscureText: _obscured, obscureText: _obscured,
validator: widget.validator, validator: widget.validator,
decoration: InputDecoration( decoration: InputDecoration(
hintText: widget.hint, hintText: widget.hint,
prefixIcon: Icon(widget.icon, color: Colors.grey), prefixIcon: Icon(widget.icon, color: Colors.grey),
suffixIcon: widget.isPassword suffixIcon: widget.isPassword
? IconButton( ? IconButton(
onPressed: () { onPressed: () {
setState(() { setState(() {
_obscured = !_obscured; _obscured = !_obscured;
}); });
}, },
icon: Icon( icon: Icon(
_obscured ? Icons.visibility : Icons.visibility_off, _obscured ? Icons.visibility : Icons.visibility_off,
color: Colors.grey, color: Colors.grey,
), ),
) )
: null, : null,
filled: true, filled: true,
fillColor: const Color(0xFFE9EEF3), fillColor: const Color(0xFFE9EEF3),
contentPadding: const EdgeInsets.symmetric(vertical: 16), contentPadding: const EdgeInsets.symmetric(vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30), borderRadius: BorderRadius.circular(30),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
), ),
); );
} }
} }

View File

@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final merchantPageSizeProvider = Provider<int>((ref) => 10); final merchantPageSizeProvider = Provider<int>((ref) => 10);
final merchantSearchQueryProvider = StateProvider.autoDispose<String>((ref) { final merchantSearchQueryProvider = StateProvider.autoDispose<String>((ref) {
return ''; return '';
}); });

View File

@ -1,264 +1,214 @@
import 'package:e_receipt_mobile/domain/entities/login_user.dart'; 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/auth/logout_view_model.dart';
import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.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/merchant_paging_view_model.dart';
import 'package:e_receipt_mobile/presentation/home/widgets/home_drawer.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_header.dart';
import 'package:e_receipt_mobile/presentation/home/widgets/merchant_list_view.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:e_receipt_mobile/presentation/terminal/terminal_selection_screen.dart'; import 'package:flutter/material.dart';
import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class HomeScreen extends ConsumerWidget {
class HomeScreen extends ConsumerWidget { const HomeScreen({required this.user, super.key});
const HomeScreen({required this.user, super.key});
final LoginUser user;
final LoginUser user;
@override
@override Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context, WidgetRef ref) { final pagingState = ref.watch(merchantPagingViewModelProvider);
final pagingState = ref.watch(merchantPagingViewModelProvider); final pagingViewModel = ref.read(merchantPagingViewModelProvider.notifier);
final pagingViewModel = ref.read(merchantPagingViewModelProvider.notifier); final logoutState = ref.watch(logoutViewModelProvider);
final logoutState = ref.watch(logoutViewModelProvider); final colorScheme = Theme.of(context).colorScheme;
final colorScheme = Theme.of(context).colorScheme; final query = ref.watch(merchantSearchQueryProvider);
final query = ref.watch(merchantSearchQueryProvider);
return Scaffold(
return Scaffold( appBar: AppBar(
appBar: AppBar( title: const Text('Merchants'),
title: const Text('Merchants'), actions: [
actions: [ IconButton(
IconButton( tooltip: 'Refresh',
tooltip: 'Refresh', onPressed: pagingState.isLoading
onPressed: pagingState.isLoading ? null
? null : () => pagingViewModel.refresh(),
: () => pagingViewModel.refresh(), icon: const Icon(Icons.refresh),
icon: const Icon(Icons.refresh), ),
), ],
], ),
), drawer: HomeDrawer(
drawer: HomeDrawer( user: user,
user: user, onLogout: () async {
onProfile: () { if (logoutState.isLoading) {
Navigator.of(context).pop(); return;
_showProfile(context, user); }
}, final shouldLogout = await showDialog<bool>(
onReports: () { context: context,
Navigator.of(context).pop(); builder: (context) {
_showComingSoon(context, 'Reports'); return AlertDialog(
}, title: const Text('Logout'),
onSettings: () { content: const Text('Are you sure you want to logout?'),
Navigator.of(context).pop(); actions: [
Navigator.of(context).push( TextButton(
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()), onPressed: () => Navigator.of(context).pop(false),
); child: const Text('Cancel'),
}, ),
onHelp: () { TextButton(
Navigator.of(context).pop(); onPressed: () => Navigator.of(context).pop(true),
_showComingSoon(context, 'Help'); child: const Text('Logout'),
}, ),
onLogout: () async { ],
if (logoutState.isLoading) { );
return; },
} );
final shouldLogout = await showDialog<bool>( if (shouldLogout != true || !context.mounted) {
context: context, return;
builder: (context) { }
return AlertDialog(
title: const Text('Logout'), Navigator.of(context).pop(); // close drawer
content: const Text('Are you sure you want to logout?'), ScaffoldMessenger.of(context).showSnackBar(
actions: [ const SnackBar(
TextButton( content: Text('Signed out'),
onPressed: () => Navigator.of(context).pop(false), behavior: SnackBarBehavior.floating,
child: const Text('Cancel'), ),
), );
TextButton( await ref.read(logoutViewModelProvider.notifier).logout();
onPressed: () => Navigator.of(context).pop(true), },
child: const Text('Logout'), ),
), body: pagingState.isLoading && pagingState.items.isEmpty
], ? const Center(child: CircularProgressIndicator())
); : pagingState.errorMessage != null && pagingState.items.isEmpty
}, ? Center(
); child: Padding(
padding: EdgeInsets.fromLTRB(
if (shouldLogout != true || !context.mounted) { 24,
return; 24,
} 24,
MediaQuery.viewPaddingOf(context).bottom + 24,
Navigator.of(context).pop(); // close drawer ),
ScaffoldMessenger.of(context).showSnackBar( child: Column(
const SnackBar( mainAxisSize: MainAxisSize.min,
content: Text('Signed out'), children: [
behavior: SnackBarBehavior.floating, Icon(
), Icons.wifi_off_outlined,
); size: 56,
await ref.read(logoutViewModelProvider.notifier).logout(); color: colorScheme.onSurfaceVariant,
}, ),
), const SizedBox(height: 12),
body: pagingState.isLoading && pagingState.items.isEmpty Text(
? const Center(child: CircularProgressIndicator()) 'Failed to load merchants',
: pagingState.errorMessage != null && pagingState.items.isEmpty style: Theme.of(context).textTheme.titleMedium,
? Center( textAlign: TextAlign.center,
child: Padding( ),
padding: EdgeInsets.fromLTRB( const SizedBox(height: 6),
24, Text(
24, pagingState.errorMessage!,
24, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
MediaQuery.viewPaddingOf(context).bottom + 24, color: colorScheme.onSurfaceVariant,
), ),
child: Column( textAlign: TextAlign.center,
mainAxisSize: MainAxisSize.min, ),
children: [ const SizedBox(height: 14),
Icon( FilledButton.tonal(
Icons.wifi_off_outlined, onPressed: pagingViewModel.refresh,
size: 56, child: const Text('Try again'),
color: colorScheme.onSurfaceVariant, ),
), ],
const SizedBox(height: 12), ),
Text( ),
'Failed to load merchants', )
style: Theme.of(context).textTheme.titleMedium, : Column(
textAlign: TextAlign.center, crossAxisAlignment: CrossAxisAlignment.stretch,
), children: [
const SizedBox(height: 6), MerchantHeader(
Text( loadedCount: pagingState.items.length,
pagingState.errorMessage!, query: query,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( onQueryChanged: (value) {
color: colorScheme.onSurfaceVariant, ref.read(merchantSearchQueryProvider.notifier).state =
), value;
textAlign: TextAlign.center, pagingViewModel.setSearchTerm(value);
), },
const SizedBox(height: 14), onClear: () {
FilledButton.tonal( ref.read(merchantSearchQueryProvider.notifier).state = '';
onPressed: pagingViewModel.refresh, pagingViewModel.setSearchTerm('');
child: const Text('Try again'), },
), ),
], Expanded(
), child: MerchantListView(
), merchants: pagingState.items,
) hasMore: pagingState.hasMore,
: Column( isLoadingMore: pagingState.isLoadingMore,
crossAxisAlignment: CrossAxisAlignment.stretch, onRefresh: pagingViewModel.refresh,
children: [ onMerchantTap: (merchant) {
MerchantHeader( final id = merchant.id;
loadedCount: pagingState.items.length, if (id == null) {
query: query, return;
onQueryChanged: (value) { }
ref.read(merchantSearchQueryProvider.notifier).state = _openTerminalSelection(context, id, merchant.name ?? '-');
value; },
pagingViewModel.setSearchTerm(value); onLoadMore: pagingViewModel.loadMore,
}, onEndReached: pagingViewModel.loadMore,
onClear: () { ),
ref.read(merchantSearchQueryProvider.notifier).state = ''; ),
pagingViewModel.setSearchTerm(''); if (pagingState.errorMessage != null &&
}, pagingState.items.isNotEmpty)
), Padding(
Expanded( padding: EdgeInsets.fromLTRB(
child: MerchantListView( 16,
merchants: pagingState.items, 8,
hasMore: pagingState.hasMore, 16,
isLoadingMore: pagingState.isLoadingMore, MediaQuery.viewPaddingOf(context).bottom + 8,
onRefresh: pagingViewModel.refresh, ),
onMerchantTap: (merchant) { child: Material(
final id = merchant.id; color: colorScheme.errorContainer,
if (id == null) { borderRadius: BorderRadius.circular(16),
return; child: Padding(
} padding: const EdgeInsets.all(12),
_openTerminalSelection(context, id, merchant.name ?? '-'); child: Row(
}, children: [
onLoadMore: pagingViewModel.loadMore, Icon(
onEndReached: pagingViewModel.loadMore, Icons.error_outline,
), color: colorScheme.onErrorContainer,
), ),
if (pagingState.errorMessage != null && const SizedBox(width: 10),
pagingState.items.isNotEmpty) Expanded(
Padding( child: Text(
padding: EdgeInsets.fromLTRB( pagingState.errorMessage!,
16, style: Theme.of(context).textTheme.bodyMedium
8, ?.copyWith(
16, color: colorScheme.onErrorContainer,
MediaQuery.viewPaddingOf(context).bottom + 8, ),
), ),
child: Material( ),
color: colorScheme.errorContainer, const SizedBox(width: 8),
borderRadius: BorderRadius.circular(16), TextButton(
child: Padding( onPressed: pagingViewModel.loadMore,
padding: const EdgeInsets.all(12), child: const Text('Retry'),
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,
), void _openTerminalSelection(
), BuildContext context,
), String merchantId,
const SizedBox(width: 8), String merchantName,
TextButton( ) {
onPressed: pagingViewModel.loadMore, Navigator.of(context).push(
child: const Text('Retry'), MaterialPageRoute<void>(
), builder: (_) => TerminalSelectionScreen(
], merchantId: merchantId,
), merchantName: merchantName,
), ),
), ),
), );
], }
), }
);
}
void _showComingSoon(BuildContext context, String feature) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('$feature is coming soon')));
}
void _showProfile(BuildContext context, LoginUser user) {
showDialog<void>(
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<void>(
builder: (_) => TerminalSelectionScreen(
merchantId: merchantId,
merchantName: merchantName,
),
),
);
}
}

View File

@ -1,32 +1,32 @@
import 'package:e_receipt_mobile/core/config/app_config.dart'; 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/data/repositories/api_merchant_repository.dart';
import 'package:e_receipt_mobile/domain/entities/terminal.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/domain/repositories/merchant_repository.dart';
import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; import 'package:e_receipt_mobile/presentation/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; import 'package:e_receipt_mobile/presentation/login/login_view_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final merchantRepositoryProvider = Provider<MerchantRepository>((ref) { final merchantRepositoryProvider = Provider<MerchantRepository>((ref) {
return ApiMerchantRepository( return ApiMerchantRepository(
baseUrl: AppConfig.apiBaseUrl, baseUrl: AppConfig.apiBaseUrl,
apiSecret: AppConfig.apiSecret, apiSecret: AppConfig.apiSecret,
client: ref.watch(authenticatedHttpClientProvider), client: ref.watch(authenticatedHttpClientProvider),
); );
}); });
final terminalListProvider = FutureProvider.family<List<Terminal>, String>(( final terminalListProvider = FutureProvider.family<List<Terminal>, String>((
ref, ref,
merchantId, merchantId,
) async { ) async {
final sessionUser = ref.watch(sessionControllerProvider); final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) { if (sessionUser == null) {
throw Exception('No active session'); throw Exception('No active session');
} }
return ref return ref
.watch(merchantRepositoryProvider) .watch(merchantRepositoryProvider)
.getTerminalsByMerchantId( .getTerminalsByMerchantId(
token: sessionUser.token, token: sessionUser.token,
merchantId: merchantId, merchantId: merchantId,
); );
}); });

View File

@ -1,49 +1,49 @@
import 'package:e_receipt_mobile/domain/entities/merchant.dart'; import 'package:e_receipt_mobile/domain/entities/merchant.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@immutable @immutable
class MerchantPagingState { class MerchantPagingState {
const MerchantPagingState({ const MerchantPagingState({
this.items = const <Merchant>[], this.items = const <Merchant>[],
this.page = 1, this.page = 1,
this.limit = 10, this.limit = 10,
this.searchTerm = '', this.searchTerm = '',
this.isLoading = false, this.isLoading = false,
this.isLoadingMore = false, this.isLoadingMore = false,
this.hasMore = true, this.hasMore = true,
this.errorMessage, this.errorMessage,
}); });
final List<Merchant> items; final List<Merchant> items;
final int page; final int page;
final int limit; final int limit;
final String searchTerm; final String searchTerm;
final bool isLoading; final bool isLoading;
final bool isLoadingMore; final bool isLoadingMore;
final bool hasMore; final bool hasMore;
final String? errorMessage; final String? errorMessage;
MerchantPagingState copyWith({ MerchantPagingState copyWith({
List<Merchant>? items, List<Merchant>? items,
int? page, int? page,
int? limit, int? limit,
String? searchTerm, String? searchTerm,
bool? isLoading, bool? isLoading,
bool? isLoadingMore, bool? isLoadingMore,
bool? hasMore, bool? hasMore,
String? errorMessage, String? errorMessage,
bool clearError = false, bool clearError = false,
}) { }) {
return MerchantPagingState( return MerchantPagingState(
items: items ?? this.items, items: items ?? this.items,
page: page ?? this.page, page: page ?? this.page,
limit: limit ?? this.limit, limit: limit ?? this.limit,
searchTerm: searchTerm ?? this.searchTerm, searchTerm: searchTerm ?? this.searchTerm,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
isLoadingMore: isLoadingMore ?? this.isLoadingMore, isLoadingMore: isLoadingMore ?? this.isLoadingMore,
hasMore: hasMore ?? this.hasMore, hasMore: hasMore ?? this.hasMore,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage, errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
); );
} }
} }

View File

@ -1,130 +1,130 @@
import 'dart:async'; import 'dart:async';
import 'package:e_receipt_mobile/domain/repositories/merchant_repository.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/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.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/home_view_model.dart';
import 'package:e_receipt_mobile/presentation/home/merchant_paging_state.dart'; import 'package:e_receipt_mobile/presentation/home/merchant_paging_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final merchantPagingViewModelProvider = final merchantPagingViewModelProvider =
StateNotifierProvider.autoDispose< StateNotifierProvider.autoDispose<
MerchantPagingViewModel, MerchantPagingViewModel,
MerchantPagingState MerchantPagingState
>((ref) { >((ref) {
final sessionUser = ref.watch(sessionControllerProvider); final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) { if (sessionUser == null) {
throw Exception('No active session'); throw Exception('No active session');
} }
final limit = ref.watch(merchantPageSizeProvider); final limit = ref.watch(merchantPageSizeProvider);
final viewModel = MerchantPagingViewModel( final viewModel = MerchantPagingViewModel(
repository: ref.watch(merchantRepositoryProvider), repository: ref.watch(merchantRepositoryProvider),
token: sessionUser.token, token: sessionUser.token,
limit: limit, limit: limit,
searchTerm: '', searchTerm: '',
); );
ref.onDispose(viewModel.dispose); ref.onDispose(viewModel.dispose);
viewModel.loadInitial(); viewModel.loadInitial();
return viewModel; return viewModel;
}); });
class MerchantPagingViewModel extends StateNotifier<MerchantPagingState> { class MerchantPagingViewModel extends StateNotifier<MerchantPagingState> {
MerchantPagingViewModel({ MerchantPagingViewModel({
required MerchantRepository repository, required MerchantRepository repository,
required String token, required String token,
required int limit, required int limit,
required String searchTerm, required String searchTerm,
}) : _repository = repository, }) : _repository = repository,
_token = token, _token = token,
super(MerchantPagingState(limit: limit, searchTerm: searchTerm)); super(MerchantPagingState(limit: limit, searchTerm: searchTerm));
final MerchantRepository _repository; final MerchantRepository _repository;
final String _token; final String _token;
Timer? _searchDebounce; Timer? _searchDebounce;
void dispose() { void dispose() {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
} }
Future<void> loadInitial() async { Future<void> loadInitial() async {
await refresh(searchTerm: state.searchTerm); await refresh(searchTerm: state.searchTerm);
} }
Future<void> refresh({String? searchTerm}) async { Future<void> refresh({String? searchTerm}) async {
final nextSearch = searchTerm ?? state.searchTerm; final nextSearch = searchTerm ?? state.searchTerm;
state = state.copyWith( state = state.copyWith(
isLoading: true, isLoading: true,
isLoadingMore: false, isLoadingMore: false,
page: 1, page: 1,
items: const [], items: const [],
hasMore: true, hasMore: true,
searchTerm: nextSearch, searchTerm: nextSearch,
clearError: true, clearError: true,
); );
try { try {
final results = await _repository.getMerchants( final results = await _repository.getMerchants(
token: _token, token: _token,
page: 1, page: 1,
limit: state.limit, limit: state.limit,
searchTerm: nextSearch, searchTerm: nextSearch,
); );
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
items: results, items: results,
page: 1, page: 1,
hasMore: results.length >= state.limit, hasMore: results.length >= state.limit,
); );
} catch (error) { } catch (error) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
errorMessage: error.toString().replaceFirst('Exception: ', ''), errorMessage: error.toString().replaceFirst('Exception: ', ''),
); );
} }
} }
void setSearchTerm(String value) { void setSearchTerm(String value) {
final next = value; final next = value;
if (next == state.searchTerm) { if (next == state.searchTerm) {
return; return;
} }
_searchDebounce?.cancel(); _searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 350), () { _searchDebounce = Timer(const Duration(milliseconds: 350), () {
refresh(searchTerm: next); refresh(searchTerm: next);
}); });
} }
Future<void> loadMore() async { Future<void> loadMore() async {
if (state.isLoading || state.isLoadingMore || !state.hasMore) { if (state.isLoading || state.isLoadingMore || !state.hasMore) {
return; return;
} }
state = state.copyWith(isLoadingMore: true, clearError: true); state = state.copyWith(isLoadingMore: true, clearError: true);
final nextPage = state.page + 1; final nextPage = state.page + 1;
try { try {
final results = await _repository.getMerchants( final results = await _repository.getMerchants(
token: _token, token: _token,
page: nextPage, page: nextPage,
limit: state.limit, limit: state.limit,
searchTerm: state.searchTerm, searchTerm: state.searchTerm,
); );
final merged = [...state.items, ...results]; final merged = [...state.items, ...results];
state = state.copyWith( state = state.copyWith(
isLoadingMore: false, isLoadingMore: false,
items: merged, items: merged,
page: nextPage, page: nextPage,
hasMore: results.length >= state.limit, hasMore: results.length >= state.limit,
); );
} catch (error) { } catch (error) {
state = state.copyWith( state = state.copyWith(
isLoadingMore: false, isLoadingMore: false,
errorMessage: error.toString().replaceFirst('Exception: ', ''), errorMessage: error.toString().replaceFirst('Exception: ', ''),
); );
} }
} }
} }

View File

@ -1,173 +1,139 @@
import 'package:e_receipt_mobile/core/theme/app_colors.dart'; import 'package:e_receipt_mobile/core/theme/app_colors.dart';
import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:e_receipt_mobile/domain/entities/login_user.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class HomeDrawer extends StatelessWidget { class HomeDrawer extends StatelessWidget {
const HomeDrawer({ const HomeDrawer({
required this.user, required this.user,
required this.onProfile,
required this.onReports,
required this.onSettings,
required this.onHelp,
required this.onLogout, required this.onLogout,
super.key, super.key,
}); });
final LoginUser user; final LoginUser user;
final VoidCallback onProfile;
final VoidCallback onReports;
final VoidCallback onSettings;
final VoidCallback onHelp;
final VoidCallback onLogout; final VoidCallback onLogout;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Drawer( return Drawer(
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
_DrawerHeader(user: user), _DrawerHeader(user: user),
Expanded( const Spacer(),
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,
),
],
),
),
Divider( Divider(
height: 1, height: 1,
thickness: 1, thickness: 1,
color: colorScheme.outlineVariant.withOpacity(0.6), color: colorScheme.outlineVariant.withOpacity(0.6),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: _DrawerItem( child: _DrawerItem(
icon: Icons.logout, icon: Icons.logout,
label: 'Logout', label: 'Logout',
isDestructive: true, isDestructive: true,
onTap: onLogout, onTap: onLogout,
), ),
), ),
], ],
), ),
), ),
); );
} }
} }
class _DrawerHeader extends StatelessWidget { class _DrawerHeader extends StatelessWidget {
const _DrawerHeader({required this.user}); const _DrawerHeader({required this.user});
final LoginUser user; final LoginUser user;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final initials = (user.username.isNotEmpty ? user.username[0] : 'U') final initials = (user.username.isNotEmpty ? user.username[0] : 'U')
.toUpperCase(); .toUpperCase();
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [AppColors.primary, Color(0xFF1E5FD6)], colors: [AppColors.primary, Color(0xFF1E5FD6)],
), ),
), ),
child: Row( child: Row(
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 22, radius: 22,
backgroundColor: Colors.white, backgroundColor: Colors.white,
child: Text( child: Text(
initials, initials,
style: const TextStyle( style: const TextStyle(
color: AppColors.primary, color: AppColors.primary,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
user.username, user.username,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 16, fontSize: 16,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'Role: ${user.role}', 'Role: ${user.role}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70), style: const TextStyle(color: Colors.white70),
), ),
], ],
), ),
), ),
], ],
), ),
); );
} }
} }
class _DrawerItem extends StatelessWidget { class _DrawerItem extends StatelessWidget {
const _DrawerItem({ const _DrawerItem({
required this.icon, required this.icon,
required this.label, required this.label,
required this.onTap, required this.onTap,
this.isDestructive = false, this.isDestructive = false,
}); });
final IconData icon; final IconData icon;
final String label; final String label;
final VoidCallback onTap; final VoidCallback onTap;
final bool isDestructive; final bool isDestructive;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final foreground = isDestructive final foreground = isDestructive
? colorScheme.error ? colorScheme.error
: colorScheme.onSurface; : colorScheme.onSurface;
return ListTile( return ListTile(
leading: Icon(icon, color: foreground), leading: Icon(icon, color: foreground),
title: Text( title: Text(
label, label,
style: TextStyle(fontWeight: FontWeight.w600, color: foreground), style: TextStyle(fontWeight: FontWeight.w600, color: foreground),
), ),
onTap: onTap, onTap: onTap,
); );
} }
} }

View File

@ -1,82 +1,82 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class MerchantHeader extends StatelessWidget { class MerchantHeader extends StatelessWidget {
const MerchantHeader({ const MerchantHeader({
required this.loadedCount, required this.loadedCount,
required this.query, required this.query,
required this.onQueryChanged, required this.onQueryChanged,
required this.onClear, required this.onClear,
super.key, super.key,
}); });
final int loadedCount; final int loadedCount;
final String query; final String query;
final ValueChanged<String> onQueryChanged; final ValueChanged<String> onQueryChanged;
final VoidCallback onClear; final VoidCallback onClear;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Merchants', 'Merchants',
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'Select a merchant to continue', 'Select a merchant to continue',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Chip( Chip(
label: Text('$loadedCount'), label: Text('$loadedCount'),
side: BorderSide(color: colorScheme.outlineVariant), side: BorderSide(color: colorScheme.outlineVariant),
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SearchBar( SearchBar(
leading: const Icon(Icons.search), leading: const Icon(Icons.search),
hintText: 'Search merchants', hintText: 'Search merchants',
padding: const WidgetStatePropertyAll( padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 14), EdgeInsets.symmetric(horizontal: 14),
), ),
backgroundColor: WidgetStatePropertyAll( backgroundColor: WidgetStatePropertyAll(
colorScheme.surfaceContainerHigh, colorScheme.surfaceContainerHigh,
), ),
elevation: const WidgetStatePropertyAll(0), elevation: const WidgetStatePropertyAll(0),
constraints: const BoxConstraints(minHeight: 52), constraints: const BoxConstraints(minHeight: 52),
trailing: [ trailing: [
if (query.isNotEmpty) if (query.isNotEmpty)
IconButton( IconButton(
tooltip: 'Clear', tooltip: 'Clear',
onPressed: onClear, onPressed: onClear,
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
), ),
], ],
onChanged: onQueryChanged, onChanged: onQueryChanged,
), ),
], ],
), ),
); );
} }
} }

View File

@ -1,233 +1,233 @@
import 'package:e_receipt_mobile/domain/entities/merchant.dart'; import 'package:e_receipt_mobile/domain/entities/merchant.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class MerchantListView extends StatelessWidget { class MerchantListView extends StatelessWidget {
const MerchantListView({ const MerchantListView({
required this.merchants, required this.merchants,
required this.onRefresh, required this.onRefresh,
required this.onMerchantTap, required this.onMerchantTap,
required this.hasMore, required this.hasMore,
required this.isLoadingMore, required this.isLoadingMore,
this.onLoadMore, this.onLoadMore,
this.onEndReached, this.onEndReached,
this.endReachedThreshold = 240, this.endReachedThreshold = 240,
super.key, super.key,
}); });
final List<Merchant> merchants; final List<Merchant> merchants;
final Future<void> Function() onRefresh; final Future<void> Function() onRefresh;
final void Function(Merchant merchant) onMerchantTap; final void Function(Merchant merchant) onMerchantTap;
final bool hasMore; final bool hasMore;
final bool isLoadingMore; final bool isLoadingMore;
final VoidCallback? onLoadMore; final VoidCallback? onLoadMore;
final VoidCallback? onEndReached; final VoidCallback? onEndReached;
final double endReachedThreshold; final double endReachedThreshold;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = MediaQuery.viewPaddingOf(context).bottom; final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final list = merchants.isEmpty final list = merchants.isEmpty
? ListView( ? ListView(
padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24), padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24),
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
children: [ children: [
Icon( Icon(
Icons.storefront_outlined, Icons.storefront_outlined,
size: 56, size: 56,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'No merchants found', 'No merchants found',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Try a different search term or pull down to refresh.', 'Try a different search term or pull down to refresh.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
], ],
) )
: ListView.separated( : ListView.separated(
padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16), padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16),
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: merchants.length + (hasMore ? 1 : 0), itemCount: merchants.length + (hasMore ? 1 : 0),
separatorBuilder: (_, __) => const SizedBox(height: 10), separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= merchants.length) { if (index >= merchants.length) {
return _PaginationFooter( return _PaginationFooter(
isLoadingMore: isLoadingMore, isLoadingMore: isLoadingMore,
onLoadMore: onLoadMore, onLoadMore: onLoadMore,
); );
} }
final merchant = merchants[index]; final merchant = merchants[index];
final name = merchant.name?.trim().isEmpty ?? true final name = merchant.name?.trim().isEmpty ?? true
? '-' ? '-'
: merchant.name!.trim(); : merchant.name!.trim();
final address = (merchant.address?.trim().isEmpty ?? true) final address = (merchant.address?.trim().isEmpty ?? true)
? 'No address' ? 'No address'
: merchant.address!.trim(); : merchant.address!.trim();
final enabled = merchant.id != null; final enabled = merchant.id != null;
return Card( return Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
side: BorderSide( side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.55), color: colorScheme.outlineVariant.withOpacity(0.55),
), ),
), ),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
onTap: enabled ? () => onMerchantTap(merchant) : null, onTap: enabled ? () => onMerchantTap(merchant) : null,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
vertical: 12, vertical: 12,
), ),
child: Row( child: Row(
children: [ children: [
Container( Container(
height: 44, height: 44,
width: 44, width: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
color: enabled color: enabled
? colorScheme.primary.withOpacity(0.14) ? colorScheme.primary.withOpacity(0.14)
: colorScheme.surfaceContainerHigh, : colorScheme.surfaceContainerHigh,
), ),
child: Icon( child: Icon(
Icons.storefront_outlined, Icons.storefront_outlined,
color: enabled color: enabled
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
name, name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium style: Theme.of(context).textTheme.titleMedium
?.copyWith( ?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: enabled color: enabled
? null ? null
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
address, address,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium style: Theme.of(context).textTheme.bodyMedium
?.copyWith( ?.copyWith(
color: enabled color: enabled
? colorScheme.onSurfaceVariant ? colorScheme.onSurfaceVariant
: colorScheme.onSurfaceVariant : colorScheme.onSurfaceVariant
.withOpacity(0.8), .withOpacity(0.8),
), ),
), ),
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Icon( Icon(
Icons.chevron_right, Icons.chevron_right,
color: enabled color: enabled
? colorScheme.onSurfaceVariant ? colorScheme.onSurfaceVariant
: colorScheme.onSurfaceVariant.withOpacity(0.4), : colorScheme.onSurfaceVariant.withOpacity(0.4),
), ),
], ],
), ),
), ),
), ),
); );
}, },
); );
final wrapped = onEndReached == null || merchants.isEmpty final wrapped = onEndReached == null || merchants.isEmpty
? list ? list
: NotificationListener<ScrollNotification>( : NotificationListener<ScrollNotification>(
onNotification: (notification) { onNotification: (notification) {
if (!hasMore || isLoadingMore) { if (!hasMore || isLoadingMore) {
return false; return false;
} }
if (notification.metrics.axis != Axis.vertical) { if (notification.metrics.axis != Axis.vertical) {
return false; return false;
} }
if (notification.metrics.maxScrollExtent <= 0) { if (notification.metrics.maxScrollExtent <= 0) {
return false; return false;
} }
if (notification.metrics.pixels >= if (notification.metrics.pixels >=
notification.metrics.maxScrollExtent - endReachedThreshold) { notification.metrics.maxScrollExtent - endReachedThreshold) {
onEndReached?.call(); onEndReached?.call();
} }
return false; return false;
}, },
child: list, child: list,
); );
return RefreshIndicator.adaptive(onRefresh: onRefresh, child: wrapped); return RefreshIndicator.adaptive(onRefresh: onRefresh, child: wrapped);
} }
} }
class _PaginationFooter extends StatelessWidget { class _PaginationFooter extends StatelessWidget {
const _PaginationFooter({ const _PaginationFooter({
required this.isLoadingMore, required this.isLoadingMore,
required this.onLoadMore, required this.onLoadMore,
}); });
final bool isLoadingMore; final bool isLoadingMore;
final VoidCallback? onLoadMore; final VoidCallback? onLoadMore;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Card( return Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
isLoadingMore ? 'Loading more...' : 'More results available', isLoadingMore ? 'Loading more...' : 'More results available',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
), ),
if (isLoadingMore) if (isLoadingMore)
const SizedBox( const SizedBox(
height: 18, height: 18,
width: 18, width: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
else else
FilledButton.tonal( FilledButton.tonal(
onPressed: onLoadMore, onPressed: onLoadMore,
child: const Text('Load more'), child: const Text('Load more'),
), ),
], ],
), ),
), ),
); );
} }
} }

View File

@ -1,40 +1,40 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final loginFormKeyProvider = Provider.autoDispose<GlobalKey<FormState>>((ref) { final loginFormKeyProvider = Provider.autoDispose<GlobalKey<FormState>>((ref) {
return GlobalKey<FormState>(); return GlobalKey<FormState>();
}); });
final loginUsernameControllerProvider = final loginUsernameControllerProvider =
Provider.autoDispose<TextEditingController>((ref) { Provider.autoDispose<TextEditingController>((ref) {
final controller = TextEditingController(); final controller = TextEditingController();
ref.onDispose(controller.dispose); ref.onDispose(controller.dispose);
return controller; return controller;
}); });
final loginPasswordControllerProvider = final loginPasswordControllerProvider =
Provider.autoDispose<TextEditingController>((ref) { Provider.autoDispose<TextEditingController>((ref) {
final controller = TextEditingController(); final controller = TextEditingController();
ref.onDispose(controller.dispose); ref.onDispose(controller.dispose);
return controller; return controller;
}); });
final loginUsernameFocusNodeProvider = Provider.autoDispose<FocusNode>((ref) { final loginUsernameFocusNodeProvider = Provider.autoDispose<FocusNode>((ref) {
final focusNode = FocusNode(); final focusNode = FocusNode();
ref.onDispose(focusNode.dispose); ref.onDispose(focusNode.dispose);
return focusNode; return focusNode;
}); });
final loginPasswordFocusNodeProvider = Provider.autoDispose<FocusNode>((ref) { final loginPasswordFocusNodeProvider = Provider.autoDispose<FocusNode>((ref) {
final focusNode = FocusNode(); final focusNode = FocusNode();
ref.onDispose(focusNode.dispose); ref.onDispose(focusNode.dispose);
return focusNode; return focusNode;
}); });
final loginObscurePasswordProvider = StateProvider.autoDispose<bool>( final loginObscurePasswordProvider = StateProvider.autoDispose<bool>(
(ref) => true, (ref) => true,
); );
final loginAttemptedSubmitProvider = StateProvider.autoDispose<bool>( final loginAttemptedSubmitProvider = StateProvider.autoDispose<bool>(
(ref) => false, (ref) => false,
); );

View File

@ -1,329 +1,329 @@
import 'package:e_receipt_mobile/presentation/login/login_form_providers.dart'; 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_state.dart';
import 'package:e_receipt_mobile/presentation/login/login_view_model.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_background.dart';
import 'package:e_receipt_mobile/presentation/login/widgets/login_error_banner.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:e_receipt_mobile/presentation/reset_password/reset_password_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
class LoginPage extends ConsumerWidget { class LoginPage extends ConsumerWidget {
const LoginPage({super.key}); const LoginPage({super.key});
void _submit({ void _submit({
required WidgetRef ref, required WidgetRef ref,
required LoginState state, required LoginState state,
required GlobalKey<FormState> formKey, required GlobalKey<FormState> formKey,
required TextEditingController usernameController, required TextEditingController usernameController,
required TextEditingController passwordController, required TextEditingController passwordController,
}) { }) {
if (state.isLoading) { if (state.isLoading) {
return; return;
} }
ref.read(loginViewModelProvider.notifier).clearMessages(); ref.read(loginViewModelProvider.notifier).clearMessages();
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
ref.read(loginAttemptedSubmitProvider.notifier).state = true; ref.read(loginAttemptedSubmitProvider.notifier).state = true;
if (!(formKey.currentState?.validate() ?? false)) { if (!(formKey.currentState?.validate() ?? false)) {
return; return;
} }
TextInput.finishAutofillContext(); TextInput.finishAutofillContext();
ref.read(loginViewModelProvider.notifier).login( ref.read(loginViewModelProvider.notifier).login(
username: usernameController.text, username: usernameController.text,
password: passwordController.text, password: passwordController.text,
); );
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(loginViewModelProvider); final state = ref.watch(loginViewModelProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
ref.listen(loginViewModelProvider, (previous, next) { ref.listen(loginViewModelProvider, (previous, next) {
final action = next.requiredAction; final action = next.requiredAction;
if (action == null || action == previous?.requiredAction) { if (action == null || action == previous?.requiredAction) {
return; return;
} }
switch (action) { switch (action) {
case LoginRequiredAction.passwordUpdateRequired: case LoginRequiredAction.passwordUpdateRequired:
final userId = (next.requiredUserId ?? '').trim(); final userId = (next.requiredUserId ?? '').trim();
if (userId.isEmpty) { if (userId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Password update required (missing user ID)'), content: Text('Password update required (missing user ID)'),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
ref.read(loginViewModelProvider.notifier).clearRequirement(); ref.read(loginViewModelProvider.notifier).clearRequirement();
return; return;
} }
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Password update required'), content: Text('Password update required'),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
ref.read(loginViewModelProvider.notifier).clearRequirement(); ref.read(loginViewModelProvider.notifier).clearRequirement();
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => ResetPasswordPage(userId: userId)), MaterialPageRoute<void>(builder: (_) => ResetPasswordPage(userId: userId)),
); );
} }
}); });
final formKey = ref.watch(loginFormKeyProvider); final formKey = ref.watch(loginFormKeyProvider);
final usernameController = ref.watch(loginUsernameControllerProvider); final usernameController = ref.watch(loginUsernameControllerProvider);
final passwordController = ref.watch(loginPasswordControllerProvider); final passwordController = ref.watch(loginPasswordControllerProvider);
final usernameFocusNode = ref.watch(loginUsernameFocusNodeProvider); final usernameFocusNode = ref.watch(loginUsernameFocusNodeProvider);
final passwordFocusNode = ref.watch(loginPasswordFocusNodeProvider); final passwordFocusNode = ref.watch(loginPasswordFocusNodeProvider);
final obscurePassword = ref.watch(loginObscurePasswordProvider); final obscurePassword = ref.watch(loginObscurePasswordProvider);
final attemptedSubmit = ref.watch(loginAttemptedSubmitProvider); final attemptedSubmit = ref.watch(loginAttemptedSubmitProvider);
return Scaffold( return Scaffold(
body: AnnotatedRegion<SystemUiOverlayStyle>( body: AnnotatedRegion<SystemUiOverlayStyle>(
value: Theme.of(context).brightness == Brightness.dark value: Theme.of(context).brightness == Brightness.dark
? SystemUiOverlayStyle.light ? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark, : SystemUiOverlayStyle.dark,
child: LoginBackground( child: LoginBackground(
child: SafeArea( child: SafeArea(
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 20, horizontal: 20,
vertical: 24, vertical: 24,
), ),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440), constraints: const BoxConstraints(maxWidth: 440),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surface.withOpacity( color: colorScheme.surface.withOpacity(
Theme.of(context).brightness == Brightness.dark Theme.of(context).brightness == Brightness.dark
? 0.75 ? 0.75
: 0.92, : 0.92,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
side: BorderSide( side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.35), color: colorScheme.outlineVariant.withOpacity(0.35),
), ),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
child: AutofillGroup( child: AutofillGroup(
child: Form( child: Form(
key: formKey, key: formKey,
autovalidateMode: attemptedSubmit autovalidateMode: attemptedSubmit
? AutovalidateMode.onUserInteraction ? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled, : AutovalidateMode.disabled,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
children: [ children: [
Container( Container(
height: 44, height: 44,
width: 44, width: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
colorScheme.primary, colorScheme.primary,
colorScheme.tertiary, colorScheme.tertiary,
], ],
), ),
), ),
child: Icon( child: Icon(
Icons.receipt_long, Icons.receipt_long,
color: colorScheme.onPrimary, color: colorScheme.onPrimary,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'E-Receipt', 'E-Receipt',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleLarge .titleLarge
?.copyWith( ?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
Text( Text(
'Sign in to continue', 'Sign in to continue',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.bodyMedium .bodyMedium
?.copyWith( ?.copyWith(
color: colorScheme color: colorScheme
.onSurfaceVariant, .onSurfaceVariant,
), ),
), ),
], ],
), ),
), ),
], ],
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
LoginErrorBanner(message: state.errorMessage), LoginErrorBanner(message: state.errorMessage),
if (state.errorMessage != null) if (state.errorMessage != null)
const SizedBox(height: 14), const SizedBox(height: 14),
TextFormField( TextFormField(
controller: usernameController, controller: usernameController,
focusNode: usernameFocusNode, focusNode: usernameFocusNode,
enabled: !state.isLoading, enabled: !state.isLoading,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username], autofillHints: const [AutofillHints.username],
onFieldSubmitted: (_) => onFieldSubmitted: (_) =>
passwordFocusNode.requestFocus(), passwordFocusNode.requestFocus(),
validator: (value) { validator: (value) {
if ((value ?? '').trim().isEmpty) { if ((value ?? '').trim().isEmpty) {
return 'Username is required'; return 'Username is required';
} }
return null; return null;
}, },
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Username', labelText: 'Username',
hintText: 'Enter your username', hintText: 'Enter your username',
prefixIcon: const Icon(Icons.person_outline), prefixIcon: const Icon(Icons.person_outline),
filled: true, filled: true,
fillColor: colorScheme.surfaceContainerHighest fillColor: colorScheme.surfaceContainerHighest
.withOpacity( .withOpacity(
Theme.of(context).brightness == Theme.of(context).brightness ==
Brightness.dark Brightness.dark
? 0.55 ? 0.55
: 0.9, : 0.9,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
controller: passwordController, controller: passwordController,
focusNode: passwordFocusNode, focusNode: passwordFocusNode,
enabled: !state.isLoading, enabled: !state.isLoading,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
obscureText: obscurePassword, obscureText: obscurePassword,
onFieldSubmitted: (_) => _submit( onFieldSubmitted: (_) => _submit(
ref: ref, ref: ref,
state: state, state: state,
formKey: formKey, formKey: formKey,
usernameController: usernameController, usernameController: usernameController,
passwordController: passwordController, passwordController: passwordController,
), ),
validator: (value) { validator: (value) {
if ((value ?? '').isEmpty) { if ((value ?? '').isEmpty) {
return 'Password is required'; return 'Password is required';
} }
if (value!.length < 6) { if (value!.length < 6) {
return 'Password must be at least 6 characters'; return 'Password must be at least 6 characters';
} }
return null; return null;
}, },
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Password', labelText: 'Password',
hintText: 'Enter your password', hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
filled: true, filled: true,
fillColor: colorScheme.surfaceContainerHighest fillColor: colorScheme.surfaceContainerHighest
.withOpacity( .withOpacity(
Theme.of(context).brightness == Theme.of(context).brightness ==
Brightness.dark Brightness.dark
? 0.55 ? 0.55
: 0.9, : 0.9,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
), ),
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: obscurePassword tooltip: obscurePassword
? 'Show password' ? 'Show password'
: 'Hide password', : 'Hide password',
onPressed: state.isLoading onPressed: state.isLoading
? null ? null
: () => ref : () => ref
.read( .read(
loginObscurePasswordProvider loginObscurePasswordProvider
.notifier, .notifier,
) )
.state = .state =
!obscurePassword, !obscurePassword,
icon: Icon( icon: Icon(
obscurePassword obscurePassword
? Icons.visibility_outlined ? Icons.visibility_outlined
: Icons.visibility_off_outlined, : Icons.visibility_off_outlined,
), ),
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: state.isLoading onPressed: state.isLoading
? null ? null
: () => _submit( : () => _submit(
ref: ref, ref: ref,
state: state, state: state,
formKey: formKey, formKey: formKey,
usernameController: usernameController, usernameController: usernameController,
passwordController: passwordController, passwordController: passwordController,
), ),
icon: state.isLoading icon: state.isLoading
? const SizedBox( ? const SizedBox(
height: 18, height: 18,
width: 18, width: 18,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
), ),
) )
: const Icon(Icons.login), : const Icon(Icons.login),
label: Padding( label: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 12, vertical: 12,
), ),
child: Text( child: Text(
state.isLoading state.isLoading
? 'Signing in...' ? 'Signing in...'
: 'Sign in', : 'Sign in',
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
"By continuing, you agree to your organization's policies.", "By continuing, you agree to your organization's policies.",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
), ),
), ),
), ),
), ),
), ),
), ),
), ),
), ),
), ),
), ),
); );
} }
} }

View File

@ -1,52 +1,52 @@
import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:e_receipt_mobile/domain/entities/login_user.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
enum LoginRequiredAction { passwordUpdateRequired } enum LoginRequiredAction { passwordUpdateRequired }
@immutable @immutable
class LoginState { class LoginState {
const LoginState({ const LoginState({
this.isLoading = false, this.isLoading = false,
this.errorMessage, this.errorMessage,
this.successMessage, this.successMessage,
this.user, this.user,
this.requiredAction, this.requiredAction,
this.requiredUserId, this.requiredUserId,
}); });
final bool isLoading; final bool isLoading;
final String? errorMessage; final String? errorMessage;
final String? successMessage; final String? successMessage;
final LoginUser? user; final LoginUser? user;
final LoginRequiredAction? requiredAction; final LoginRequiredAction? requiredAction;
final String? requiredUserId; final String? requiredUserId;
LoginState copyWith({ LoginState copyWith({
bool? isLoading, bool? isLoading,
String? errorMessage, String? errorMessage,
String? successMessage, String? successMessage,
LoginUser? user, LoginUser? user,
LoginRequiredAction? requiredAction, LoginRequiredAction? requiredAction,
String? requiredUserId, String? requiredUserId,
bool clearError = false, bool clearError = false,
bool clearSuccess = false, bool clearSuccess = false,
bool clearUser = false, bool clearUser = false,
bool clearRequired = false, bool clearRequired = false,
}) { }) {
return LoginState( return LoginState(
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage, errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
successMessage: clearSuccess successMessage: clearSuccess
? null ? null
: successMessage ?? this.successMessage, : successMessage ?? this.successMessage,
user: clearUser ? null : user ?? this.user, user: clearUser ? null : user ?? this.user,
requiredAction: clearRequired ? null : requiredAction ?? this.requiredAction, requiredAction: clearRequired ? null : requiredAction ?? this.requiredAction,
requiredUserId: clearRequired ? null : requiredUserId ?? this.requiredUserId, requiredUserId: clearRequired ? null : requiredUserId ?? this.requiredUserId,
); );
} }
@override @override
String toString() { String toString() {
return 'LoginState(isLoading: $isLoading, errorMessage: $errorMessage, successMessage: $successMessage, user: $user, requiredAction: $requiredAction, requiredUserId: $requiredUserId)'; return 'LoginState(isLoading: $isLoading, errorMessage: $errorMessage, successMessage: $successMessage, user: $user, requiredAction: $requiredAction, requiredUserId: $requiredUserId)';
} }
} }

View File

@ -1,142 +1,142 @@
import 'package:e_receipt_mobile/core/config/app_config.dart'; 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/api_key_http_client.dart';
import 'package:e_receipt_mobile/core/network/auth_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/core/network/logging_http_client.dart';
import 'package:e_receipt_mobile/data/repositories/api_auth_repository.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/repositories/auth_repository.dart';
import 'package:e_receipt_mobile/domain/exceptions/auth_exceptions.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/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/login/login_state.dart'; import 'package:e_receipt_mobile/presentation/login/login_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
final rawHttpClientProvider = Provider<http.Client>((ref) { final rawHttpClientProvider = Provider<http.Client>((ref) {
final client = ApiKeyHttpClient( final client = ApiKeyHttpClient(
innerClient: LoggingHttpClient(), innerClient: LoggingHttpClient(),
apiSecret: AppConfig.apiSecret, apiSecret: AppConfig.apiSecret,
); );
ref.onDispose(client.close); ref.onDispose(client.close);
return client; return client;
}); });
final authenticatedHttpClientProvider = Provider<http.Client>((ref) { final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
final client = AuthHttpClient( final client = AuthHttpClient(
innerClient: ApiKeyHttpClient( innerClient: ApiKeyHttpClient(
innerClient: LoggingHttpClient(), innerClient: LoggingHttpClient(),
apiSecret: AppConfig.apiSecret, apiSecret: AppConfig.apiSecret,
), ),
accessTokenProvider: () => ref.read(sessionControllerProvider)?.token, accessTokenProvider: () => ref.read(sessionControllerProvider)?.token,
shouldRefreshOnUnauthorized: (uri) { shouldRefreshOnUnauthorized: (uri) {
final path = uri.path; final path = uri.path;
return !(path.endsWith('/auth/login') || return !(path.endsWith('/auth/login') ||
path.endsWith('/auth/refresh-token') || path.endsWith('/auth/refresh-token') ||
path.endsWith('/receipt/auth/login') || path.endsWith('/receipt/auth/login') ||
path.endsWith('/receipt/auth/refresh-token')); path.endsWith('/receipt/auth/refresh-token'));
}, },
refreshAccessToken: () async { refreshAccessToken: () async {
final sessionUser = ref.read(sessionControllerProvider); final sessionUser = ref.read(sessionControllerProvider);
if (sessionUser == null) { if (sessionUser == null) {
return null; return null;
} }
try { try {
final refreshedUser = await ref final refreshedUser = await ref
.read(authRepositoryProvider) .read(authRepositoryProvider)
.refreshSession( .refreshSession(
username: sessionUser.username, username: sessionUser.username,
refreshToken: sessionUser.refreshToken, refreshToken: sessionUser.refreshToken,
role: sessionUser.role, role: sessionUser.role,
); );
ref.read(sessionControllerProvider.notifier).setUser(refreshedUser); ref.read(sessionControllerProvider.notifier).setUser(refreshedUser);
return refreshedUser.token; return refreshedUser.token;
} catch (_) { } catch (_) {
ref.read(sessionControllerProvider.notifier).clearUser(); ref.read(sessionControllerProvider.notifier).clearUser();
return null; return null;
} }
}, },
); );
ref.onDispose(client.close); ref.onDispose(client.close);
return client; return client;
}); });
final authRepositoryProvider = Provider<AuthRepository>((ref) { final authRepositoryProvider = Provider<AuthRepository>((ref) {
return ApiAuthRepository( return ApiAuthRepository(
baseUrl: AppConfig.apiBaseUrl, baseUrl: AppConfig.apiBaseUrl,
apiSecret: AppConfig.apiSecret, apiSecret: AppConfig.apiSecret,
client: ref.watch(rawHttpClientProvider), client: ref.watch(rawHttpClientProvider),
); );
}); });
final loginViewModelProvider = final loginViewModelProvider =
StateNotifierProvider<LoginViewModel, LoginState>((ref) { StateNotifierProvider<LoginViewModel, LoginState>((ref) {
return LoginViewModel( return LoginViewModel(
ref.watch(authRepositoryProvider), ref.watch(authRepositoryProvider),
ref.watch(sessionControllerProvider.notifier), ref.watch(sessionControllerProvider.notifier),
); );
}); });
class LoginViewModel extends StateNotifier<LoginState> { class LoginViewModel extends StateNotifier<LoginState> {
LoginViewModel(this._authRepository, this._sessionController) LoginViewModel(this._authRepository, this._sessionController)
: super(const LoginState()); : super(const LoginState());
final AuthRepository _authRepository; final AuthRepository _authRepository;
final SessionController _sessionController; final SessionController _sessionController;
Future<void> login({ Future<void> login({
required String username, required String username,
required String password, required String password,
}) async { }) async {
final trimmedUsername = username.trim(); final trimmedUsername = username.trim();
if (trimmedUsername.isEmpty || password.isEmpty) { if (trimmedUsername.isEmpty || password.isEmpty) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
errorMessage: 'Username and password are required', errorMessage: 'Username and password are required',
clearSuccess: true, clearSuccess: true,
); );
return; return;
} }
state = state.copyWith( state = state.copyWith(
isLoading: true, isLoading: true,
clearError: true, clearError: true,
clearSuccess: true, clearSuccess: true,
clearUser: true, clearUser: true,
clearRequired: true, clearRequired: true,
); );
_sessionController.clearUser(); _sessionController.clearUser();
try { try {
final user = await _authRepository.login( final user = await _authRepository.login(
username: trimmedUsername, username: trimmedUsername,
password: password, password: password,
); );
_sessionController.setUser(user); _sessionController.setUser(user);
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
user: user, user: user,
successMessage: 'Welcome ${user.username} (${user.role})', successMessage: 'Welcome ${user.username} (${user.role})',
); );
} on PasswordUpdateRequiredException catch (error) { } on PasswordUpdateRequiredException catch (error) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
requiredAction: LoginRequiredAction.passwordUpdateRequired, requiredAction: LoginRequiredAction.passwordUpdateRequired,
requiredUserId: error.userId, requiredUserId: error.userId,
clearError: true, clearError: true,
clearSuccess: true, clearSuccess: true,
); );
} catch (error) { } catch (error) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
errorMessage: error.toString().replaceFirst('Exception: ', ''), errorMessage: error.toString().replaceFirst('Exception: ', ''),
); );
} }
} }
void clearMessages() { void clearMessages() {
state = state.copyWith(clearError: true, clearSuccess: true); state = state.copyWith(clearError: true, clearSuccess: true);
} }
void clearRequirement() { void clearRequirement() {
state = state.copyWith(clearRequired: true); state = state.copyWith(clearRequired: true);
} }
} }

View File

@ -1,67 +1,67 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class LoginBackground extends StatelessWidget { class LoginBackground extends StatelessWidget {
const LoginBackground({required this.child, super.key}); const LoginBackground({required this.child, super.key});
final Widget child; final Widget child;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Stack( return Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
colorScheme.primary.withOpacity(0.18), colorScheme.primary.withOpacity(0.18),
colorScheme.secondary.withOpacity(0.10), colorScheme.secondary.withOpacity(0.10),
colorScheme.surface, colorScheme.surface,
], ],
stops: const [0.0, 0.45, 1.0], stops: const [0.0, 0.45, 1.0],
), ),
), ),
), ),
), ),
Positioned( Positioned(
top: -120, top: -120,
right: -120, right: -120,
child: _GlowBlob(color: colorScheme.primary.withOpacity(0.20)), child: _GlowBlob(color: colorScheme.primary.withOpacity(0.20)),
), ),
Positioned( Positioned(
bottom: -150, bottom: -150,
left: -150, left: -150,
child: _GlowBlob(color: colorScheme.tertiary.withOpacity(0.18)), child: _GlowBlob(color: colorScheme.tertiary.withOpacity(0.18)),
), ),
child, child,
], ],
); );
} }
} }
class _GlowBlob extends StatelessWidget { class _GlowBlob extends StatelessWidget {
const _GlowBlob({required this.color}); const _GlowBlob({required this.color});
final Color color; final Color color;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IgnorePointer( return IgnorePointer(
child: Container( child: Container(
height: 280, height: 280,
width: 280, width: 280,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: color, color: color,
boxShadow: [ boxShadow: [
BoxShadow(color: color, blurRadius: 80, spreadRadius: 40), BoxShadow(color: color, blurRadius: 80, spreadRadius: 40),
], ],
), ),
), ),
); );
} }
} }

View File

@ -1,44 +1,44 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class LoginErrorBanner extends StatelessWidget { class LoginErrorBanner extends StatelessWidget {
const LoginErrorBanner({required this.message, super.key}); const LoginErrorBanner({required this.message, super.key});
final String? message; final String? message;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return AnimatedSwitcher( return AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: message == null child: message == null
? const SizedBox.shrink() ? const SizedBox.shrink()
: Container( : Container(
key: ValueKey(message), key: ValueKey(message),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.errorContainer.withOpacity(0.9), color: colorScheme.errorContainer.withOpacity(0.9),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.error.withOpacity(0.25)), border: Border.all(color: colorScheme.error.withOpacity(0.25)),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Icons.error_outline, Icons.error_outline,
color: colorScheme.onErrorContainer, color: colorScheme.onErrorContainer,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
message!, message!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onErrorContainer, color: colorScheme.onErrorContainer,
), ),
), ),
), ),
], ],
), ),
), ),
); );
} }
} }

View File

@ -1,40 +1,40 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final resetPasswordFormKeyProvider = final resetPasswordFormKeyProvider =
Provider.autoDispose<GlobalKey<FormState>>((ref) { Provider.autoDispose<GlobalKey<FormState>>((ref) {
return GlobalKey<FormState>(); return GlobalKey<FormState>();
}); });
final resetPasswordControllerProvider = final resetPasswordControllerProvider =
Provider.autoDispose<TextEditingController>((ref) { Provider.autoDispose<TextEditingController>((ref) {
final controller = TextEditingController(); final controller = TextEditingController();
ref.onDispose(controller.dispose); ref.onDispose(controller.dispose);
return controller; return controller;
}); });
final resetConfirmPasswordControllerProvider = final resetConfirmPasswordControllerProvider =
Provider.autoDispose<TextEditingController>((ref) { Provider.autoDispose<TextEditingController>((ref) {
final controller = TextEditingController(); final controller = TextEditingController();
ref.onDispose(controller.dispose); ref.onDispose(controller.dispose);
return controller; return controller;
}); });
final resetPasswordFocusNodeProvider = Provider.autoDispose<FocusNode>((ref) { final resetPasswordFocusNodeProvider = Provider.autoDispose<FocusNode>((ref) {
final focusNode = FocusNode(); final focusNode = FocusNode();
ref.onDispose(focusNode.dispose); ref.onDispose(focusNode.dispose);
return focusNode; return focusNode;
}); });
final resetConfirmPasswordFocusNodeProvider = final resetConfirmPasswordFocusNodeProvider =
Provider.autoDispose<FocusNode>((ref) { Provider.autoDispose<FocusNode>((ref) {
final focusNode = FocusNode(); final focusNode = FocusNode();
ref.onDispose(focusNode.dispose); ref.onDispose(focusNode.dispose);
return focusNode; return focusNode;
}); });
final resetObscurePasswordProvider = StateProvider.autoDispose<bool>((ref) => true); final resetObscurePasswordProvider = StateProvider.autoDispose<bool>((ref) => true);
final resetObscureConfirmPasswordProvider = final resetObscureConfirmPasswordProvider =
StateProvider.autoDispose<bool>((ref) => true); StateProvider.autoDispose<bool>((ref) => true);
final resetAttemptedSubmitProvider = StateProvider.autoDispose<bool>((ref) => false); final resetAttemptedSubmitProvider = StateProvider.autoDispose<bool>((ref) => false);

View File

@ -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_form_providers.dart';
import 'package:e_receipt_mobile/presentation/reset_password/reset_password_view_model.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:e_receipt_mobile/presentation/login/widgets/login_background.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
class ResetPasswordPage extends ConsumerWidget { class ResetPasswordPage extends ConsumerWidget {
const ResetPasswordPage({required this.userId, super.key}); const ResetPasswordPage({required this.userId, super.key});
final String userId; final String userId;
bool _isStrongPassword(String value) { bool _isStrongPassword(String value) {
return value.length >= 8 && return value.length >= 8 &&
RegExp(r'[a-z]').hasMatch(value) && RegExp(r'[a-z]').hasMatch(value) &&
RegExp(r'[A-Z]').hasMatch(value) && RegExp(r'[A-Z]').hasMatch(value) &&
RegExp(r'[0-9]').hasMatch(value) && RegExp(r'[0-9]').hasMatch(value) &&
RegExp(r'[^A-Za-z0-9]').hasMatch(value); RegExp(r'[^A-Za-z0-9]').hasMatch(value);
} }
void _submit({ void _submit({
required BuildContext context, required BuildContext context,
required WidgetRef ref, required WidgetRef ref,
required GlobalKey<FormState> formKey, required GlobalKey<FormState> formKey,
required TextEditingController passwordController, required TextEditingController passwordController,
required TextEditingController confirmController, required TextEditingController confirmController,
required bool isLoading, required bool isLoading,
}) { }) {
if (isLoading) { if (isLoading) {
return; return;
} }
ref.read(resetPasswordViewModelProvider.notifier).clearMessages(); ref.read(resetPasswordViewModelProvider.notifier).clearMessages();
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
ref.read(resetAttemptedSubmitProvider.notifier).state = true; ref.read(resetAttemptedSubmitProvider.notifier).state = true;
if (!(formKey.currentState?.validate() ?? false)) { if (!(formKey.currentState?.validate() ?? false)) {
return; return;
} }
final password = passwordController.text; final password = passwordController.text;
final confirm = confirmController.text; final confirm = confirmController.text;
if (password != confirm) { if (password != confirm) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Passwords do not match'), content: Text('Passwords do not match'),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
return; return;
} }
ref ref
.read(resetPasswordViewModelProvider.notifier) .read(resetPasswordViewModelProvider.notifier)
.resetPassword(userId: userId, password: password); .resetPassword(userId: userId, password: password);
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(resetPasswordViewModelProvider); final state = ref.watch(resetPasswordViewModelProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
ref.listen(resetPasswordViewModelProvider, (previous, next) { ref.listen(resetPasswordViewModelProvider, (previous, next) {
final message = next.errorMessage; final message = next.errorMessage;
if (message != null && message != previous?.errorMessage) { if (message != null && message != previous?.errorMessage) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(message), content: Text(message),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
} }
final success = next.successMessage; final success = next.successMessage;
if (success != null && success != previous?.successMessage) { if (success != null && success != previous?.successMessage) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(success), content: Text(success),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}); });
final formKey = ref.watch(resetPasswordFormKeyProvider); final formKey = ref.watch(resetPasswordFormKeyProvider);
final passwordController = ref.watch(resetPasswordControllerProvider); final passwordController = ref.watch(resetPasswordControllerProvider);
final confirmController = ref.watch(resetConfirmPasswordControllerProvider); final confirmController = ref.watch(resetConfirmPasswordControllerProvider);
final passwordFocusNode = ref.watch(resetPasswordFocusNodeProvider); final passwordFocusNode = ref.watch(resetPasswordFocusNodeProvider);
final confirmFocusNode = ref.watch(resetConfirmPasswordFocusNodeProvider); final confirmFocusNode = ref.watch(resetConfirmPasswordFocusNodeProvider);
final obscure = ref.watch(resetObscurePasswordProvider); final obscure = ref.watch(resetObscurePasswordProvider);
final obscureConfirm = ref.watch(resetObscureConfirmPasswordProvider); final obscureConfirm = ref.watch(resetObscureConfirmPasswordProvider);
final attemptedSubmit = ref.watch(resetAttemptedSubmitProvider); final attemptedSubmit = ref.watch(resetAttemptedSubmitProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Reset password')), appBar: AppBar(title: const Text('Reset password')),
body: AnnotatedRegion<SystemUiOverlayStyle>( body: AnnotatedRegion<SystemUiOverlayStyle>(
value: Theme.of(context).brightness == Brightness.dark value: Theme.of(context).brightness == Brightness.dark
? SystemUiOverlayStyle.light ? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark, : SystemUiOverlayStyle.dark,
child: LoginBackground( child: LoginBackground(
child: SafeArea( child: SafeArea(
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 24), const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440), constraints: const BoxConstraints(maxWidth: 440),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surface.withOpacity( color: colorScheme.surface.withOpacity(
Theme.of(context).brightness == Brightness.dark ? 0.75 : 0.92, Theme.of(context).brightness == Brightness.dark ? 0.75 : 0.92,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
side: BorderSide( side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.35), color: colorScheme.outlineVariant.withOpacity(0.35),
), ),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
child: Form( child: Form(
key: formKey, key: formKey,
autovalidateMode: attemptedSubmit autovalidateMode: attemptedSubmit
? AutovalidateMode.onUserInteraction ? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled, : AutovalidateMode.disabled,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
children: [ children: [
Container( Container(
height: 44, height: 44,
width: 44, width: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
colorScheme.primary, colorScheme.primary,
colorScheme.tertiary, colorScheme.tertiary,
], ],
), ),
), ),
child: const Icon( child: const Icon(
Icons.lock_reset_outlined, Icons.lock_reset_outlined,
color: Colors.white, color: Colors.white,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Update your password', 'Update your password',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleLarge .titleLarge
?.copyWith(fontWeight: FontWeight.w800), ?.copyWith(fontWeight: FontWeight.w800),
), ),
Text( Text(
'Your account requires a password change.', 'Your account requires a password change.',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.bodyMedium .bodyMedium
?.copyWith( ?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
), ),
], ],
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
TextFormField( TextFormField(
controller: passwordController, controller: passwordController,
focusNode: passwordFocusNode, focusNode: passwordFocusNode,
enabled: !state.isLoading, enabled: !state.isLoading,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
obscureText: obscure, obscureText: obscure,
onFieldSubmitted: (_) => onFieldSubmitted: (_) =>
FocusScope.of(context).requestFocus(confirmFocusNode), FocusScope.of(context).requestFocus(confirmFocusNode),
validator: (value) { validator: (value) {
final text = (value ?? '').trim(); final text = (value ?? '').trim();
if (text.isEmpty) { if (text.isEmpty) {
return 'New password is required'; return 'New password is required';
} }
if (!_isStrongPassword(text)) { if (!_isStrongPassword(text)) {
return 'Use 8+ chars with upper, lower, number, and symbol'; return 'Use 8+ chars with upper, lower, number, and symbol';
} }
return null; return null;
}, },
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'New password', labelText: 'New password',
hintText: 'Enter a strong password', hintText: 'Enter a strong password',
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
filled: true, filled: true,
fillColor: colorScheme.surfaceContainerHighest fillColor: colorScheme.surfaceContainerHighest
.withOpacity( .withOpacity(
Theme.of(context).brightness == Brightness.dark Theme.of(context).brightness == Brightness.dark
? 0.55 ? 0.55
: 0.9, : 0.9,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
), ),
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: tooltip:
obscure ? 'Show password' : 'Hide password', obscure ? 'Show password' : 'Hide password',
onPressed: state.isLoading onPressed: state.isLoading
? null ? null
: () => ref : () => ref
.read( .read(
resetObscurePasswordProvider.notifier, resetObscurePasswordProvider.notifier,
) )
.state = .state =
!obscure, !obscure,
icon: Icon( icon: Icon(
obscure obscure
? Icons.visibility_outlined ? Icons.visibility_outlined
: Icons.visibility_off_outlined, : Icons.visibility_off_outlined,
), ),
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
controller: confirmController, controller: confirmController,
focusNode: confirmFocusNode, focusNode: confirmFocusNode,
enabled: !state.isLoading, enabled: !state.isLoading,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
obscureText: obscureConfirm, obscureText: obscureConfirm,
onFieldSubmitted: (_) => _submit( onFieldSubmitted: (_) => _submit(
context: context, context: context,
ref: ref, ref: ref,
formKey: formKey, formKey: formKey,
passwordController: passwordController, passwordController: passwordController,
confirmController: confirmController, confirmController: confirmController,
isLoading: state.isLoading, isLoading: state.isLoading,
), ),
validator: (value) { validator: (value) {
if ((value ?? '').trim().isEmpty) { if ((value ?? '').trim().isEmpty) {
return 'Confirm password is required'; return 'Confirm password is required';
} }
return null; return null;
}, },
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Confirm new password', labelText: 'Confirm new password',
hintText: 'Re-enter your password', hintText: 'Re-enter your password',
prefixIcon: const Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
filled: true, filled: true,
fillColor: colorScheme.surfaceContainerHighest fillColor: colorScheme.surfaceContainerHighest
.withOpacity( .withOpacity(
Theme.of(context).brightness == Brightness.dark Theme.of(context).brightness == Brightness.dark
? 0.55 ? 0.55
: 0.9, : 0.9,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
), ),
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: obscureConfirm tooltip: obscureConfirm
? 'Show password' ? 'Show password'
: 'Hide password', : 'Hide password',
onPressed: state.isLoading onPressed: state.isLoading
? null ? null
: () => ref : () => ref
.read( .read(
resetObscureConfirmPasswordProvider resetObscureConfirmPasswordProvider
.notifier, .notifier,
) )
.state = .state =
!obscureConfirm, !obscureConfirm,
icon: Icon( icon: Icon(
obscureConfirm obscureConfirm
? Icons.visibility_outlined ? Icons.visibility_outlined
: Icons.visibility_off_outlined, : Icons.visibility_off_outlined,
), ),
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: state.isLoading onPressed: state.isLoading
? null ? null
: () => _submit( : () => _submit(
context: context, context: context,
ref: ref, ref: ref,
formKey: formKey, formKey: formKey,
passwordController: passwordController, passwordController: passwordController,
confirmController: confirmController, confirmController: confirmController,
isLoading: state.isLoading, isLoading: state.isLoading,
), ),
icon: state.isLoading icon: state.isLoading
? const SizedBox( ? const SizedBox(
height: 18, height: 18,
width: 18, width: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.check), : const Icon(Icons.check),
label: Padding( label: Padding(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
child: Text( child: Text(
state.isLoading ? 'Updating...' : 'Reset password', state.isLoading ? 'Updating...' : 'Reset password',
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
'Tip: use a unique password you dont use elsewhere.', 'Tip: use a unique password you dont use elsewhere.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
), ),
), ),
), ),
), ),
), ),
), ),
), ),
), ),
), ),
); );
} }
} }

View File

@ -1,28 +1,28 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@immutable @immutable
class ResetPasswordState { class ResetPasswordState {
const ResetPasswordState({ const ResetPasswordState({
this.isLoading = false, this.isLoading = false,
this.errorMessage, this.errorMessage,
this.successMessage, this.successMessage,
}); });
final bool isLoading; final bool isLoading;
final String? errorMessage; final String? errorMessage;
final String? successMessage; final String? successMessage;
ResetPasswordState copyWith({ ResetPasswordState copyWith({
bool? isLoading, bool? isLoading,
String? errorMessage, String? errorMessage,
String? successMessage, String? successMessage,
bool clearError = false, bool clearError = false,
bool clearSuccess = false, bool clearSuccess = false,
}) { }) {
return ResetPasswordState( return ResetPasswordState(
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage, errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
successMessage: clearSuccess ? null : successMessage ?? this.successMessage, successMessage: clearSuccess ? null : successMessage ?? this.successMessage,
); );
} }
} }

View File

@ -1,54 +1,54 @@
import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; 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/login/login_view_model.dart';
import 'package:e_receipt_mobile/presentation/reset_password/reset_password_state.dart'; import 'package:e_receipt_mobile/presentation/reset_password/reset_password_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final resetPasswordViewModelProvider = final resetPasswordViewModelProvider =
StateNotifierProvider.autoDispose<ResetPasswordViewModel, ResetPasswordState>( StateNotifierProvider.autoDispose<ResetPasswordViewModel, ResetPasswordState>(
(ref) { (ref) {
return ResetPasswordViewModel(ref.watch(authRepositoryProvider)); return ResetPasswordViewModel(ref.watch(authRepositoryProvider));
}, },
); );
class ResetPasswordViewModel extends StateNotifier<ResetPasswordState> { class ResetPasswordViewModel extends StateNotifier<ResetPasswordState> {
ResetPasswordViewModel(this._authRepository) : super(const ResetPasswordState()); ResetPasswordViewModel(this._authRepository) : super(const ResetPasswordState());
final AuthRepository _authRepository; final AuthRepository _authRepository;
Future<void> resetPassword({ Future<void> resetPassword({
required String userId, required String userId,
required String password, required String password,
}) async { }) async {
if (userId.trim().isEmpty) { if (userId.trim().isEmpty) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
errorMessage: 'Missing user ID', errorMessage: 'Missing user ID',
clearSuccess: true, clearSuccess: true,
); );
return; return;
} }
state = state.copyWith( state = state.copyWith(
isLoading: true, isLoading: true,
clearError: true, clearError: true,
clearSuccess: true, clearSuccess: true,
); );
try { try {
await _authRepository.resetPassword(userId: userId.trim(), password: password); await _authRepository.resetPassword(userId: userId.trim(), password: password);
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
successMessage: 'Password updated', successMessage: 'Password updated',
); );
} catch (error) { } catch (error) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
errorMessage: error.toString().replaceFirst('Exception: ', ''), errorMessage: error.toString().replaceFirst('Exception: ', ''),
); );
} }
} }
void clearMessages() { void clearMessages() {
state = state.copyWith(clearError: true, clearSuccess: true); state = state.copyWith(clearError: true, clearSuccess: true);
} }
} }

View File

@ -1,65 +1,65 @@
import 'package:e_receipt_mobile/presentation/settings/theme_mode_provider.dart'; import 'package:e_receipt_mobile/presentation/settings/theme_mode_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
class SettingsScreen extends ConsumerWidget { class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectedMode = ref.watch(appThemeModeProvider); final selectedMode = ref.watch(appThemeModeProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Settings'), title: const Text('Settings'),
centerTitle: true, centerTitle: true,
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
Text( Text(
'Theme', 'Theme',
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Card( Card(
child: Column( child: Column(
children: [ children: [
RadioListTile<ThemeMode>( RadioListTile<ThemeMode>(
title: const Text('Light'), title: const Text('Light'),
value: ThemeMode.light, value: ThemeMode.light,
groupValue: selectedMode, groupValue: selectedMode,
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
ref.read(appThemeModeProvider.notifier).state = value; ref.read(appThemeModeProvider.notifier).state = value;
} }
}, },
), ),
RadioListTile<ThemeMode>( RadioListTile<ThemeMode>(
title: const Text('Dark'), title: const Text('Dark'),
value: ThemeMode.dark, value: ThemeMode.dark,
groupValue: selectedMode, groupValue: selectedMode,
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
ref.read(appThemeModeProvider.notifier).state = value; ref.read(appThemeModeProvider.notifier).state = value;
} }
}, },
), ),
RadioListTile<ThemeMode>( RadioListTile<ThemeMode>(
title: const Text('System'), title: const Text('System'),
value: ThemeMode.system, value: ThemeMode.system,
groupValue: selectedMode, groupValue: selectedMode,
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
ref.read(appThemeModeProvider.notifier).state = value; ref.read(appThemeModeProvider.notifier).state = value;
} }
}, },
), ),
], ],
), ),
), ),
], ],
), ),
); );
} }
} }

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final appThemeModeProvider = StateProvider<ThemeMode>((ref) { final appThemeModeProvider = StateProvider<ThemeMode>((ref) {
return ThemeMode.system; return ThemeMode.system;
}); });

View File

@ -1,78 +1,78 @@
import 'dart:io'; import 'dart:io';
import 'package:e_receipt_mobile/core/config/app_config.dart'; 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/data/repositories/api_receipt_repository.dart';
import 'package:e_receipt_mobile/domain/repositories/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/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; import 'package:e_receipt_mobile/presentation/login/login_view_model.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
class TransactionReceiptQuery { class TransactionReceiptQuery {
const TransactionReceiptQuery({ const TransactionReceiptQuery({
required this.transactionId, required this.transactionId,
required this.copyFor, required this.copyFor,
}); });
final String transactionId; final String transactionId;
final String copyFor; final String copyFor;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is TransactionReceiptQuery && return other is TransactionReceiptQuery &&
other.transactionId == transactionId && other.transactionId == transactionId &&
other.copyFor == copyFor; other.copyFor == copyFor;
} }
@override @override
int get hashCode => Object.hash(transactionId, copyFor); int get hashCode => Object.hash(transactionId, copyFor);
} }
sealed class ReceiptViewData { sealed class ReceiptViewData {
const ReceiptViewData(); const ReceiptViewData();
} }
class ReceiptPdfViewData extends ReceiptViewData { class ReceiptPdfViewData extends ReceiptViewData {
const ReceiptPdfViewData(this.file); const ReceiptPdfViewData(this.file);
final File file; final File file;
} }
final receiptRepositoryProvider = Provider<ReceiptRepository>((ref) { final receiptRepositoryProvider = Provider<ReceiptRepository>((ref) {
return ApiReceiptRepository( return ApiReceiptRepository(
baseUrl: AppConfig.apiBaseUrl, baseUrl: AppConfig.apiBaseUrl,
apiSecret: AppConfig.apiSecret, apiSecret: AppConfig.apiSecret,
client: ref.watch(authenticatedHttpClientProvider), client: ref.watch(authenticatedHttpClientProvider),
); );
}); });
final transactionReceiptViewDataProvider = final transactionReceiptViewDataProvider =
FutureProvider.family<ReceiptViewData, TransactionReceiptQuery>(( FutureProvider.family<ReceiptViewData, TransactionReceiptQuery>((
ref, ref,
query, query,
) async { ) async {
try { try {
final sessionUser = ref.watch(sessionControllerProvider); final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) { if (sessionUser == null) {
throw Exception('No active session'); throw Exception('No active session');
} }
final bytes = await ref final bytes = await ref
.watch(receiptRepositoryProvider) .watch(receiptRepositoryProvider)
.getTransactionReceiptPdfBytes( .getTransactionReceiptPdfBytes(
token: sessionUser.token, token: sessionUser.token,
transactionId: query.transactionId, transactionId: query.transactionId,
copyFor: query.copyFor, copyFor: query.copyFor,
); );
final dir = await Directory.systemTemp.createTemp('e_receipt_mobile'); final dir = await Directory.systemTemp.createTemp('e_receipt_mobile');
final file = File( final file = File(
'${dir.path}/receipt_${query.transactionId}_${query.copyFor}.pdf', '${dir.path}/receipt_${query.transactionId}_${query.copyFor}.pdf',
); );
await file.writeAsBytes(bytes, flush: true); await file.writeAsBytes(bytes, flush: true);
return ReceiptPdfViewData(file); return ReceiptPdfViewData(file);
} catch (e, st) { } catch (e, st) {
debugPrint('[RECEIPT][ERROR] ${Error.safeToString(e)}\n$st'); debugPrint('[RECEIPT][ERROR] ${Error.safeToString(e)}\n$st');
throw Exception(Error.safeToString(e)); throw Exception(Error.safeToString(e));
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@ -1,118 +1,118 @@
import 'package:e_receipt_mobile/domain/entities/terminal.dart'; 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/home/home_view_model.dart';
import 'package:e_receipt_mobile/presentation/terminal/terminal_next_screen.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_header.dart';
import 'package:e_receipt_mobile/presentation/terminal/widgets/terminal_list_view.dart'; import 'package:e_receipt_mobile/presentation/terminal/widgets/terminal_list_view.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
class TerminalSelectionScreen extends ConsumerWidget { class TerminalSelectionScreen extends ConsumerWidget {
const TerminalSelectionScreen({ const TerminalSelectionScreen({
required this.merchantId, required this.merchantId,
required this.merchantName, required this.merchantName,
super.key, super.key,
}); });
final String merchantId; final String merchantId;
final String merchantName; final String merchantName;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final terminalsAsync = ref.watch(terminalListProvider(merchantId)); final terminalsAsync = ref.watch(terminalListProvider(merchantId));
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(merchantName), title: Text(merchantName),
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Refresh', tooltip: 'Refresh',
onPressed: terminalsAsync.isLoading onPressed: terminalsAsync.isLoading
? null ? null
: () => ref.invalidate(terminalListProvider(merchantId)), : () => ref.invalidate(terminalListProvider(merchantId)),
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
), ),
], ],
), ),
body: SafeArea( body: SafeArea(
bottom: true, bottom: true,
child: terminalsAsync.when( child: terminalsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center( error: (error, _) => Center(
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
24, 24,
24, 24,
24, 24,
MediaQuery.viewPaddingOf(context).bottom + 24, MediaQuery.viewPaddingOf(context).bottom + 24,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
Icons.wifi_off_outlined, Icons.wifi_off_outlined,
size: 56, size: 56,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Failed to load terminals', 'Failed to load terminals',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'$error', '$error',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => onPressed: () =>
ref.invalidate(terminalListProvider(merchantId)), ref.invalidate(terminalListProvider(merchantId)),
child: const Text('Try again'), child: const Text('Try again'),
), ),
], ],
), ),
), ),
), ),
data: (terminals) { data: (terminals) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TerminalHeader( TerminalHeader(
merchantName: merchantName, merchantName: merchantName,
totalCount: terminals.length, totalCount: terminals.length,
), ),
Expanded( Expanded(
child: TerminalListView( child: TerminalListView(
terminals: terminals, terminals: terminals,
emptyMessage: 'No terminals found', emptyMessage: 'No terminals found',
onRefresh: () async { onRefresh: () async {
await ref.refresh( await ref.refresh(
terminalListProvider(merchantId).future, terminalListProvider(merchantId).future,
); );
}, },
onTerminalTap: (terminal) => onTerminalTap: (terminal) =>
_openNextScreen(context, terminal), _openNextScreen(context, terminal),
), ),
), ),
], ],
); );
}, },
), ),
), ),
); );
} }
void _openNextScreen(BuildContext context, Terminal terminal) { void _openNextScreen(BuildContext context, Terminal terminal) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => builder: (_) =>
TerminalNextScreen(merchantName: merchantName, terminal: terminal), TerminalNextScreen(merchantName: merchantName, terminal: terminal),
), ),
); );
} }
} }

View File

@ -1,4 +1,4 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final transactionPageSizeProvider = Provider<int>((ref) => 8); final transactionPageSizeProvider = Provider<int>((ref) => 8);

View File

@ -1,45 +1,45 @@
import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; import 'package:e_receipt_mobile/domain/entities/transaction_record.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@immutable @immutable
class TransactionPagingState { class TransactionPagingState {
const TransactionPagingState({ const TransactionPagingState({
this.items = const <TransactionRecord>[], this.items = const <TransactionRecord>[],
this.page = 1, this.page = 1,
this.limit = 8, this.limit = 8,
this.isLoading = false, this.isLoading = false,
this.isLoadingMore = false, this.isLoadingMore = false,
this.hasMore = true, this.hasMore = true,
this.errorMessage, this.errorMessage,
}); });
final List<TransactionRecord> items; final List<TransactionRecord> items;
final int page; final int page;
final int limit; final int limit;
final bool isLoading; final bool isLoading;
final bool isLoadingMore; final bool isLoadingMore;
final bool hasMore; final bool hasMore;
final String? errorMessage; final String? errorMessage;
TransactionPagingState copyWith({ TransactionPagingState copyWith({
List<TransactionRecord>? items, List<TransactionRecord>? items,
int? page, int? page,
int? limit, int? limit,
bool? isLoading, bool? isLoading,
bool? isLoadingMore, bool? isLoadingMore,
bool? hasMore, bool? hasMore,
String? errorMessage, String? errorMessage,
bool clearError = false, bool clearError = false,
}) { }) {
return TransactionPagingState( return TransactionPagingState(
items: items ?? this.items, items: items ?? this.items,
page: page ?? this.page, page: page ?? this.page,
limit: limit ?? this.limit, limit: limit ?? this.limit,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
isLoadingMore: isLoadingMore ?? this.isLoadingMore, isLoadingMore: isLoadingMore ?? this.isLoadingMore,
hasMore: hasMore ?? this.hasMore, hasMore: hasMore ?? this.hasMore,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage, errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
); );
} }
} }

View File

@ -1,151 +1,151 @@
import 'package:e_receipt_mobile/domain/entities/transaction_record.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/domain/repositories/transaction_repository.dart';
import 'package:e_receipt_mobile/presentation/auth/session_controller.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_pagination_providers.dart';
import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_state.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:e_receipt_mobile/presentation/terminal/transaction_view_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final transactionPagingViewModelProvider = final transactionPagingViewModelProvider =
StateNotifierProvider.autoDispose.family< StateNotifierProvider.autoDispose.family<
TransactionPagingViewModel, TransactionPagingViewModel,
TransactionPagingState, TransactionPagingState,
TransactionQuery TransactionQuery
>((ref, query) { >((ref, query) {
final sessionUser = ref.watch(sessionControllerProvider); final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) { if (sessionUser == null) {
throw Exception('No active session'); throw Exception('No active session');
} }
final limit = ref.watch(transactionPageSizeProvider); final limit = ref.watch(transactionPageSizeProvider);
final viewModel = TransactionPagingViewModel( final viewModel = TransactionPagingViewModel(
repository: ref.watch(transactionRepositoryProvider), repository: ref.watch(transactionRepositoryProvider),
token: sessionUser.token, token: sessionUser.token,
serial: query.serial, serial: query.serial,
range: query.range, range: query.range,
limit: limit, limit: limit,
); );
viewModel.loadInitial(); viewModel.loadInitial();
return viewModel; return viewModel;
}); });
class TransactionPagingViewModel extends StateNotifier<TransactionPagingState> { class TransactionPagingViewModel extends StateNotifier<TransactionPagingState> {
TransactionPagingViewModel({ TransactionPagingViewModel({
required TransactionRepository repository, required TransactionRepository repository,
required String token, required String token,
required String serial, required String serial,
required String range, required String range,
required int limit, required int limit,
}) : _repository = repository, }) : _repository = repository,
_token = token, _token = token,
_serial = serial, _serial = serial,
_range = range, _range = range,
super(TransactionPagingState(limit: limit)); super(TransactionPagingState(limit: limit));
final TransactionRepository _repository; final TransactionRepository _repository;
final String _token; final String _token;
final String _serial; final String _serial;
final String _range; final String _range;
Future<void> loadInitial() async { Future<void> loadInitial() async {
await refresh(); await refresh();
} }
Future<void> refresh() async { Future<void> refresh() async {
state = state.copyWith( state = state.copyWith(
isLoading: true, isLoading: true,
isLoadingMore: false, isLoadingMore: false,
page: 1, page: 1,
items: const [], items: const [],
hasMore: true, hasMore: true,
clearError: true, clearError: true,
); );
try { try {
final results = await _repository.getTransactions( final results = await _repository.getTransactions(
token: _token, token: _token,
serial: _serial, serial: _serial,
range: _range, range: _range,
page: 1, page: 1,
limit: state.limit, limit: state.limit,
); );
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
items: results, items: results,
page: 1, page: 1,
hasMore: results.length >= state.limit, hasMore: results.length >= state.limit,
); );
} catch (error) { } catch (error) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
errorMessage: error.toString().replaceFirst('Exception: ', ''), errorMessage: error.toString().replaceFirst('Exception: ', ''),
); );
} }
} }
Future<void> loadMore() async { Future<void> loadMore() async {
if (state.isLoading || state.isLoadingMore || !state.hasMore) { if (state.isLoading || state.isLoadingMore || !state.hasMore) {
return; return;
} }
state = state.copyWith(isLoadingMore: true, clearError: true); state = state.copyWith(isLoadingMore: true, clearError: true);
final nextPage = state.page + 1; final nextPage = state.page + 1;
try { try {
final results = await _repository.getTransactions( final results = await _repository.getTransactions(
token: _token, token: _token,
serial: _serial, serial: _serial,
range: _range, range: _range,
page: nextPage, page: nextPage,
limit: state.limit, limit: state.limit,
); );
final merged = _mergeUnique(state.items, results); final merged = _mergeUnique(state.items, results);
final addedCount = merged.length - state.items.length; final addedCount = merged.length - state.items.length;
final hasMore = results.length >= state.limit && addedCount > 0; final hasMore = results.length >= state.limit && addedCount > 0;
state = state.copyWith( state = state.copyWith(
isLoadingMore: false, isLoadingMore: false,
items: merged, items: merged,
page: nextPage, page: nextPage,
hasMore: hasMore, hasMore: hasMore,
); );
} catch (error) { } catch (error) {
state = state.copyWith( state = state.copyWith(
isLoadingMore: false, isLoadingMore: false,
errorMessage: error.toString().replaceFirst('Exception: ', ''), errorMessage: error.toString().replaceFirst('Exception: ', ''),
); );
} }
} }
List<TransactionRecord> _mergeUnique( List<TransactionRecord> _mergeUnique(
List<TransactionRecord> existing, List<TransactionRecord> existing,
List<TransactionRecord> incoming, List<TransactionRecord> incoming,
) { ) {
if (existing.isEmpty) { if (existing.isEmpty) {
return incoming; return incoming;
} }
final keys = existing.map(_keyFor).toSet(); final keys = existing.map(_keyFor).toSet();
final merged = [...existing]; final merged = [...existing];
for (final item in incoming) { for (final item in incoming) {
final key = _keyFor(item); final key = _keyFor(item);
if (keys.add(key)) { if (keys.add(key)) {
merged.add(item); merged.add(item);
} }
} }
return merged; return merged;
} }
String _keyFor(TransactionRecord item) { String _keyFor(TransactionRecord item) {
return [ return [
item.id, item.id,
item.rrn, item.rrn,
item.createdAt, item.createdAt,
item.amount?.toString(), item.amount?.toString(),
item.status, item.status,
item.type, item.type,
].join('|'); ].join('|');
} }
} }

View File

@ -1,403 +1,403 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart';
import 'package:e_receipt_mobile/domain/entities/transaction_record.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/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/terminal/receipt_view_model.dart'; import 'package:e_receipt_mobile/presentation/terminal/receipt_view_model.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_pdfview/flutter_pdfview.dart'; import 'package:flutter_pdfview/flutter_pdfview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
class TransactionReceiptScreen extends ConsumerWidget { class TransactionReceiptScreen extends ConsumerWidget {
const TransactionReceiptScreen({ const TransactionReceiptScreen({
required this.merchantName, required this.merchantName,
required this.terminal, required this.terminal,
required this.transaction, required this.transaction,
super.key, super.key,
}); });
final String merchantName; final String merchantName;
final Terminal terminal; final Terminal terminal;
final TransactionRecord transaction; final TransactionRecord transaction;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final transactionId = transaction.id?.trim(); final transactionId = transaction.id?.trim();
if (transactionId == null || transactionId.isEmpty) { if (transactionId == null || transactionId.isEmpty) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Receipt')), appBar: AppBar(title: const Text('Receipt')),
body: const SafeArea( body: const SafeArea(
child: Center(child: Text('Missing transaction id')), child: Center(child: Text('Missing transaction id')),
), ),
); );
} }
final pdfAsync = ref.watch( final pdfAsync = ref.watch(
transactionReceiptViewDataProvider( transactionReceiptViewDataProvider(
TransactionReceiptQuery( TransactionReceiptQuery(
transactionId: transactionId, transactionId: transactionId,
copyFor: 'Merchant', copyFor: 'Merchant',
), ),
), ),
); );
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Receipt'), title: const Text('Receipt'),
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Reload', tooltip: 'Reload',
onPressed: () => ref.invalidate( onPressed: () => ref.invalidate(
transactionReceiptViewDataProvider( transactionReceiptViewDataProvider(
TransactionReceiptQuery( TransactionReceiptQuery(
transactionId: transactionId, transactionId: transactionId,
copyFor: 'Merchant', copyFor: 'Merchant',
), ),
), ),
), ),
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
), ),
], ],
), ),
body: SafeArea( body: SafeArea(
bottom: true, bottom: true,
child: pdfAsync.when( child: pdfAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center( error: (error, _) => Center(
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
24, 24,
24, 24,
24, 24,
MediaQuery.viewPaddingOf(context).bottom + 24, MediaQuery.viewPaddingOf(context).bottom + 24,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.picture_as_pdf_outlined, size: 56), const Icon(Icons.picture_as_pdf_outlined, size: 56),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Failed to load receipt PDF', 'Failed to load receipt PDF',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
Error.safeToString(error), Error.safeToString(error),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => ref.invalidate( onPressed: () => ref.invalidate(
transactionReceiptViewDataProvider( transactionReceiptViewDataProvider(
TransactionReceiptQuery( TransactionReceiptQuery(
transactionId: transactionId, transactionId: transactionId,
copyFor: 'Merchant', copyFor: 'Merchant',
), ),
), ),
), ),
child: const Text('Try again'), child: const Text('Try again'),
), ),
], ],
), ),
), ),
), ),
data: (viewData) => Column( data: (viewData) => Column(
children: [ children: [
Expanded( Expanded(
child: switch (viewData) { child: switch (viewData) {
ReceiptPdfViewData() => _ReceiptViewport( ReceiptPdfViewData() => _ReceiptViewport(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: PDFView( child: PDFView(
filePath: viewData.file.path, filePath: viewData.file.path,
enableSwipe: true, enableSwipe: true,
swipeHorizontal: false, swipeHorizontal: false,
autoSpacing: false, autoSpacing: false,
pageFling: true, pageFling: true,
pageSnap: true, pageSnap: true,
fitEachPage: true, fitEachPage: true,
fitPolicy: FitPolicy.WIDTH, fitPolicy: FitPolicy.WIDTH,
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
onError: (error) { onError: (error) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'PDF error: ${Error.safeToString(error)}', 'PDF error: ${Error.safeToString(error)}',
), ),
), ),
); );
}, },
onPageError: (page, error) { onPageError: (page, error) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'PDF page $page error: ${Error.safeToString(error)}', 'PDF page $page error: ${Error.safeToString(error)}',
), ),
), ),
); );
}, },
), ),
), ),
}, },
), ),
SafeArea( SafeArea(
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () => _downloadReceipt( onPressed: () => _downloadReceipt(
context: context, context: context,
ref: ref, ref: ref,
transactionId: transactionId, transactionId: transactionId,
), ),
icon: const Icon(Icons.download_outlined), icon: const Icon(Icons.download_outlined),
label: const Text('Download PDF'), label: const Text('Download PDF'),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: FilledButton.tonal( child: FilledButton.tonal(
onPressed: () => _printReceipt( onPressed: () => _printReceipt(
context: context, context: context,
ref: ref, ref: ref,
transactionId: transactionId, transactionId: transactionId,
), ),
child: const Row( child: const Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.print_outlined), Icon(Icons.print_outlined),
SizedBox(width: 8), SizedBox(width: 8),
Text('Print PDF'), Text('Print PDF'),
], ],
), ),
), ),
), ),
], ],
), ),
), ),
), ),
], ],
), ),
), ),
), ),
); );
} }
} }
class _ReceiptViewport extends StatelessWidget { class _ReceiptViewport extends StatelessWidget {
const _ReceiptViewport({required this.child, this.padding}); const _ReceiptViewport({required this.child, this.padding});
final Widget child; final Widget child;
final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? padding;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final viewPadding = MediaQuery.viewPaddingOf(context); final viewPadding = MediaQuery.viewPaddingOf(context);
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final maxHeight = size.height * 0.60; final maxHeight = size.height * 0.60;
return Padding( return Padding(
padding: padding:
padding ?? EdgeInsets.fromLTRB(16, 16, 16, viewPadding.bottom + 16), padding ?? EdgeInsets.fromLTRB(16, 16, 16, viewPadding.bottom + 16),
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final double targetHeight = maxHeight final double targetHeight = maxHeight
.clamp(0, constraints.maxHeight) .clamp(0, constraints.maxHeight)
.toDouble(); .toDouble();
return Align( return Align(
alignment: Alignment.center, alignment: Alignment.center,
child: SizedBox( child: SizedBox(
width: 230, width: 230,
height: targetHeight, height: targetHeight,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.7), color: colorScheme.surfaceContainerHighest.withOpacity(0.7),
border: Border.all(color: colorScheme.outlineVariant), border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: SizedBox.expand(child: child), child: SizedBox.expand(child: child),
), ),
), ),
), ),
); );
}, },
), ),
); );
} }
} }
Future<void> _downloadReceipt({ Future<void> _downloadReceipt({
required BuildContext context, required BuildContext context,
required WidgetRef ref, required WidgetRef ref,
required String transactionId, required String transactionId,
}) async { }) async {
try { try {
final sanitizedId = transactionId.replaceAll( final sanitizedId = transactionId.replaceAll(
RegExp(r'[^A-Za-z0-9_-]'), RegExp(r'[^A-Za-z0-9_-]'),
'_', '_',
); );
final stamp = DateTime.now().toIso8601String().replaceAll(':', '-'); final stamp = DateTime.now().toIso8601String().replaceAll(':', '-');
final Uint8List bytes = await _runWithProgress( final Uint8List bytes = await _runWithProgress(
context, context,
message: 'Preparing receipt…', message: 'Preparing receipt…',
task: () => _fetchReceiptPdfBytes( task: () => _fetchReceiptPdfBytes(
ref: ref, ref: ref,
transactionId: transactionId, transactionId: transactionId,
copyFor: 'Merchant', copyFor: 'Merchant',
), ),
); );
final candidateBaseDirs = <Directory>[]; final candidateBaseDirs = <Directory>[];
if (!kIsWeb) { if (!kIsWeb) {
if (Platform.isAndroid) { if (Platform.isAndroid) {
final dir = await getExternalStorageDirectory(); final dir = await getExternalStorageDirectory();
if (dir != null) { if (dir != null) {
candidateBaseDirs.add(dir); candidateBaseDirs.add(dir);
} }
} }
final downloads = await getDownloadsDirectory(); final downloads = await getDownloadsDirectory();
if (downloads != null) { if (downloads != null) {
candidateBaseDirs.add(downloads); candidateBaseDirs.add(downloads);
} }
} }
candidateBaseDirs.add(await getApplicationDocumentsDirectory()); candidateBaseDirs.add(await getApplicationDocumentsDirectory());
File? outFile; File? outFile;
Object? lastError; Object? lastError;
for (final baseDir in candidateBaseDirs) { for (final baseDir in candidateBaseDirs) {
try { try {
final receiptsDir = Directory( final receiptsDir = Directory(
'${baseDir.path}${Platform.pathSeparator}receipts', '${baseDir.path}${Platform.pathSeparator}receipts',
); );
if (!await receiptsDir.exists()) { if (!await receiptsDir.exists()) {
await receiptsDir.create(recursive: true); await receiptsDir.create(recursive: true);
} }
final candidate = File( final candidate = File(
'${receiptsDir.path}${Platform.pathSeparator}receipt_${sanitizedId}_merchant_$stamp.pdf', '${receiptsDir.path}${Platform.pathSeparator}receipt_${sanitizedId}_merchant_$stamp.pdf',
); );
await candidate.writeAsBytes(bytes, flush: true); await candidate.writeAsBytes(bytes, flush: true);
outFile = candidate; outFile = candidate;
break; break;
} catch (e) { } catch (e) {
lastError = e; lastError = e;
} }
} }
if (outFile == null) { if (outFile == null) {
throw lastError ?? Exception('Failed to save receipt PDF'); throw lastError ?? Exception('Failed to save receipt PDF');
} }
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('Saved: ${outFile.path}'))); ).showSnackBar(SnackBar(content: Text('Saved: ${outFile.path}')));
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('Download failed: $e'))); ).showSnackBar(SnackBar(content: Text('Download failed: $e')));
} }
} }
} }
Future<void> _printReceipt({ Future<void> _printReceipt({
required BuildContext context, required BuildContext context,
required WidgetRef ref, required WidgetRef ref,
required String transactionId, required String transactionId,
}) async { }) async {
try { try {
final Uint8List bytes = await _runWithProgress( final Uint8List bytes = await _runWithProgress(
context, context,
message: 'Preparing print…', message: 'Preparing print…',
task: () => _fetchReceiptPdfBytes( task: () => _fetchReceiptPdfBytes(
ref: ref, ref: ref,
transactionId: transactionId, transactionId: transactionId,
copyFor: 'Merchant', copyFor: 'Merchant',
), ),
); );
await Printing.layoutPdf(onLayout: (_) async => bytes); await Printing.layoutPdf(onLayout: (_) async => bytes);
} catch (e) { } catch (e) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('Print failed: $e'))); ).showSnackBar(SnackBar(content: Text('Print failed: $e')));
} }
} }
Future<Uint8List> _fetchReceiptPdfBytes({ Future<Uint8List> _fetchReceiptPdfBytes({
required WidgetRef ref, required WidgetRef ref,
required String transactionId, required String transactionId,
required String copyFor, required String copyFor,
}) async { }) async {
final sessionUser = ref.read(sessionControllerProvider); final sessionUser = ref.read(sessionControllerProvider);
if (sessionUser == null) { if (sessionUser == null) {
throw Exception('No active session'); throw Exception('No active session');
} }
return ref return ref
.read(receiptRepositoryProvider) .read(receiptRepositoryProvider)
.getTransactionReceiptPdfBytes( .getTransactionReceiptPdfBytes(
token: sessionUser.token, token: sessionUser.token,
transactionId: transactionId, transactionId: transactionId,
copyFor: copyFor, copyFor: copyFor,
); );
} }
Future<T> _runWithProgress<T>( Future<T> _runWithProgress<T>(
BuildContext context, { BuildContext context, {
required String message, required String message,
required Future<T> Function() task, required Future<T> Function() task,
}) async { }) async {
if (!context.mounted) { if (!context.mounted) {
return task(); return task();
} }
unawaited( unawaited(
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) { builder: (context) {
return PopScope( return PopScope(
canPop: false, canPop: false,
child: AlertDialog( child: AlertDialog(
content: Row( content: Row(
children: [ children: [
const SizedBox( const SizedBox(
height: 20, height: 20,
width: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Text(message)), Expanded(child: Text(message)),
], ],
), ),
), ),
); );
}, },
), ),
); );
try { try {
return await task(); return await task();
} finally { } finally {
if (context.mounted) { if (context.mounted) {
Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context, rootNavigator: true).pop();
} }
} }
} }

View File

@ -1,51 +1,51 @@
import 'package:e_receipt_mobile/core/config/app_config.dart'; 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/data/repositories/api_transaction_repository.dart';
import 'package:e_receipt_mobile/domain/entities/transaction_record.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/domain/repositories/transaction_repository.dart';
import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; import 'package:e_receipt_mobile/presentation/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; import 'package:e_receipt_mobile/presentation/login/login_view_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
final transactionRepositoryProvider = Provider<TransactionRepository>((ref) { final transactionRepositoryProvider = Provider<TransactionRepository>((ref) {
return ApiTransactionRepository( return ApiTransactionRepository(
baseUrl: AppConfig.apiBaseUrl, baseUrl: AppConfig.apiBaseUrl,
apiSecret: AppConfig.apiSecret, apiSecret: AppConfig.apiSecret,
client: ref.watch(authenticatedHttpClientProvider), client: ref.watch(authenticatedHttpClientProvider),
); );
}); });
class TransactionQuery { class TransactionQuery {
const TransactionQuery({required this.serial, required this.range}); const TransactionQuery({required this.serial, required this.range});
final String serial; final String serial;
final String range; final String range;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) { if (identical(this, other)) {
return true; return true;
} }
return other is TransactionQuery && return other is TransactionQuery &&
other.serial == serial && other.serial == serial &&
other.range == range; other.range == range;
} }
@override @override
int get hashCode => Object.hash(serial, range); int get hashCode => Object.hash(serial, range);
} }
final transactionListProvider = final transactionListProvider =
FutureProvider.family<List<TransactionRecord>, TransactionQuery>( FutureProvider.family<List<TransactionRecord>, TransactionQuery>(
(ref, query) async { (ref, query) async {
final sessionUser = ref.watch(sessionControllerProvider); final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) { if (sessionUser == null) {
throw Exception('No active session'); throw Exception('No active session');
} }
return ref.watch(transactionRepositoryProvider).getTransactions( return ref.watch(transactionRepositoryProvider).getTransactions(
token: sessionUser.token, token: sessionUser.token,
serial: query.serial, serial: query.serial,
range: query.range, range: query.range,
); );
}, },
); );

View File

@ -1,58 +1,58 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class TerminalHeader extends StatelessWidget { class TerminalHeader extends StatelessWidget {
const TerminalHeader({ const TerminalHeader({
required this.merchantName, required this.merchantName,
required this.totalCount, required this.totalCount,
super.key, super.key,
}); });
final String merchantName; final String merchantName;
final int totalCount; final int totalCount;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Terminals', 'Terminals',
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
merchantName, merchantName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Chip( Chip(
label: Text('$totalCount'), label: Text('$totalCount'),
side: BorderSide(color: colorScheme.outlineVariant), side: BorderSide(color: colorScheme.outlineVariant),
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
), ),
], ],
), ),
], ],
), ),
); );
} }
} }

View File

@ -1,208 +1,208 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'dart:ui'; import 'dart:ui';
import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class TerminalListView extends StatelessWidget { class TerminalListView extends StatelessWidget {
const TerminalListView({ const TerminalListView({
required this.terminals, required this.terminals,
required this.onRefresh, required this.onRefresh,
required this.onTerminalTap, required this.onTerminalTap,
required this.emptyMessage, required this.emptyMessage,
super.key, super.key,
}); });
final List<Terminal> terminals; final List<Terminal> terminals;
final Future<void> Function() onRefresh; final Future<void> Function() onRefresh;
final void Function(Terminal terminal) onTerminalTap; final void Function(Terminal terminal) onTerminalTap;
final String emptyMessage; final String emptyMessage;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = MediaQuery.viewPaddingOf(context).bottom; final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return RefreshIndicator.adaptive( return RefreshIndicator.adaptive(
onRefresh: onRefresh, onRefresh: onRefresh,
child: terminals.isEmpty child: terminals.isEmpty
? ListView( ? ListView(
padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24), padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24),
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
children: [ children: [
Icon( Icon(
Icons.point_of_sale_outlined, Icons.point_of_sale_outlined,
size: 56, size: 56,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
emptyMessage, emptyMessage,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Pull down to refresh.', 'Pull down to refresh.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
], ],
) )
: ListView.separated( : ListView.separated(
padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16), padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16),
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: terminals.length, itemCount: terminals.length,
separatorBuilder: (_, __) => const SizedBox(height: 10), separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final terminal = terminals[index]; final terminal = terminals[index];
print('this is terminal ${terminal.status}'); print('this is terminal ${terminal.status}');
final enabled = terminal.tid != null || terminal.id != null; final enabled = terminal.tid != null || terminal.id != null;
final title = final title =
(_sanitizeMultiline(terminal.name)?.isNotEmpty ?? false) (_sanitizeMultiline(terminal.name)?.isNotEmpty ?? false)
? _sanitizeMultiline(terminal.name)! ? _sanitizeMultiline(terminal.name)!
: (terminal.tid?.trim().isNotEmpty ?? false) : (terminal.tid?.trim().isNotEmpty ?? false)
? terminal.tid!.trim() ? terminal.tid!.trim()
: '-'; : '-';
final status = (terminal.status ?? '').trim(); final status = (terminal.status ?? '').trim();
final serial = (_sanitizeMultiline(terminal.serial) ?? '') final serial = (_sanitizeMultiline(terminal.serial) ?? '')
.trim(); .trim();
return Card( return Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
side: BorderSide( side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.55), color: colorScheme.outlineVariant.withOpacity(0.55),
), ),
), ),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
onTap: enabled ? () => onTerminalTap(terminal) : null, onTap: enabled ? () => onTerminalTap(terminal) : null,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
vertical: 12, vertical: 12,
), ),
child: Row( child: Row(
children: [ children: [
Container( Container(
height: 44, height: 44,
width: 44, width: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
color: enabled color: enabled
? colorScheme.primary.withOpacity(0.14) ? colorScheme.primary.withOpacity(0.14)
: colorScheme.surfaceContainerHigh, : colorScheme.surfaceContainerHigh,
), ),
child: Icon( child: Icon(
Icons.point_of_sale_outlined, Icons.point_of_sale_outlined,
color: enabled color: enabled
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
title, title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium .titleMedium
?.copyWith( ?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
), ),
if (status.isNotEmpty) if (status.isNotEmpty)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
999, 999,
), ),
color: color:
colorScheme.surfaceContainerHigh, colorScheme.surfaceContainerHigh,
border: Border.all( border: Border.all(
color: colorScheme.outlineVariant, color: colorScheme.outlineVariant,
), ),
), ),
child: Text( child: Text(
status.toUpperCase(), status.toUpperCase(),
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.labelSmall .labelSmall
?.copyWith( ?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.2, letterSpacing: 0.2,
), ),
), ),
), ),
], ],
), ),
if (serial.isNotEmpty) ...[ if (serial.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Serial: $serial', 'Serial: $serial',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.bodyMedium .bodyMedium
?.copyWith( ?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontFeatures: const [ fontFeatures: const [
FontFeature.tabularFigures(), FontFeature.tabularFigures(),
], ],
), ),
), ),
], ],
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Icon( Icon(
Icons.chevron_right, Icons.chevron_right,
color: enabled color: enabled
? colorScheme.onSurfaceVariant ? colorScheme.onSurfaceVariant
: colorScheme.onSurfaceVariant.withOpacity(0.4), : colorScheme.onSurfaceVariant.withOpacity(0.4),
), ),
], ],
), ),
), ),
), ),
); );
}, },
), ),
); );
} }
} }
String? _sanitizeMultiline(String? value) { String? _sanitizeMultiline(String? value) {
if (value == null) { if (value == null) {
return null; return null;
} }
final cleaned = value final cleaned = value
.replaceAll('\r', ' ') .replaceAll('\r', ' ')
.replaceAll('\n', ' ') .replaceAll('\n', ' ')
.replaceAll(r'\n', ' ') .replaceAll(r'\n', ' ')
.replaceAll(r'/n', ' ') .replaceAll(r'/n', ' ')
.replaceAll(RegExp(r'\s+'), ' ') .replaceAll(RegExp(r'\s+'), ' ')
.trim(); .trim();
return cleaned.isEmpty ? null : cleaned; return cleaned.isEmpty ? null : cleaned;
} }

Some files were not shown because too many files have changed in this diff Show More