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

View File

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

View File

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

View File

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

28
android/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</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"?>
<resources>
<!-- 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">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 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">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

68
ios/.gitignore vendored
View File

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

View File

@ -1,26 +1,26 @@
<?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">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
<?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">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</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"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -1,8 +1,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">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
<?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">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -1,8 +1,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">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
<?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">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

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

View File

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

View File

@ -1,8 +1,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">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
<?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">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -1,8 +1,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">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
<?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">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@ -1,37 +1,37 @@
<?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">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<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"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
<?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">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<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"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -1,26 +1,26 @@
<?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">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
<?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">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@ -1,49 +1,49 @@
<?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">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>E Receipt Mobile</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>e_receipt_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>
<?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">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>E Receipt Mobile</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>e_receipt_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,148 +1,148 @@
import 'dart:convert';
import 'package:e_receipt_mobile/domain/entities/transaction_record.dart';
import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart';
import 'package:http/http.dart' as http;
class ApiTransactionRepository implements TransactionRepository {
ApiTransactionRepository({
required this.baseUrl,
required this.apiSecret,
required http.Client client,
}) : _client = client;
final String baseUrl;
final String apiSecret;
final http.Client _client;
@override
Future<List<TransactionRecord>> getTransactions({
required String token,
required String serial,
required String range,
int page = 1,
int limit = 8,
String searchTerm = '',
String sort = '',
}) async {
final uri = Uri.parse('$baseUrl/transaction').replace(
queryParameters: {
'page': page.toString(),
'limit': limit.toString(),
'serial': serial,
'range': range,
if (searchTerm.trim().isNotEmpty) 'search': searchTerm.trim(),
if (sort.trim().isNotEmpty) 'sort': sort.trim(),
},
);
final response = await _client.get(
uri,
headers: {
'Content-Type': 'application/json',
'x-api-key': apiSecret,
'Authorization': 'Bearer ${token.trim()}',
},
);
if (response.statusCode >= 200 && response.statusCode < 300) {
return _parseTransactions(response.body);
}
throw Exception(_extractErrorMessage(response.body));
}
List<TransactionRecord> _parseTransactions(String body) {
final decoded = _tryDecode(body);
final list = _extractTransactionList(decoded);
return list
.map((item) => _transactionFromItem(item))
.whereType<TransactionRecord>()
.toList();
}
dynamic _tryDecode(String body) {
try {
return jsonDecode(body);
} catch (_) {
return null;
}
}
List<dynamic> _extractTransactionList(dynamic decoded) {
if (decoded is List<dynamic>) {
return decoded;
}
if (decoded is Map<String, dynamic>) {
final direct = decoded['transactions'];
if (direct is List<dynamic>) {
return direct;
}
final data = decoded['data'] ?? decoded['result'] ?? decoded['payload'];
if (data is List<dynamic>) {
return data;
}
if (data is Map<String, dynamic>) {
final nested = data['transactions'];
if (nested is List<dynamic>) {
return nested;
}
}
}
return const <dynamic>[];
}
TransactionRecord? _transactionFromItem(dynamic item) {
if (item is! Map<String, dynamic>) {
return null;
}
return TransactionRecord(
id: _pickString(item, const ['id', 'transactionId', 'transaction_id']),
rrn: _pickString(item, const ['rrn', 'referenceNo', 'reference_no', 'DE37']),
amount: _pickNum(item, const ['amount', 'totalAmount', 'total_amount', 'DE4']),
status: _pickString(item, const ['status', 'description', 'DE39']),
type: _pickString(item, const ['type', 'transactionType', 'DE3']),
createdAt: _pickString(item, const ['createdAt', 'created_at', 'CREATED_AT']),
raw: item,
);
}
String? _pickString(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString().trim();
}
}
return null;
}
num? _pickNum(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value is num) {
return value;
}
if (value != null) {
final parsed = num.tryParse(value.toString().trim());
if (parsed != null) {
return parsed;
}
}
}
return null;
}
String _extractErrorMessage(String body) {
final decoded = _tryDecode(body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'] ?? decoded['error'] ?? decoded['detail'];
if (message != null) {
return message.toString();
}
}
return 'Failed to load transactions';
}
}
import 'dart:convert';
import 'package:e_receipt_mobile/domain/entities/transaction_record.dart';
import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart';
import 'package:http/http.dart' as http;
class ApiTransactionRepository implements TransactionRepository {
ApiTransactionRepository({
required this.baseUrl,
required this.apiSecret,
required http.Client client,
}) : _client = client;
final String baseUrl;
final String apiSecret;
final http.Client _client;
@override
Future<List<TransactionRecord>> getTransactions({
required String token,
required String serial,
required String range,
int page = 1,
int limit = 8,
String searchTerm = '',
String sort = '',
}) async {
final uri = Uri.parse('$baseUrl/transaction').replace(
queryParameters: {
'page': page.toString(),
'limit': limit.toString(),
'serial': serial,
'range': range,
if (searchTerm.trim().isNotEmpty) 'search': searchTerm.trim(),
if (sort.trim().isNotEmpty) 'sort': sort.trim(),
},
);
final response = await _client.get(
uri,
headers: {
'Content-Type': 'application/json',
'x-api-key': apiSecret,
'Authorization': 'Bearer ${token.trim()}',
},
);
if (response.statusCode >= 200 && response.statusCode < 300) {
return _parseTransactions(response.body);
}
throw Exception(_extractErrorMessage(response.body));
}
List<TransactionRecord> _parseTransactions(String body) {
final decoded = _tryDecode(body);
final list = _extractTransactionList(decoded);
return list
.map((item) => _transactionFromItem(item))
.whereType<TransactionRecord>()
.toList();
}
dynamic _tryDecode(String body) {
try {
return jsonDecode(body);
} catch (_) {
return null;
}
}
List<dynamic> _extractTransactionList(dynamic decoded) {
if (decoded is List<dynamic>) {
return decoded;
}
if (decoded is Map<String, dynamic>) {
final direct = decoded['transactions'];
if (direct is List<dynamic>) {
return direct;
}
final data = decoded['data'] ?? decoded['result'] ?? decoded['payload'];
if (data is List<dynamic>) {
return data;
}
if (data is Map<String, dynamic>) {
final nested = data['transactions'];
if (nested is List<dynamic>) {
return nested;
}
}
}
return const <dynamic>[];
}
TransactionRecord? _transactionFromItem(dynamic item) {
if (item is! Map<String, dynamic>) {
return null;
}
return TransactionRecord(
id: _pickString(item, const ['id', 'transactionId', 'transaction_id']),
rrn: _pickString(item, const ['rrn', 'referenceNo', 'reference_no', 'DE37']),
amount: _pickNum(item, const ['amount', 'totalAmount', 'total_amount', 'DE4']),
status: _pickString(item, const ['status', 'description', 'DE39']),
type: _pickString(item, const ['type', 'transactionType', 'DE3']),
createdAt: _pickString(item, const ['createdAt', 'created_at', 'CREATED_AT']),
raw: item,
);
}
String? _pickString(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString().trim();
}
}
return null;
}
num? _pickNum(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value is num) {
return value;
}
if (value != null) {
final parsed = num.tryParse(value.toString().trim());
if (parsed != null) {
return parsed;
}
}
}
return null;
}
String _extractErrorMessage(String body) {
final decoded = _tryDecode(body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'] ?? decoded['error'] ?? decoded['detail'];
if (message != null) {
return message.toString();
}
}
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/repositories/auth_repository.dart';
class MockAuthRepository implements AuthRepository {
@override
Future<LoginUser> login({
required String username,
required String password,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 800));
if (username.trim().isEmpty || password.isEmpty) {
throw Exception('Username and password are required.');
}
if (password.length < 6) {
throw Exception('Password must be at least 6 characters.');
}
return LoginUser(
username: username.trim(),
token: 'mock-access-token',
refreshToken: 'mock-refresh-token',
role: username.trim().toLowerCase() == 'admin' ? 'admin' : 'user',
);
}
@override
Future<LoginUser> refreshSession({
required String username,
required String refreshToken,
required String role,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 300));
if (refreshToken.trim().isEmpty) {
throw Exception('Refresh token is required.');
}
return LoginUser(
username: username.trim(),
token: 'mock-access-token-refreshed',
refreshToken: 'mock-refresh-token-refreshed',
role: role,
);
}
@override
Future<void> resetPassword({
required String userId,
required String password,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 500));
if (userId.trim().isEmpty) {
throw Exception('User ID is required.');
}
if (password.length < 8) {
throw Exception('Password must be at least 8 characters.');
}
}
@override
Future<void> logout({required String refreshToken}) async {
await Future<void>.delayed(const Duration(milliseconds: 250));
if (refreshToken.trim().isEmpty) {
throw Exception('Refresh token is required.');
}
}
}
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart';
class MockAuthRepository implements AuthRepository {
@override
Future<LoginUser> login({
required String username,
required String password,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 800));
if (username.trim().isEmpty || password.isEmpty) {
throw Exception('Username and password are required.');
}
if (password.length < 6) {
throw Exception('Password must be at least 6 characters.');
}
return LoginUser(
username: username.trim(),
token: 'mock-access-token',
refreshToken: 'mock-refresh-token',
role: username.trim().toLowerCase() == 'admin' ? 'admin' : 'user',
);
}
@override
Future<LoginUser> refreshSession({
required String username,
required String refreshToken,
required String role,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 300));
if (refreshToken.trim().isEmpty) {
throw Exception('Refresh token is required.');
}
return LoginUser(
username: username.trim(),
token: 'mock-access-token-refreshed',
refreshToken: 'mock-refresh-token-refreshed',
role: role,
);
}
@override
Future<void> resetPassword({
required String userId,
required String password,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 500));
if (userId.trim().isEmpty) {
throw Exception('User ID is required.');
}
if (password.length < 8) {
throw Exception('Password must be at least 8 characters.');
}
}
@override
Future<void> logout({required String refreshToken}) async {
await Future<void>.delayed(const Duration(milliseconds: 250));
if (refreshToken.trim().isEmpty) {
throw Exception('Refresh token is required.');
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,15 @@
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
abstract class AuthRepository {
Future<LoginUser> login({required String username, required String password});
Future<LoginUser> refreshSession({
required String username,
required String refreshToken,
required String role,
});
Future<void> resetPassword({required String userId, required String password});
Future<void> logout({required String refreshToken});
}
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
abstract class AuthRepository {
Future<LoginUser> login({required String username, required String password});
Future<LoginUser> refreshSession({
required String username,
required String refreshToken,
required String role,
});
Future<void> resetPassword({required String userId, required String password});
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/terminal.dart';
abstract class MerchantRepository {
Future<List<Merchant>> getMerchants({
required String token,
int page = 1,
int limit = 10,
String searchTerm = '',
});
Future<List<Terminal>> getTerminalsByMerchantId({
required String token,
required String merchantId,
});
}
import 'package:e_receipt_mobile/domain/entities/merchant.dart';
import 'package:e_receipt_mobile/domain/entities/terminal.dart';
abstract class MerchantRepository {
Future<List<Merchant>> getMerchants({
required String token,
int page = 1,
int limit = 10,
String searchTerm = '',
});
Future<List<Terminal>> getTerminalsByMerchantId({
required String token,
required String merchantId,
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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