second commit
45
.gitignore
vendored
Normal file
@@ -0,0 +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
|
||||||
45
.metadata
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# 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: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
- platform: android
|
||||||
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
- platform: ios
|
||||||
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
- platform: linux
|
||||||
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
- platform: macos
|
||||||
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
- platform: web
|
||||||
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
- platform: windows
|
||||||
|
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||||
|
|
||||||
|
# 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'
|
||||||
24
analysis_options.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- "**/*.g.dart"
|
||||||
|
- "**/*.freezed.dart"
|
||||||
|
language:
|
||||||
|
strict-casts: true
|
||||||
|
strict-raw-types: true
|
||||||
|
errors:
|
||||||
|
invalid_annotation_target: ignore
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
- always_declare_return_types
|
||||||
|
- avoid_print
|
||||||
|
- prefer_const_constructors
|
||||||
|
- prefer_const_declarations
|
||||||
|
- prefer_final_locals
|
||||||
|
- prefer_single_quotes
|
||||||
|
- require_trailing_commas
|
||||||
|
- sort_child_properties_last
|
||||||
|
- unawaited_futures
|
||||||
|
- use_super_parameters
|
||||||
14
android/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/to/reference-keystore
|
||||||
|
key.properties
|
||||||
|
**/*.keystore
|
||||||
|
**/*.jks
|
||||||
45
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.nearle_pos"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.example.nearle_pos"
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +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>
|
||||||
45
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="nearle_pos"
|
||||||
|
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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.nearle_pos
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
@@ -0,0 +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>
|
||||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
@@ -0,0 +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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
@@ -0,0 +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>
|
||||||
18
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +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>
|
||||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
@@ -0,0 +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>
|
||||||
24
android/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newBuildDir: Directory =
|
||||||
|
rootProject.layout.buildDirectory
|
||||||
|
.dir("../../build")
|
||||||
|
.get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(":app")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
6
android/gradle.properties
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
# This newDsl flag was added by the Flutter template
|
||||||
|
android.newDsl=false
|
||||||
|
# This builtInKotlin flag was added by the Flutter template
|
||||||
|
android.builtInKotlin=false
|
||||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
|
||||||
26
android/settings.gradle.kts
Normal file
@@ -0,0 +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 "9.0.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
BIN
assets/sounds/beep_error.wav
Normal file
BIN
assets/sounds/beep_success.wav
Normal file
BIN
assets/sounds/charge_complete.wav
Normal file
34
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
**/dgph
|
||||||
|
*.mode1v3
|
||||||
|
*.mode2v3
|
||||||
|
*.moved-aside
|
||||||
|
*.pbxuser
|
||||||
|
*.perspectivev3
|
||||||
|
**/*sync/
|
||||||
|
.sconsign.dblite
|
||||||
|
.tags*
|
||||||
|
**/.vagrant/
|
||||||
|
**/DerivedData/
|
||||||
|
Icon?
|
||||||
|
**/Pods/
|
||||||
|
**/.symlinks/
|
||||||
|
profile
|
||||||
|
xcuserdata
|
||||||
|
**/.generated/
|
||||||
|
Flutter/App.framework
|
||||||
|
Flutter/Flutter.framework
|
||||||
|
Flutter/Flutter.podspec
|
||||||
|
Flutter/Generated.xcconfig
|
||||||
|
Flutter/ephemeral/
|
||||||
|
Flutter/app.flx
|
||||||
|
Flutter/app.zip
|
||||||
|
Flutter/flutter_assets/
|
||||||
|
Flutter/flutter_export_environment.sh
|
||||||
|
ServiceDefinitions.json
|
||||||
|
Runner/GeneratedPluginRegistrant.*
|
||||||
|
|
||||||
|
# Exceptions to above rules.
|
||||||
|
!default.mode1v3
|
||||||
|
!default.mode2v3
|
||||||
|
!default.pbxuser
|
||||||
|
!default.perspectivev3
|
||||||
24
ios/Flutter/AppFrameworkInfo.plist
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?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>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
1
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
1
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
644
ios/Runner.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 54;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||||
|
remoteInfo = Runner;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 10;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
name = "Embed Frameworks";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||||
|
);
|
||||||
|
path = RunnerTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Flutter;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146E51CF9000F007C117D = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */,
|
||||||
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146EF1CF9000F007C117D /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */,
|
||||||
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
|
);
|
||||||
|
path = Runner;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
331C807D294A63A400263BE5 /* Sources */,
|
||||||
|
331C807F294A63A400263BE5 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = RunnerTests;
|
||||||
|
productName = RunnerTests;
|
||||||
|
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
|
buildPhases = (
|
||||||
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = Runner;
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
|
productName = Runner;
|
||||||
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1510;
|
||||||
|
ORGANIZATIONNAME = "";
|
||||||
|
TargetAttributes = {
|
||||||
|
331C8080294A63A400263BE5 = {
|
||||||
|
CreatedOnToolsVersion = 14.0;
|
||||||
|
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||||
|
};
|
||||||
|
97C146ED1CF9000F007C117D = {
|
||||||
|
CreatedOnToolsVersion = 7.3.1;
|
||||||
|
LastSwiftMigration = 1100;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||||
|
compatibilityVersion = "Xcode 9.3";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
331C807F294A63A400263BE5 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||||
|
);
|
||||||
|
name = "Thin Binary";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||||
|
};
|
||||||
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Run Script";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
331C807D294A63A400263BE5 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||||
|
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C146FB1CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = Main.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C147001CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = LaunchScreen.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.nearlePos;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
331C8088294A63A400263BE5 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.nearlePos.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
331C8089294A63A400263BE5 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.nearlePos.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
331C808A294A63A400263BE5 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.nearlePos.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
97C147031CF9000F007C117D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
97C147041CF9000F007C117D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
97C147061CF9000F007C117D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.nearlePos;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
97C147071CF9000F007C117D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.example.nearlePos;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
331C8088294A63A400263BE5 /* Debug */,
|
||||||
|
331C8089294A63A400263BE5 /* Release */,
|
||||||
|
331C808A294A63A400263BE5 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97C147031CF9000F007C117D /* Debug */,
|
||||||
|
97C147041CF9000F007C117D /* Release */,
|
||||||
|
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97C147061CF9000F007C117D /* Debug */,
|
||||||
|
97C147071CF9000F007C117D /* Release */,
|
||||||
|
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
}
|
||||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +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>
|
||||||
@@ -0,0 +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>
|
||||||
119
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1510"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Prepare Flutter Framework Script"
|
||||||
|
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
|
<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>
|
||||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +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>
|
||||||
@@ -0,0 +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>
|
||||||
16
ios/Runner/AppDelegate.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
|
) -> Bool {
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
@@ -0,0 +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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Launch Screen Assets
|
||||||
|
|
||||||
|
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||||
|
|
||||||
|
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal file
@@ -0,0 +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>
|
||||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal file
@@ -0,0 +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>
|
||||||
70
ios/Runner/Info.plist
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?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>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Nearle Pos</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>nearle_pos</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>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</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>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
6
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
|
||||||
|
}
|
||||||
12
ios/RunnerTests/RunnerTests.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
class RunnerTests: XCTestCase {
|
||||||
|
|
||||||
|
func testExample() {
|
||||||
|
// If you add code to the Runner application, consider adding tests here.
|
||||||
|
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
27
lib/app/app.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../core/constants/app_constants.dart';
|
||||||
|
import '../core/router/app_router.dart';
|
||||||
|
import '../core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
class NearlePosApp extends ConsumerWidget {
|
||||||
|
const NearlePosApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return MaterialApp.router(
|
||||||
|
title: AppConstants.appName,
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: AppTheme.light,
|
||||||
|
routerConfig: ref.watch(routerProvider),
|
||||||
|
builder: (context, child) {
|
||||||
|
// A POS runs on fixed hardware; ignore OS font scaling so the dense
|
||||||
|
// billing panel can never overflow.
|
||||||
|
return MediaQuery.withNoTextScaling(
|
||||||
|
child: child ?? const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
lib/app/providers.dart
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../core/services/receipt_service.dart';
|
||||||
|
import '../core/services/sound_service.dart';
|
||||||
|
import '../data/datasources/local_store.dart';
|
||||||
|
import '../data/repositories/customer_repository_impl.dart';
|
||||||
|
import '../data/repositories/product_repository_impl.dart';
|
||||||
|
import '../data/datasources/remote_catalogue_source.dart';
|
||||||
|
import '../data/repositories/sync_repository_impl.dart';
|
||||||
|
import '../data/repositories/transaction_repository_impl.dart';
|
||||||
|
import '../domain/repositories/customer_repository.dart';
|
||||||
|
import '../domain/repositories/product_repository.dart';
|
||||||
|
import '../domain/repositories/sync_repository.dart';
|
||||||
|
import '../domain/repositories/transaction_repository.dart';
|
||||||
|
import '../domain/usecases/checkout_sale.dart';
|
||||||
|
|
||||||
|
/// Root data source. Overridden in tests with an in-memory double.
|
||||||
|
final localStoreProvider = Provider<LocalStore>((ref) => LocalStore.instance);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- Repositories
|
||||||
|
final productRepositoryProvider = Provider<ProductRepository>(
|
||||||
|
(ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final customerRepositoryProvider = Provider<CustomerRepository>(
|
||||||
|
(ref) => CustomerRepositoryImpl(ref.watch(localStoreProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final transactionRepositoryProvider = Provider<TransactionRepository>(
|
||||||
|
(ref) => TransactionRepositoryImpl(ref.watch(localStoreProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Simulated back-office endpoints. Held as singletons so the offline toggle
|
||||||
|
/// in Settings affects every call.
|
||||||
|
final remoteCatalogueProvider =
|
||||||
|
Provider<RemoteCatalogueSource>((ref) => RemoteCatalogueSource());
|
||||||
|
|
||||||
|
final remoteReportSinkProvider =
|
||||||
|
Provider<RemoteReportSink>((ref) => RemoteReportSink());
|
||||||
|
|
||||||
|
final syncRepositoryProvider = Provider<SyncRepository>(
|
||||||
|
(ref) => SyncRepositoryImpl(
|
||||||
|
ref.watch(localStoreProvider),
|
||||||
|
ref.watch(remoteCatalogueProvider),
|
||||||
|
ref.watch(remoteReportSinkProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Use cases
|
||||||
|
final checkoutSaleProvider = Provider<CheckoutSale>(
|
||||||
|
(ref) => CheckoutSale(
|
||||||
|
productRepository: ref.watch(productRepositoryProvider),
|
||||||
|
customerRepository: ref.watch(customerRepositoryProvider),
|
||||||
|
transactionRepository: ref.watch(transactionRepositoryProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- Services
|
||||||
|
final soundServiceProvider =
|
||||||
|
Provider<SoundService>((ref) => SoundService.instance);
|
||||||
|
|
||||||
|
final receiptServiceProvider =
|
||||||
|
Provider<ReceiptService>((ref) => ReceiptService.instance);
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- Session
|
||||||
|
class CashierSession {
|
||||||
|
const CashierSession({
|
||||||
|
required this.name,
|
||||||
|
required this.role,
|
||||||
|
required this.terminalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String role;
|
||||||
|
final String terminalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cashierSessionProvider = StateProvider<CashierSession>(
|
||||||
|
(ref) => const CashierSession(
|
||||||
|
name: 'Suriya',
|
||||||
|
role: 'ADMIN',
|
||||||
|
terminalId: 'TERM-01',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Ticks once a minute to drive the header clock without rebuilding on every
|
||||||
|
/// frame.
|
||||||
|
final clockProvider = StreamProvider<DateTime>((ref) async* {
|
||||||
|
yield DateTime.now();
|
||||||
|
yield* Stream.periodic(const Duration(seconds: 20), (_) => DateTime.now());
|
||||||
|
});
|
||||||
49
lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/// Application-wide configuration constants.
|
||||||
|
class AppConstants {
|
||||||
|
const AppConstants._();
|
||||||
|
|
||||||
|
static const String appName = 'Nearle POS';
|
||||||
|
static const String storeName = 'Nearle Daily';
|
||||||
|
static const String storeAddress = '12 Gandhipuram Main Rd, Coimbatore 641012';
|
||||||
|
static const String storeGstin = '33ABCDE1234F1Z5';
|
||||||
|
static const String storePhone = '+91 90000 12345';
|
||||||
|
static const String currencySymbol = '\u20B9';
|
||||||
|
static const String locale = 'en_IN';
|
||||||
|
|
||||||
|
/// Standard GST slab applied when a product does not declare its own.
|
||||||
|
static const double defaultGstRate = 0.18;
|
||||||
|
|
||||||
|
/// One loyalty point is earned per this many rupees of net sale value.
|
||||||
|
static const double loyaltyRupeesPerPoint = 10;
|
||||||
|
|
||||||
|
/// Rupee value of a single loyalty point when redeemed.
|
||||||
|
static const double loyaltyPointValue = 0.25;
|
||||||
|
|
||||||
|
static const int mobileNumberLength = 10;
|
||||||
|
|
||||||
|
/// Barcode scanners emit keystrokes fast; anything slower is human typing.
|
||||||
|
static const Duration barcodeScanTimeout = Duration(milliseconds: 120);
|
||||||
|
static const int minBarcodeLength = 6;
|
||||||
|
|
||||||
|
/// Idle time after a completed sale before the terminal resets itself.
|
||||||
|
static const Duration postSaleResetDelay = Duration(seconds: 3);
|
||||||
|
|
||||||
|
static const int lowStockThreshold = 10;
|
||||||
|
static const int maxParkedBills = 20;
|
||||||
|
static const int maxCartQuantityPerLine = 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hive box names.
|
||||||
|
class StorageKeys {
|
||||||
|
const StorageKeys._();
|
||||||
|
|
||||||
|
static const String products = 'box_products';
|
||||||
|
static const String customers = 'box_customers';
|
||||||
|
static const String transactions = 'box_transactions';
|
||||||
|
static const String parkedBills = 'box_parked_bills';
|
||||||
|
static const String settings = 'box_settings';
|
||||||
|
|
||||||
|
static const String cashierName = 'cashier_name';
|
||||||
|
static const String cashierRole = 'cashier_role';
|
||||||
|
static const String terminalId = 'terminal_id';
|
||||||
|
}
|
||||||
13
lib/core/constants/asset_paths.dart
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/// Typed references to bundled assets.
|
||||||
|
///
|
||||||
|
/// Only sounds are bundled: product imagery uses emoji glyphs and the welcome
|
||||||
|
/// artwork is painted in code, so there are no raster or SVG assets to ship.
|
||||||
|
class AssetPaths {
|
||||||
|
const AssetPaths._();
|
||||||
|
|
||||||
|
static const String _snd = 'assets/sounds';
|
||||||
|
|
||||||
|
static const String beepSuccess = '$_snd/beep_success.wav';
|
||||||
|
static const String beepError = '$_snd/beep_error.wav';
|
||||||
|
static const String chargeComplete = '$_snd/charge_complete.wav';
|
||||||
|
}
|
||||||
140
lib/core/router/app_router.dart
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../domain/entities/transaction.dart';
|
||||||
|
import '../../presentation/auth/providers/auth_controller.dart';
|
||||||
|
import '../../presentation/auth/screens/login_screen.dart';
|
||||||
|
import '../../presentation/customer/screens/customer_registration_screen.dart';
|
||||||
|
import '../../presentation/customer/screens/existing_customer_screen.dart';
|
||||||
|
import '../../presentation/payment/screens/payment_screen.dart';
|
||||||
|
import '../../presentation/pos/screens/pos_dashboard_screen.dart';
|
||||||
|
import '../../presentation/receipt/screens/receipt_screen.dart';
|
||||||
|
import '../../presentation/welcome/screens/welcome_screen.dart';
|
||||||
|
|
||||||
|
class AppRoutes {
|
||||||
|
const AppRoutes._();
|
||||||
|
|
||||||
|
static const String login = '/login';
|
||||||
|
static const String welcome = '/';
|
||||||
|
static const String registerCustomer = '/customer/new';
|
||||||
|
static const String existingCustomer = '/customer/find';
|
||||||
|
static const String pos = '/pos';
|
||||||
|
static const String payment = '/pos/payment';
|
||||||
|
static const String receipt = '/pos/receipt';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Router with an authentication guard.
|
||||||
|
///
|
||||||
|
/// Every route except [AppRoutes.login] requires a signed-in store, and an
|
||||||
|
/// already-signed-in terminal is bounced away from the login screen.
|
||||||
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
|
// GoRouter re-evaluates `redirect` whenever this notifier fires.
|
||||||
|
final authChanged = ValueNotifier<bool>(
|
||||||
|
ref.read(authControllerProvider).isAuthenticated,
|
||||||
|
);
|
||||||
|
ref.listen<AuthState>(
|
||||||
|
authControllerProvider,
|
||||||
|
(_, next) => authChanged.value = next.isAuthenticated,
|
||||||
|
);
|
||||||
|
ref.onDispose(authChanged.dispose);
|
||||||
|
|
||||||
|
return GoRouter(
|
||||||
|
initialLocation: AppRoutes.login,
|
||||||
|
refreshListenable: authChanged,
|
||||||
|
debugLogDiagnostics: false,
|
||||||
|
redirect: (context, state) {
|
||||||
|
final signedIn = ref.read(authControllerProvider).isAuthenticated;
|
||||||
|
final atLogin = state.matchedLocation == AppRoutes.login;
|
||||||
|
|
||||||
|
if (!signedIn) return atLogin ? null : AppRoutes.login;
|
||||||
|
if (atLogin) return AppRoutes.welcome;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.login,
|
||||||
|
name: 'login',
|
||||||
|
pageBuilder: (context, state) => _fade(state, const LoginScreen()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.welcome,
|
||||||
|
name: 'welcome',
|
||||||
|
pageBuilder: (context, state) => _fade(state, const WelcomeScreen()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.registerCustomer,
|
||||||
|
name: 'registerCustomer',
|
||||||
|
pageBuilder: (context, state) => _slide(
|
||||||
|
state,
|
||||||
|
CustomerRegistrationScreen(
|
||||||
|
prefillMobile: state.uri.queryParameters['mobile'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.existingCustomer,
|
||||||
|
name: 'existingCustomer',
|
||||||
|
pageBuilder: (context, state) =>
|
||||||
|
_slide(state, const ExistingCustomerScreen()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.pos,
|
||||||
|
name: 'pos',
|
||||||
|
pageBuilder: (context, state) =>
|
||||||
|
_fade(state, const PosDashboardScreen()),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'payment',
|
||||||
|
name: 'payment',
|
||||||
|
pageBuilder: (context, state) =>
|
||||||
|
_slide(state, const PaymentScreen()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'receipt',
|
||||||
|
name: 'receipt',
|
||||||
|
pageBuilder: (context, state) => _fade(
|
||||||
|
state,
|
||||||
|
ReceiptScreen(transaction: state.extra! as SaleTransaction),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
errorBuilder: (context, state) => Scaffold(
|
||||||
|
body: Center(child: Text('Route not found: ${state.uri}')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomTransitionPage<void> _fade(GoRouterState state, Widget child) {
|
||||||
|
return CustomTransitionPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: child,
|
||||||
|
transitionDuration: const Duration(milliseconds: 220),
|
||||||
|
transitionsBuilder: (_, animation, __, child) =>
|
||||||
|
FadeTransition(opacity: animation, child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomTransitionPage<void> _slide(GoRouterState state, Widget child) {
|
||||||
|
return CustomTransitionPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: child,
|
||||||
|
transitionDuration: const Duration(milliseconds: 260),
|
||||||
|
transitionsBuilder: (_, animation, __, child) {
|
||||||
|
final curved =
|
||||||
|
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: curved,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.03),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(curved),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
91
lib/core/services/barcode_service.dart
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../constants/app_constants.dart';
|
||||||
|
|
||||||
|
/// Detects hardware barcode scanners that emulate a keyboard.
|
||||||
|
///
|
||||||
|
/// Such scanners emit an entire code in a few milliseconds and terminate it
|
||||||
|
/// with Enter. We buffer raw key events and only treat the buffer as a scan
|
||||||
|
/// when the characters arrived faster than a human could type — that way the
|
||||||
|
/// cashier can still type into the same field by hand.
|
||||||
|
class BarcodeService {
|
||||||
|
BarcodeService({this.onScan, this.onManualKey});
|
||||||
|
|
||||||
|
final void Function(String code)? onScan;
|
||||||
|
final VoidCallback? onManualKey;
|
||||||
|
|
||||||
|
final StringBuffer _buffer = StringBuffer();
|
||||||
|
DateTime? _lastKeyAt;
|
||||||
|
Timer? _flushTimer;
|
||||||
|
|
||||||
|
bool _attached = false;
|
||||||
|
|
||||||
|
void attach() {
|
||||||
|
if (_attached) return;
|
||||||
|
HardwareKeyboard.instance.addHandler(_handleKey);
|
||||||
|
_attached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void detach() {
|
||||||
|
if (!_attached) return;
|
||||||
|
HardwareKeyboard.instance.removeHandler(_handleKey);
|
||||||
|
_flushTimer?.cancel();
|
||||||
|
_attached = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleKey(KeyEvent event) {
|
||||||
|
if (event is! KeyDownEvent) return false;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final gap = _lastKeyAt == null
|
||||||
|
? Duration.zero
|
||||||
|
: now.difference(_lastKeyAt!);
|
||||||
|
_lastKeyAt = now;
|
||||||
|
|
||||||
|
// A long pause means a new entry started; discard whatever was buffered.
|
||||||
|
if (gap > AppConstants.barcodeScanTimeout) {
|
||||||
|
_buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.logicalKey == LogicalKeyboardKey.enter ||
|
||||||
|
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
|
||||||
|
return _flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
final char = event.character;
|
||||||
|
if (char == null || char.trim().isEmpty) return false;
|
||||||
|
if (!RegExp(r'^[0-9A-Za-z\-]$').hasMatch(char)) return false;
|
||||||
|
|
||||||
|
_buffer.write(char);
|
||||||
|
_scheduleFlush();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Some scanners are not configured to send a terminating Enter, so we also
|
||||||
|
/// flush on a short idle window.
|
||||||
|
void _scheduleFlush() {
|
||||||
|
_flushTimer?.cancel();
|
||||||
|
_flushTimer = Timer(
|
||||||
|
AppConstants.barcodeScanTimeout * 2,
|
||||||
|
() => _flush(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _flush() {
|
||||||
|
_flushTimer?.cancel();
|
||||||
|
final code = _buffer.toString().trim();
|
||||||
|
_buffer.clear();
|
||||||
|
|
||||||
|
if (code.length >= AppConstants.minBarcodeLength) {
|
||||||
|
onScan?.call(code);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.isNotEmpty) onManualKey?.call();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() => detach();
|
||||||
|
}
|
||||||
335
lib/core/services/receipt_service.dart
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:pdf/pdf.dart';
|
||||||
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
|
||||||
|
import '../../domain/entities/transaction.dart';
|
||||||
|
import '../constants/app_constants.dart';
|
||||||
|
import '../utils/formatters.dart';
|
||||||
|
|
||||||
|
/// Builds and prints an 80mm thermal GST invoice.
|
||||||
|
class ReceiptService {
|
||||||
|
ReceiptService._();
|
||||||
|
|
||||||
|
static final ReceiptService instance = ReceiptService._();
|
||||||
|
|
||||||
|
/// 80mm roll with a small safety margin.
|
||||||
|
static const double _rollWidth = 78 * PdfPageFormat.mm;
|
||||||
|
|
||||||
|
Future<Uint8List> build(SaleTransaction txn) async {
|
||||||
|
final doc = pw.Document(title: txn.invoiceNumber);
|
||||||
|
final font = await PdfGoogleFonts.interRegular();
|
||||||
|
final bold = await PdfGoogleFonts.interSemiBold();
|
||||||
|
|
||||||
|
final cart = txn.cart;
|
||||||
|
|
||||||
|
doc.addPage(
|
||||||
|
pw.Page(
|
||||||
|
pageFormat: PdfPageFormat(
|
||||||
|
_rollWidth,
|
||||||
|
double.infinity,
|
||||||
|
marginAll: 6 * PdfPageFormat.mm,
|
||||||
|
),
|
||||||
|
theme: pw.ThemeData.withFont(base: font, bold: bold),
|
||||||
|
build: (context) => pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_header(txn),
|
||||||
|
_divider(),
|
||||||
|
_meta(txn),
|
||||||
|
_divider(),
|
||||||
|
_itemsTable(txn),
|
||||||
|
_divider(),
|
||||||
|
_totals(txn),
|
||||||
|
_divider(),
|
||||||
|
_taxSummary(txn),
|
||||||
|
_divider(),
|
||||||
|
_payments(txn),
|
||||||
|
if (cart.customer != null) ...[
|
||||||
|
_divider(),
|
||||||
|
_loyalty(txn),
|
||||||
|
],
|
||||||
|
pw.SizedBox(height: 8),
|
||||||
|
_footer(txn),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Sections
|
||||||
|
pw.Widget _header(SaleTransaction txn) => pw.Column(children: [
|
||||||
|
pw.Text(
|
||||||
|
AppConstants.storeName.toUpperCase(),
|
||||||
|
style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold),
|
||||||
|
textAlign: pw.TextAlign.center,
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 2),
|
||||||
|
pw.Text(
|
||||||
|
AppConstants.storeAddress,
|
||||||
|
style: const pw.TextStyle(fontSize: 7),
|
||||||
|
textAlign: pw.TextAlign.center,
|
||||||
|
),
|
||||||
|
pw.Text(
|
||||||
|
'GSTIN: ${AppConstants.storeGstin} | ${AppConstants.storePhone}',
|
||||||
|
style: const pw.TextStyle(fontSize: 7),
|
||||||
|
textAlign: pw.TextAlign.center,
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
pw.Text(
|
||||||
|
'TAX INVOICE',
|
||||||
|
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
pw.Widget _meta(SaleTransaction txn) {
|
||||||
|
final c = txn.customer;
|
||||||
|
return pw.Column(children: [
|
||||||
|
_row('Invoice', txn.invoiceNumber),
|
||||||
|
_row('Date', Formatters.receiptStamp(txn.createdAt)),
|
||||||
|
_row('Cashier', txn.cashierName),
|
||||||
|
_row('Terminal', txn.terminalId),
|
||||||
|
_row('Customer', c == null ? 'Walk-in' : c.name),
|
||||||
|
if (c != null) _row('Mobile', Formatters.mobile(c.mobile)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pw.Widget _itemsTable(SaleTransaction txn) {
|
||||||
|
return pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
pw.Row(children: [
|
||||||
|
pw.Expanded(flex: 5, child: _th('Item')),
|
||||||
|
pw.Expanded(flex: 2, child: _th('Qty', align: pw.TextAlign.center)),
|
||||||
|
pw.Expanded(flex: 3, child: _th('Rate', align: pw.TextAlign.right)),
|
||||||
|
pw.Expanded(flex: 3, child: _th('Amt', align: pw.TextAlign.right)),
|
||||||
|
]),
|
||||||
|
pw.SizedBox(height: 2),
|
||||||
|
...txn.cart.lines.map((line) => pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.symmetric(vertical: 1.5),
|
||||||
|
child: pw.Column(children: [
|
||||||
|
pw.Row(children: [
|
||||||
|
pw.Expanded(flex: 5, child: _td(line.product.name)),
|
||||||
|
pw.Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _td(
|
||||||
|
_qty(line.quantity),
|
||||||
|
align: pw.TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: _td(
|
||||||
|
line.product.price.toStringAsFixed(2),
|
||||||
|
align: pw.TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: _td(
|
||||||
|
line.payable.toStringAsFixed(2),
|
||||||
|
align: pw.TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
if (line.discount.isActive)
|
||||||
|
pw.Row(children: [
|
||||||
|
pw.Expanded(
|
||||||
|
child: _td(
|
||||||
|
' ${line.discount.label} '
|
||||||
|
'-${line.discountAmount.toStringAsFixed(2)}',
|
||||||
|
size: 6.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pw.Widget _totals(SaleTransaction txn) {
|
||||||
|
final cart = txn.cart;
|
||||||
|
return pw.Column(children: [
|
||||||
|
_row('Items', '${cart.lineCount} (Qty ${_qty(cart.totalQuantity)})'),
|
||||||
|
_row('Subtotal', cart.subtotal.toStringAsFixed(2)),
|
||||||
|
if (cart.membershipDiscountAmount > 0)
|
||||||
|
_row(
|
||||||
|
'${cart.customer!.tier.label} discount',
|
||||||
|
'-${cart.membershipDiscountAmount.toStringAsFixed(2)}',
|
||||||
|
),
|
||||||
|
if (cart.manualBillDiscountAmount > 0)
|
||||||
|
_row('Discount', '-${cart.manualBillDiscountAmount.toStringAsFixed(2)}'),
|
||||||
|
if (cart.loyaltyRedemptionValue > 0)
|
||||||
|
_row(
|
||||||
|
'Points redeemed (${cart.pointsRedeemed})',
|
||||||
|
'-${cart.loyaltyRedemptionValue.toStringAsFixed(2)}',
|
||||||
|
),
|
||||||
|
_row('Taxable value', cart.taxableAmount.toStringAsFixed(2)),
|
||||||
|
_row('CGST', cart.cgst.toStringAsFixed(2)),
|
||||||
|
_row('SGST', cart.sgst.toStringAsFixed(2)),
|
||||||
|
if (cart.roundOff != 0)
|
||||||
|
_row('Round off', cart.roundOff.toStringAsFixed(2)),
|
||||||
|
pw.SizedBox(height: 3),
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text('TOTAL',
|
||||||
|
style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||||
|
pw.Text(
|
||||||
|
'${AppConstants.currencySymbol}${txn.total.toStringAsFixed(2)}',
|
||||||
|
style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (cart.totalSavings > 0) ...[
|
||||||
|
pw.SizedBox(height: 2),
|
||||||
|
pw.Text(
|
||||||
|
'You saved ${AppConstants.currencySymbol}'
|
||||||
|
'${cart.totalSavings.toStringAsFixed(2)} on this bill',
|
||||||
|
style: pw.TextStyle(fontSize: 7.5, fontWeight: pw.FontWeight.bold),
|
||||||
|
textAlign: pw.TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pw.Widget _taxSummary(SaleTransaction txn) {
|
||||||
|
final breakdown = txn.cart.taxBreakdown.entries
|
||||||
|
.where((e) => e.value > 0)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
|
if (breakdown.isEmpty) {
|
||||||
|
return _td('All items zero-rated', size: 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pw.Column(children: [
|
||||||
|
_th('GST Summary'),
|
||||||
|
...breakdown.map((e) => _row(
|
||||||
|
'GST @ ${(e.key * 100).toStringAsFixed(0)}%',
|
||||||
|
e.value.toStringAsFixed(2),
|
||||||
|
size: 7,
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pw.Widget _payments(SaleTransaction txn) => pw.Column(children: [
|
||||||
|
...txn.payments.map((p) => _row(
|
||||||
|
p.method.label +
|
||||||
|
(p.reference != null ? ' (${p.reference})' : ''),
|
||||||
|
p.amount.toStringAsFixed(2),
|
||||||
|
)),
|
||||||
|
if (txn.changeDue > 0)
|
||||||
|
_row('Change returned', txn.changeDue.toStringAsFixed(2)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
pw.Widget _loyalty(SaleTransaction txn) {
|
||||||
|
final c = txn.customer!;
|
||||||
|
final balance = c.loyaltyPoints - txn.pointsRedeemed + txn.pointsEarned;
|
||||||
|
return pw.Column(children: [
|
||||||
|
_row('Points earned', '+${txn.pointsEarned}'),
|
||||||
|
if (txn.pointsRedeemed > 0)
|
||||||
|
_row('Points redeemed', '-${txn.pointsRedeemed}'),
|
||||||
|
_row('Points balance', '$balance'),
|
||||||
|
_row('Membership', c.tier.label),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pw.Widget _footer(SaleTransaction txn) => pw.Column(children: [
|
||||||
|
pw.BarcodeWidget(
|
||||||
|
barcode: pw.Barcode.code128(),
|
||||||
|
data: txn.invoiceNumber,
|
||||||
|
width: 140,
|
||||||
|
height: 34,
|
||||||
|
drawText: false,
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
pw.Text(txn.invoiceNumber, style: const pw.TextStyle(fontSize: 7)),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
pw.Text('Thank you for shopping with us!',
|
||||||
|
style: pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold)),
|
||||||
|
pw.Text('Goods once sold are exchangeable within 7 days with this bill.',
|
||||||
|
style: const pw.TextStyle(fontSize: 6),
|
||||||
|
textAlign: pw.TextAlign.center),
|
||||||
|
pw.SizedBox(height: 2),
|
||||||
|
pw.Text('Powered by Nearle POS', style: const pw.TextStyle(fontSize: 6)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- Helpers
|
||||||
|
String _qty(double q) =>
|
||||||
|
q % 1 == 0 ? q.toStringAsFixed(0) : q.toStringAsFixed(3);
|
||||||
|
|
||||||
|
pw.Widget _divider() => pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: pw.Divider(height: 0.5, borderStyle: pw.BorderStyle.dashed),
|
||||||
|
);
|
||||||
|
|
||||||
|
pw.Widget _th(String text, {pw.TextAlign align = pw.TextAlign.left}) =>
|
||||||
|
pw.Text(text,
|
||||||
|
textAlign: align,
|
||||||
|
style: pw.TextStyle(fontSize: 7.5, fontWeight: pw.FontWeight.bold));
|
||||||
|
|
||||||
|
pw.Widget _td(String text,
|
||||||
|
{pw.TextAlign align = pw.TextAlign.left, double size = 7.5}) =>
|
||||||
|
pw.Text(text, textAlign: align, style: pw.TextStyle(fontSize: size));
|
||||||
|
|
||||||
|
pw.Widget _row(String label, String value, {double size = 7.5}) => pw.Padding(
|
||||||
|
padding: const pw.EdgeInsets.symmetric(vertical: 0.8),
|
||||||
|
child: pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text(label, style: pw.TextStyle(fontSize: size)),
|
||||||
|
pw.Text(value, style: pw.TextStyle(fontSize: size)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --------------------------------------------------------- Printing / IO
|
||||||
|
/// Silent print to the default roll printer — no OS dialog, so the cashier
|
||||||
|
/// is never blocked between sales.
|
||||||
|
Future<bool> printDirect(SaleTransaction txn) async {
|
||||||
|
try {
|
||||||
|
final bytes = await build(txn);
|
||||||
|
final printers = await Printing.listPrinters();
|
||||||
|
final target = printers.where((p) => p.isDefault).firstOrNull ??
|
||||||
|
(printers.isNotEmpty ? printers.first : null);
|
||||||
|
|
||||||
|
if (target == null) return false;
|
||||||
|
|
||||||
|
return await Printing.directPrintPdf(
|
||||||
|
printer: target,
|
||||||
|
onLayout: (_) async => bytes,
|
||||||
|
name: txn.invoiceNumber,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Direct print failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Falls back to the system print preview.
|
||||||
|
Future<void> printWithDialog(SaleTransaction txn) async {
|
||||||
|
final bytes = await build(txn);
|
||||||
|
await Printing.layoutPdf(
|
||||||
|
onLayout: (_) async => bytes,
|
||||||
|
name: txn.invoiceNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> share(SaleTransaction txn) async {
|
||||||
|
final bytes = await build(txn);
|
||||||
|
await Printing.sharePdf(bytes: bytes, filename: '${txn.invoiceNumber}.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the cash drawer via the ESC/POS kick pulse on pin 2.
|
||||||
|
Future<void> openCashDrawer() async {
|
||||||
|
// ESC p m t1 t2 — sent to the receipt printer's serial passthrough.
|
||||||
|
// Wired up here as a no-op placeholder for the concrete driver.
|
||||||
|
debugPrint('Cash drawer kick: ESC p 0 25 250');
|
||||||
|
}
|
||||||
|
}
|
||||||
62
lib/core/services/sound_service.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../constants/asset_paths.dart';
|
||||||
|
|
||||||
|
/// Audible feedback for scanner billing.
|
||||||
|
///
|
||||||
|
/// A cashier scanning at speed watches the customer, not the screen, so the
|
||||||
|
/// beep is the primary confirmation that an item registered.
|
||||||
|
class SoundService {
|
||||||
|
SoundService._();
|
||||||
|
|
||||||
|
static final SoundService instance = SoundService._();
|
||||||
|
|
||||||
|
final AudioPlayer _player = AudioPlayer(playerId: 'nearle_pos_sfx');
|
||||||
|
bool _enabled = true;
|
||||||
|
|
||||||
|
bool get enabled => _enabled;
|
||||||
|
set enabled(bool value) => _enabled = value;
|
||||||
|
|
||||||
|
Future<void> preload() async {
|
||||||
|
try {
|
||||||
|
await _player.setReleaseMode(ReleaseMode.stop);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('SoundService preload failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> scanSuccess() => _play(AssetPaths.beepSuccess, haptic: true);
|
||||||
|
|
||||||
|
Future<void> scanError() => _play(AssetPaths.beepError, heavy: true);
|
||||||
|
|
||||||
|
Future<void> saleComplete() => _play(AssetPaths.chargeComplete);
|
||||||
|
|
||||||
|
Future<void> _play(
|
||||||
|
String asset, {
|
||||||
|
bool haptic = false,
|
||||||
|
bool heavy = false,
|
||||||
|
}) async {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
// Haptics matter on tablets where the speaker may be muted on the floor.
|
||||||
|
if (heavy) {
|
||||||
|
unawaited(HapticFeedback.heavyImpact());
|
||||||
|
} else if (haptic) {
|
||||||
|
unawaited(HapticFeedback.selectionClick());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _player.stop();
|
||||||
|
await _player.play(AssetSource(asset.replaceFirst('assets/', '')));
|
||||||
|
} catch (e) {
|
||||||
|
// Never let a missing sound file break the billing flow.
|
||||||
|
debugPrint('SoundService play failed for $asset: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() => _player.dispose();
|
||||||
|
}
|
||||||
84
lib/core/theme/app_colors.dart
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Central colour palette for Nearle POS.
|
||||||
|
///
|
||||||
|
/// Everything is derived from the brand primary `#662582` so that a single
|
||||||
|
/// change here re-skins the entire application.
|
||||||
|
class AppColors {
|
||||||
|
const AppColors._();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Brand
|
||||||
|
static const Color primary = Color(0xFF662582);
|
||||||
|
static const Color primaryDark = Color(0xFF4B1A60);
|
||||||
|
static const Color primaryLight = Color(0xFF8B4BA6);
|
||||||
|
static const Color primarySurface = Color(0xFFF4EDF7);
|
||||||
|
static const Color primaryBorder = Color(0xFFE3D3EC);
|
||||||
|
|
||||||
|
static const MaterialColor primarySwatch = MaterialColor(0xFF662582, {
|
||||||
|
50: Color(0xFFF4EDF7),
|
||||||
|
100: Color(0xFFE3D3EC),
|
||||||
|
200: Color(0xFFCDB1DC),
|
||||||
|
300: Color(0xFFB68FCC),
|
||||||
|
400: Color(0xFFA475C0),
|
||||||
|
500: Color(0xFF662582),
|
||||||
|
600: Color(0xFF5C2175),
|
||||||
|
700: Color(0xFF4B1A60),
|
||||||
|
800: Color(0xFF3B144B),
|
||||||
|
900: Color(0xFF2A0E36),
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- Neutrals
|
||||||
|
static const Color background = Color(0xFFF7F7F9);
|
||||||
|
static const Color surface = Color(0xFFFFFFFF);
|
||||||
|
static const Color surfaceAlt = Color(0xFFFAFAFC);
|
||||||
|
static const Color border = Color(0xFFE8E8EE);
|
||||||
|
static const Color divider = Color(0xFFEFEFF4);
|
||||||
|
|
||||||
|
static const Color textPrimary = Color(0xFF17131C);
|
||||||
|
static const Color textSecondary = Color(0xFF6B6577);
|
||||||
|
static const Color textTertiary = Color(0xFF9A94A6);
|
||||||
|
static const Color textOnPrimary = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Semantic
|
||||||
|
static const Color success = Color(0xFF16A34A);
|
||||||
|
static const Color successSurface = Color(0xFFEAF7EF);
|
||||||
|
static const Color warning = Color(0xFFF59E0B);
|
||||||
|
static const Color warningSurface = Color(0xFFFEF6E7);
|
||||||
|
static const Color danger = Color(0xFFDC2626);
|
||||||
|
static const Color dangerSurface = Color(0xFFFDECEC);
|
||||||
|
static const Color info = Color(0xFF2563EB);
|
||||||
|
static const Color infoSurface = Color(0xFFEAF0FE);
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Membership tiers
|
||||||
|
static const Color tierBronze = Color(0xFFB08D57);
|
||||||
|
static const Color tierSilver = Color(0xFF8E96A3);
|
||||||
|
static const Color tierGold = Color(0xFFD4A017);
|
||||||
|
static const Color tierPlatinum = Color(0xFF4C5A6B);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Effects
|
||||||
|
static const Color glassTint = Color(0x14662582);
|
||||||
|
|
||||||
|
static const List<BoxShadow> shadowSm = [
|
||||||
|
BoxShadow(color: Color(0x0D17131C), blurRadius: 6, offset: Offset(0, 2)),
|
||||||
|
];
|
||||||
|
|
||||||
|
static const List<BoxShadow> shadowMd = [
|
||||||
|
BoxShadow(color: Color(0x1417131C), blurRadius: 16, offset: Offset(0, 6)),
|
||||||
|
];
|
||||||
|
|
||||||
|
static const List<BoxShadow> shadowLg = [
|
||||||
|
BoxShadow(color: Color(0x1F17131C), blurRadius: 32, offset: Offset(0, 12)),
|
||||||
|
];
|
||||||
|
|
||||||
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF7A2E9A), Color(0xFF4B1A60)],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient glassGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0x40FFFFFF), Color(0x0DFFFFFF)],
|
||||||
|
);
|
||||||
|
}
|
||||||
83
lib/core/theme/app_dimens.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Spacing scale — 4pt grid.
|
||||||
|
class AppSpacing {
|
||||||
|
const AppSpacing._();
|
||||||
|
|
||||||
|
static const double xxs = 2;
|
||||||
|
static const double xs = 4;
|
||||||
|
static const double sm = 8;
|
||||||
|
static const double md = 12;
|
||||||
|
static const double lg = 16;
|
||||||
|
static const double xl = 20;
|
||||||
|
static const double xxl = 24;
|
||||||
|
static const double xxxl = 32;
|
||||||
|
static const double huge = 40;
|
||||||
|
static const double giant = 56;
|
||||||
|
|
||||||
|
static const EdgeInsets pageDesktop = EdgeInsets.all(xxl);
|
||||||
|
static const EdgeInsets pageTablet = EdgeInsets.all(lg);
|
||||||
|
static const EdgeInsets card = EdgeInsets.all(lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corner radii — brand standard is 16.
|
||||||
|
class AppRadius {
|
||||||
|
const AppRadius._();
|
||||||
|
|
||||||
|
static const double xs = 6;
|
||||||
|
static const double sm = 10;
|
||||||
|
static const double md = 12;
|
||||||
|
static const double lg = 16;
|
||||||
|
static const double xl = 20;
|
||||||
|
static const double xxl = 28;
|
||||||
|
static const double pill = 999;
|
||||||
|
|
||||||
|
static const BorderRadius brXs = BorderRadius.all(Radius.circular(xs));
|
||||||
|
static const BorderRadius brSm = BorderRadius.all(Radius.circular(sm));
|
||||||
|
static const BorderRadius brMd = BorderRadius.all(Radius.circular(md));
|
||||||
|
static const BorderRadius brLg = BorderRadius.all(Radius.circular(lg));
|
||||||
|
static const BorderRadius brXl = BorderRadius.all(Radius.circular(xl));
|
||||||
|
static const BorderRadius brPill = BorderRadius.all(Radius.circular(pill));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimum sizes tuned for finger targets on tablets.
|
||||||
|
class AppSizes {
|
||||||
|
const AppSizes._();
|
||||||
|
|
||||||
|
static const double touchTarget = 48;
|
||||||
|
static const double buttonHeight = 52;
|
||||||
|
static const double buttonHeightLarge = 64;
|
||||||
|
static const double inputHeight = 56;
|
||||||
|
static const double headerHeight = 76;
|
||||||
|
static const double customerBarHeight = 72;
|
||||||
|
static const double navItemHeight = 46;
|
||||||
|
static const double productCardAspect = 0.86;
|
||||||
|
static const double keypadKeySize = 76;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Motion durations & curves.
|
||||||
|
class AppMotion {
|
||||||
|
const AppMotion._();
|
||||||
|
|
||||||
|
static const Duration instant = Duration(milliseconds: 90);
|
||||||
|
static const Duration fast = Duration(milliseconds: 160);
|
||||||
|
static const Duration normal = Duration(milliseconds: 240);
|
||||||
|
static const Duration slow = Duration(milliseconds: 400);
|
||||||
|
|
||||||
|
static const Curve emphasized = Curves.easeOutCubic;
|
||||||
|
static const Curve standard = Curves.easeInOut;
|
||||||
|
static const Curve bouncy = Curves.easeOutBack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Responsive breakpoints.
|
||||||
|
class AppBreakpoints {
|
||||||
|
const AppBreakpoints._();
|
||||||
|
|
||||||
|
static const double tabletPortrait = 920;
|
||||||
|
static const double tabletLandscape = 1300;
|
||||||
|
static const double desktop = 1650;
|
||||||
|
|
||||||
|
static bool isCompact(double w) => w < tabletPortrait;
|
||||||
|
static bool isMedium(double w) => w >= tabletPortrait && w < desktop;
|
||||||
|
static bool isExpanded(double w) => w >= desktop;
|
||||||
|
}
|
||||||
98
lib/core/theme/app_layout.dart
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// How the left navigation should render at the current width.
|
||||||
|
enum SidebarMode {
|
||||||
|
/// Off-canvas; reachable through the page-header menu button.
|
||||||
|
drawer,
|
||||||
|
|
||||||
|
/// Icons only, 84px.
|
||||||
|
rail,
|
||||||
|
|
||||||
|
/// Icons plus labels and section headers, 252px.
|
||||||
|
expanded,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the bill should render at the current width.
|
||||||
|
enum BillingMode {
|
||||||
|
/// Bottom sheet, opened from the floating cart button.
|
||||||
|
sheet,
|
||||||
|
|
||||||
|
/// Docked column on the right.
|
||||||
|
docked,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves every layout decision for the POS dashboard from a single width.
|
||||||
|
///
|
||||||
|
/// Keeping this in one place means the sidebar, product grid and billing panel
|
||||||
|
/// can never disagree about which breakpoint they are in.
|
||||||
|
class PosLayout {
|
||||||
|
const PosLayout({
|
||||||
|
required this.sidebar,
|
||||||
|
required this.billing,
|
||||||
|
required this.billingWidth,
|
||||||
|
required this.gridTileExtent,
|
||||||
|
required this.contentPadding,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SidebarMode sidebar;
|
||||||
|
final BillingMode billing;
|
||||||
|
final double billingWidth;
|
||||||
|
|
||||||
|
/// Maximum width of a product card; the grid fits as many as will fit.
|
||||||
|
final double gridTileExtent;
|
||||||
|
|
||||||
|
final double contentPadding;
|
||||||
|
|
||||||
|
static const double railWidth = 84;
|
||||||
|
static const double expandedWidth = 252;
|
||||||
|
|
||||||
|
/// Below this the sidebar goes off-canvas.
|
||||||
|
static const double drawerBelow = 920;
|
||||||
|
|
||||||
|
/// Below this the sidebar is icons-only.
|
||||||
|
static const double railBelow = 1300;
|
||||||
|
|
||||||
|
/// Below this the bill becomes a bottom sheet.
|
||||||
|
static const double sheetBelow = 1120;
|
||||||
|
|
||||||
|
/// Above this we have room for a wider bill and larger cards.
|
||||||
|
static const double wideAbove = 1650;
|
||||||
|
|
||||||
|
factory PosLayout.of(BuildContext context) =>
|
||||||
|
PosLayout.forWidth(MediaQuery.sizeOf(context).width);
|
||||||
|
|
||||||
|
factory PosLayout.forWidth(double width) {
|
||||||
|
final sidebar = width < drawerBelow
|
||||||
|
? SidebarMode.drawer
|
||||||
|
: (width < railBelow ? SidebarMode.rail : SidebarMode.expanded);
|
||||||
|
|
||||||
|
final billing =
|
||||||
|
width < sheetBelow ? BillingMode.sheet : BillingMode.docked;
|
||||||
|
|
||||||
|
final billingWidth = width >= wideAbove ? 440.0 : 380.0;
|
||||||
|
|
||||||
|
// Cards stay finger-sized on tablets and grow a little on large desktops.
|
||||||
|
final tile = width < drawerBelow
|
||||||
|
? 168.0
|
||||||
|
: (width >= wideAbove ? 208.0 : 186.0);
|
||||||
|
|
||||||
|
final padding = width < drawerBelow ? 16.0 : 24.0;
|
||||||
|
|
||||||
|
return PosLayout(
|
||||||
|
sidebar: sidebar,
|
||||||
|
billing: billing,
|
||||||
|
billingWidth: billingWidth,
|
||||||
|
gridTileExtent: tile,
|
||||||
|
contentPadding: padding,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double get sidebarWidth => switch (sidebar) {
|
||||||
|
SidebarMode.drawer => 0,
|
||||||
|
SidebarMode.rail => railWidth,
|
||||||
|
SidebarMode.expanded => expandedWidth,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool get sidebarIsDrawer => sidebar == SidebarMode.drawer;
|
||||||
|
bool get billingIsSheet => billing == BillingMode.sheet;
|
||||||
|
}
|
||||||
162
lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'app_colors.dart';
|
||||||
|
import 'app_dimens.dart';
|
||||||
|
import 'app_typography.dart';
|
||||||
|
|
||||||
|
/// Builds the single source of truth [ThemeData] for Nearle POS.
|
||||||
|
class AppTheme {
|
||||||
|
const AppTheme._();
|
||||||
|
|
||||||
|
static ThemeData get light {
|
||||||
|
final colorScheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: AppColors.primary,
|
||||||
|
primary: AppColors.primary,
|
||||||
|
onPrimary: AppColors.textOnPrimary,
|
||||||
|
secondary: AppColors.primaryLight,
|
||||||
|
surface: AppColors.surface,
|
||||||
|
onSurface: AppColors.textPrimary,
|
||||||
|
error: AppColors.danger,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
|
textTheme: AppTypography.textTheme,
|
||||||
|
splashFactory: InkSparkle.splashFactory,
|
||||||
|
visualDensity: VisualDensity.standard,
|
||||||
|
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
foregroundColor: AppColors.textOnPrimary,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
toolbarHeight: AppSizes.headerHeight,
|
||||||
|
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||||
|
titleTextStyle: AppTypography.textTheme.titleLarge
|
||||||
|
?.copyWith(color: AppColors.textOnPrimary),
|
||||||
|
),
|
||||||
|
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: AppColors.surface,
|
||||||
|
elevation: 0,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: AppRadius.brLg,
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
dividerTheme: const DividerThemeData(
|
||||||
|
color: AppColors.divider,
|
||||||
|
thickness: 1,
|
||||||
|
space: 1,
|
||||||
|
),
|
||||||
|
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
foregroundColor: AppColors.textOnPrimary,
|
||||||
|
disabledBackgroundColor: AppColors.border,
|
||||||
|
disabledForegroundColor: AppColors.textTertiary,
|
||||||
|
minimumSize: const Size(0, AppSizes.buttonHeight),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
|
||||||
|
elevation: 0,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brMd),
|
||||||
|
textStyle: AppTypography.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.primary,
|
||||||
|
minimumSize: const Size(0, AppSizes.buttonHeight),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
|
||||||
|
side: const BorderSide(color: AppColors.primaryBorder, width: 1.5),
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brMd),
|
||||||
|
textStyle: AppTypography.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.primary,
|
||||||
|
minimumSize: const Size(0, AppSizes.touchTarget),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brSm),
|
||||||
|
textStyle: AppTypography.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surface,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.lg,
|
||||||
|
vertical: AppSpacing.lg,
|
||||||
|
),
|
||||||
|
hintStyle: AppTypography.textTheme.bodyMedium
|
||||||
|
?.copyWith(color: AppColors.textTertiary),
|
||||||
|
labelStyle: AppTypography.textTheme.bodyMedium
|
||||||
|
?.copyWith(color: AppColors.textSecondary),
|
||||||
|
border: _inputBorder(AppColors.border),
|
||||||
|
enabledBorder: _inputBorder(AppColors.border),
|
||||||
|
focusedBorder: _inputBorder(AppColors.primary, width: 1.8),
|
||||||
|
errorBorder: _inputBorder(AppColors.danger),
|
||||||
|
focusedErrorBorder: _inputBorder(AppColors.danger, width: 1.8),
|
||||||
|
),
|
||||||
|
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
selectedColor: AppColors.primary,
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
labelStyle: AppTypography.textTheme.labelMedium,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.lg,
|
||||||
|
vertical: AppSpacing.md,
|
||||||
|
),
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brPill),
|
||||||
|
),
|
||||||
|
|
||||||
|
dialogTheme: DialogThemeData(
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
|
),
|
||||||
|
titleTextStyle: AppTypography.textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
backgroundColor: AppColors.textPrimary,
|
||||||
|
contentTextStyle: AppTypography.textTheme.bodyMedium
|
||||||
|
?.copyWith(color: Colors.white),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: AppRadius.brMd),
|
||||||
|
),
|
||||||
|
|
||||||
|
scrollbarTheme: ScrollbarThemeData(
|
||||||
|
thickness: WidgetStateProperty.all(6),
|
||||||
|
radius: const Radius.circular(AppRadius.pill),
|
||||||
|
thumbColor: WidgetStateProperty.all(AppColors.primaryBorder),
|
||||||
|
),
|
||||||
|
|
||||||
|
pageTransitionsTheme: const PageTransitionsTheme(builders: {
|
||||||
|
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static OutlineInputBorder _inputBorder(Color color, {double width = 1}) {
|
||||||
|
return OutlineInputBorder(
|
||||||
|
borderRadius: AppRadius.brMd,
|
||||||
|
borderSide: BorderSide(color: color, width: width),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
lib/core/theme/app_typography.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
import 'app_colors.dart';
|
||||||
|
|
||||||
|
/// Poppins-based type scale.
|
||||||
|
///
|
||||||
|
/// Poppins is geometric and runs slightly wide, so headings get negative
|
||||||
|
/// tracking to keep them compact in the sidebar and page header. POS screens
|
||||||
|
/// are read at arm's length, so body text starts at 15 rather than the
|
||||||
|
/// Material default of 14.
|
||||||
|
class AppTypography {
|
||||||
|
const AppTypography._();
|
||||||
|
|
||||||
|
static TextTheme get textTheme {
|
||||||
|
final base = GoogleFonts.poppinsTextTheme();
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
displayLarge: _s(base.displayLarge, 44, FontWeight.w700, -1.2),
|
||||||
|
displayMedium: _s(base.displayMedium, 36, FontWeight.w700, -1.0),
|
||||||
|
displaySmall: _s(base.displaySmall, 30, FontWeight.w700, -0.8),
|
||||||
|
|
||||||
|
headlineLarge: _s(base.headlineLarge, 28, FontWeight.w600, -0.7),
|
||||||
|
headlineMedium: _s(base.headlineMedium, 24, FontWeight.w600, -0.6),
|
||||||
|
headlineSmall: _s(base.headlineSmall, 20, FontWeight.w600, -0.4),
|
||||||
|
|
||||||
|
titleLarge: _s(base.titleLarge, 18, FontWeight.w600, -0.3),
|
||||||
|
titleMedium: _s(base.titleMedium, 15.5, FontWeight.w600, -0.2),
|
||||||
|
titleSmall: _s(base.titleSmall, 13.5, FontWeight.w600, -0.1),
|
||||||
|
|
||||||
|
bodyLarge: _s(base.bodyLarge, 15.5, FontWeight.w400, 0),
|
||||||
|
bodyMedium: _s(base.bodyMedium, 14.5, FontWeight.w400, 0),
|
||||||
|
bodySmall: _s(base.bodySmall, 12.5, FontWeight.w400, 0,
|
||||||
|
color: AppColors.textSecondary),
|
||||||
|
|
||||||
|
labelLarge: _s(base.labelLarge, 14.5, FontWeight.w600, 0),
|
||||||
|
labelMedium: _s(base.labelMedium, 12.5, FontWeight.w600, 0.1),
|
||||||
|
labelSmall: _s(base.labelSmall, 10.5, FontWeight.w600, 0.6,
|
||||||
|
color: AppColors.textTertiary),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static TextStyle _s(
|
||||||
|
TextStyle? base,
|
||||||
|
double size,
|
||||||
|
FontWeight weight,
|
||||||
|
double tracking, {
|
||||||
|
Color color = AppColors.textPrimary,
|
||||||
|
}) {
|
||||||
|
return (base ?? const TextStyle()).copyWith(
|
||||||
|
fontSize: size,
|
||||||
|
fontWeight: weight,
|
||||||
|
letterSpacing: tracking,
|
||||||
|
color: color,
|
||||||
|
height: 1.35,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tabular figures — essential so totals don't jitter as quantities change.
|
||||||
|
static TextStyle money(double size,
|
||||||
|
{FontWeight weight = FontWeight.w700, Color? color}) =>
|
||||||
|
GoogleFonts.poppins(
|
||||||
|
fontSize: size,
|
||||||
|
fontWeight: weight,
|
||||||
|
color: color ?? AppColors.textPrimary,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.15,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Fixed-pitch, used only for the receipt preview.
|
||||||
|
static TextStyle mono(double size, {Color? color}) => GoogleFonts.robotoMono(
|
||||||
|
fontSize: size,
|
||||||
|
color: color ?? AppColors.textTertiary,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Small uppercase caption for sidebar section dividers.
|
||||||
|
static TextStyle sectionLabel() => GoogleFonts.poppins(
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 1.0,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
);
|
||||||
|
}
|
||||||
59
lib/core/utils/extensions.dart
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_dimens.dart';
|
||||||
|
|
||||||
|
extension BuildContextX on BuildContext {
|
||||||
|
ThemeData get theme => Theme.of(this);
|
||||||
|
TextTheme get text => Theme.of(this).textTheme;
|
||||||
|
ColorScheme get colors => Theme.of(this).colorScheme;
|
||||||
|
|
||||||
|
Size get screen => MediaQuery.sizeOf(this);
|
||||||
|
double get screenWidth => MediaQuery.sizeOf(this).width;
|
||||||
|
double get screenHeight => MediaQuery.sizeOf(this).height;
|
||||||
|
|
||||||
|
bool get isCompact => AppBreakpoints.isCompact(screenWidth);
|
||||||
|
bool get isMedium => AppBreakpoints.isMedium(screenWidth);
|
||||||
|
bool get isExpanded => AppBreakpoints.isExpanded(screenWidth);
|
||||||
|
|
||||||
|
/// Picks a value appropriate to the current breakpoint.
|
||||||
|
T responsive<T>({required T compact, T? medium, required T expanded}) {
|
||||||
|
if (isCompact) return compact;
|
||||||
|
if (isExpanded) return expanded;
|
||||||
|
return medium ?? expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
void showSnack(String message, {Color? background}) {
|
||||||
|
ScaffoldMessenger.of(this)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: background,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DoubleX on double {
|
||||||
|
/// Rounds to two decimals to avoid floating point drift in money maths.
|
||||||
|
double get asMoney => (this * 100).roundToDouble() / 100;
|
||||||
|
|
||||||
|
/// Indian retail round-off to the nearest rupee.
|
||||||
|
double get roundedToRupee => roundToDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StringX on String {
|
||||||
|
String get capitalized =>
|
||||||
|
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
|
||||||
|
|
||||||
|
bool containsIgnoreCase(String other) =>
|
||||||
|
toLowerCase().contains(other.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
extension IterableX<T> on Iterable<T> {
|
||||||
|
T? firstWhereOrNull(bool Function(T) test) {
|
||||||
|
for (final element in this) {
|
||||||
|
if (test(element)) return element;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/core/utils/formatters.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../constants/app_constants.dart';
|
||||||
|
|
||||||
|
/// Currency, date and number formatting helpers.
|
||||||
|
class Formatters {
|
||||||
|
const Formatters._();
|
||||||
|
|
||||||
|
static final NumberFormat _currency = NumberFormat.currency(
|
||||||
|
locale: AppConstants.locale,
|
||||||
|
symbol: AppConstants.currencySymbol,
|
||||||
|
decimalDigits: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
static final NumberFormat _compactCurrency = NumberFormat.compactCurrency(
|
||||||
|
locale: AppConstants.locale,
|
||||||
|
symbol: AppConstants.currencySymbol,
|
||||||
|
decimalDigits: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
static final DateFormat _time = DateFormat('hh:mm a');
|
||||||
|
static final DateFormat _date = DateFormat('dd MMM yyyy');
|
||||||
|
static final DateFormat _dateTime = DateFormat('dd MMM yyyy, hh:mm a');
|
||||||
|
static final DateFormat _receiptStamp = DateFormat('dd/MM/yyyy HH:mm:ss');
|
||||||
|
|
||||||
|
static String money(num value) => _currency.format(value);
|
||||||
|
static String moneyCompact(num value) => _compactCurrency.format(value);
|
||||||
|
|
||||||
|
/// Amount without the symbol — used where the symbol is styled separately.
|
||||||
|
static String amount(num value) => value.toStringAsFixed(2);
|
||||||
|
|
||||||
|
static String time(DateTime dt) => _time.format(dt);
|
||||||
|
static String date(DateTime dt) => _date.format(dt);
|
||||||
|
static String dateTime(DateTime dt) => _dateTime.format(dt);
|
||||||
|
static String receiptStamp(DateTime dt) => _receiptStamp.format(dt);
|
||||||
|
|
||||||
|
static String percent(double fraction) =>
|
||||||
|
'${(fraction * 100).toStringAsFixed(fraction * 100 % 1 == 0 ? 0 : 1)}%';
|
||||||
|
|
||||||
|
/// `9876543210` -> `98765 43210`
|
||||||
|
static String mobile(String raw) {
|
||||||
|
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.length != 10) return raw;
|
||||||
|
return '${digits.substring(0, 5)} ${digits.substring(5)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Masks all but the last four digits for on-screen privacy.
|
||||||
|
static String maskedMobile(String raw) {
|
||||||
|
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.length < 4) return raw;
|
||||||
|
return '${'\u2022' * (digits.length - 4)}${digits.substring(digits.length - 4)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String initials(String name) {
|
||||||
|
final parts = name.trim().split(RegExp(r'\s+')).where((p) => p.isNotEmpty);
|
||||||
|
if (parts.isEmpty) return '?';
|
||||||
|
if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase();
|
||||||
|
return (parts.first.substring(0, 1) + parts.last.substring(0, 1))
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String invoiceNumber(int sequence, DateTime date) {
|
||||||
|
final y = date.year.toString().substring(2);
|
||||||
|
final m = date.month.toString().padLeft(2, '0');
|
||||||
|
return 'INV-$y$m-${sequence.toString().padLeft(5, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
56
lib/core/utils/validators.dart
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import '../constants/app_constants.dart';
|
||||||
|
|
||||||
|
/// Form field validators returning a message, or null when valid.
|
||||||
|
class Validators {
|
||||||
|
const Validators._();
|
||||||
|
|
||||||
|
static final RegExp _emailRe =
|
||||||
|
RegExp(r'^[\w.+-]+@([\w-]+\.)+[A-Za-z]{2,}$');
|
||||||
|
static final RegExp _mobileRe = RegExp(r'^[6-9]\d{9}$');
|
||||||
|
|
||||||
|
static String? mobile(String? value) {
|
||||||
|
final v = (value ?? '').replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (v.isEmpty) return 'Mobile number is required';
|
||||||
|
if (v.length != AppConstants.mobileNumberLength) {
|
||||||
|
return 'Enter all ${AppConstants.mobileNumberLength} digits';
|
||||||
|
}
|
||||||
|
if (!_mobileRe.hasMatch(v)) return 'Enter a valid Indian mobile number';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? name(String? value) {
|
||||||
|
final v = (value ?? '').trim();
|
||||||
|
if (v.isEmpty) return 'Customer name is required';
|
||||||
|
if (v.length < 2) return 'Name is too short';
|
||||||
|
if (v.length > 60) return 'Name is too long';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? emailOptional(String? value) {
|
||||||
|
final v = (value ?? '').trim();
|
||||||
|
if (v.isEmpty) return null;
|
||||||
|
if (!_emailRe.hasMatch(v)) return 'Enter a valid email address';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? dobOptional(DateTime? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (value.isAfter(now)) return 'Date of birth cannot be in the future';
|
||||||
|
if (now.year - value.year > 120) return 'Enter a valid date of birth';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? positiveAmount(String? value) {
|
||||||
|
final v = double.tryParse((value ?? '').trim());
|
||||||
|
if (v == null) return 'Enter a valid amount';
|
||||||
|
if (v <= 0) return 'Amount must be greater than zero';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isLikelyBarcode(String value) {
|
||||||
|
final v = value.trim();
|
||||||
|
return v.length >= AppConstants.minBarcodeLength &&
|
||||||
|
RegExp(r'^\d+$').hasMatch(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
lib/core/widgets/empty_state.dart
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../theme/app_dimens.dart';
|
||||||
|
|
||||||
|
class EmptyState extends StatelessWidget {
|
||||||
|
const EmptyState({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.message,
|
||||||
|
this.emoji = '🛒',
|
||||||
|
this.action,
|
||||||
|
this.compact = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String? message;
|
||||||
|
final String emoji;
|
||||||
|
final Widget? action;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.xxl),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: compact ? 64 : 96,
|
||||||
|
height: compact ? 64 : 96,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.primarySurface,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(emoji,
|
||||||
|
style: TextStyle(fontSize: compact ? 28 : 40)),
|
||||||
|
),
|
||||||
|
SizedBox(height: compact ? AppSpacing.lg : AppSpacing.xxl),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: compact ? 15 : 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (message != null) ...[
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
message!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (action != null) ...[
|
||||||
|
const SizedBox(height: AppSpacing.xxl),
|
||||||
|
action!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
lib/core/widgets/glass_card.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../theme/app_dimens.dart';
|
||||||
|
|
||||||
|
/// Glassmorphism-inspired surface used for elevated panels and product cards.
|
||||||
|
class GlassCard extends StatelessWidget {
|
||||||
|
const GlassCard({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding = AppSpacing.card,
|
||||||
|
this.radius = AppRadius.lg,
|
||||||
|
this.blur = 0,
|
||||||
|
this.tinted = false,
|
||||||
|
this.borderColor,
|
||||||
|
this.shadows,
|
||||||
|
this.onTap,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
final double radius;
|
||||||
|
|
||||||
|
/// Backdrop blur strength. Zero renders an opaque card, which is cheaper and
|
||||||
|
/// is the right default for the dense product grid.
|
||||||
|
final double blur;
|
||||||
|
|
||||||
|
final bool tinted;
|
||||||
|
final Color? borderColor;
|
||||||
|
final List<BoxShadow>? shadows;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final borderRadius = BorderRadius.circular(radius);
|
||||||
|
|
||||||
|
Widget surface = AnimatedContainer(
|
||||||
|
duration: AppMotion.fast,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
padding: padding,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tinted
|
||||||
|
? AppColors.primarySurface.withValues(alpha: blur > 0 ? 0.75 : 1)
|
||||||
|
: AppColors.surface.withValues(alpha: blur > 0 ? 0.78 : 1),
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
border: Border.all(
|
||||||
|
color: borderColor ??
|
||||||
|
(tinted ? AppColors.primaryBorder : AppColors.border),
|
||||||
|
),
|
||||||
|
boxShadow: shadows ?? AppColors.shadowSm,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (blur > 0) {
|
||||||
|
surface = ClipRRect(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||||
|
child: surface,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onTap == null) return surface;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
splashColor: AppColors.primary.withValues(alpha: 0.08),
|
||||||
|
highlightColor: AppColors.primary.withValues(alpha: 0.04),
|
||||||
|
child: surface,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
lib/core/widgets/numeric_keypad.dart
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../theme/app_dimens.dart';
|
||||||
|
|
||||||
|
/// Large on-screen keypad for mobile numbers and cash amounts.
|
||||||
|
class NumericKeypad extends StatelessWidget {
|
||||||
|
const NumericKeypad({
|
||||||
|
super.key,
|
||||||
|
required this.onKey,
|
||||||
|
required this.onBackspace,
|
||||||
|
this.onClear,
|
||||||
|
this.onSubmit,
|
||||||
|
this.submitLabel,
|
||||||
|
this.allowDecimal = false,
|
||||||
|
this.maxWidth = 360,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ValueChanged<String> onKey;
|
||||||
|
final VoidCallback onBackspace;
|
||||||
|
final VoidCallback? onClear;
|
||||||
|
final VoidCallback? onSubmit;
|
||||||
|
final String? submitLabel;
|
||||||
|
final bool allowDecimal;
|
||||||
|
final double maxWidth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final row in const [
|
||||||
|
['1', '2', '3'],
|
||||||
|
['4', '5', '6'],
|
||||||
|
['7', '8', '9'],
|
||||||
|
])
|
||||||
|
_row(row.map((d) => _digit(d)).toList()),
|
||||||
|
_row([
|
||||||
|
allowDecimal
|
||||||
|
? _digit('.')
|
||||||
|
: _action(
|
||||||
|
icon: Icons.clear_all_rounded,
|
||||||
|
onTap: onClear,
|
||||||
|
tone: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
_digit('0'),
|
||||||
|
_action(
|
||||||
|
icon: Icons.backspace_outlined,
|
||||||
|
onTap: onBackspace,
|
||||||
|
tone: AppColors.danger,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
if (onSubmit != null) ...[
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: AppSizes.buttonHeight,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: onSubmit,
|
||||||
|
icon: const Icon(Icons.check_rounded),
|
||||||
|
label: Text(submitLabel ?? 'Done'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(List<Widget> children) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < children.length; i++) ...[
|
||||||
|
Expanded(child: children[i]),
|
||||||
|
if (i != children.length - 1)
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _digit(String value) => _Key(
|
||||||
|
onTap: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
onKey(value);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _action({
|
||||||
|
required IconData icon,
|
||||||
|
required VoidCallback? onTap,
|
||||||
|
required Color tone,
|
||||||
|
}) =>
|
||||||
|
_Key(
|
||||||
|
onTap: onTap == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
onTap();
|
||||||
|
},
|
||||||
|
child: Icon(icon, size: 24, color: tone),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Key extends StatelessWidget {
|
||||||
|
const _Key({required this.child, this.onTap});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: AppSizes.keypadKeySize,
|
||||||
|
child: Material(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: AppRadius.brMd,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: AppRadius.brMd,
|
||||||
|
splashColor: AppColors.primary.withValues(alpha: 0.1),
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: AppRadius.brMd,
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
lib/core/widgets/primary_button.dart
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../theme/app_dimens.dart';
|
||||||
|
|
||||||
|
enum ButtonTone { primary, neutral, success, danger, ghost }
|
||||||
|
|
||||||
|
/// Large, touch-first action button with built-in busy state.
|
||||||
|
class PrimaryButton extends StatelessWidget {
|
||||||
|
const PrimaryButton({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
this.onPressed,
|
||||||
|
this.icon,
|
||||||
|
this.tone = ButtonTone.primary,
|
||||||
|
this.expanded = true,
|
||||||
|
this.large = false,
|
||||||
|
this.busy = false,
|
||||||
|
this.trailing,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final IconData? icon;
|
||||||
|
final ButtonTone tone;
|
||||||
|
final bool expanded;
|
||||||
|
final bool large;
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
|
/// Optional right-aligned widget, typically the bill total.
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final enabled = onPressed != null && !busy;
|
||||||
|
final (bg, fg, border) = _palette;
|
||||||
|
final height =
|
||||||
|
large ? AppSizes.buttonHeightLarge : AppSizes.buttonHeight;
|
||||||
|
|
||||||
|
final content = busy
|
||||||
|
? SizedBox(
|
||||||
|
height: 22,
|
||||||
|
width: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.4, color: fg),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: expanded ? MainAxisSize.max : MainAxisSize.min,
|
||||||
|
mainAxisAlignment: trailing != null
|
||||||
|
? MainAxisAlignment.spaceBetween
|
||||||
|
: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, size: large ? 24 : 20, color: fg),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
],
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: fg,
|
||||||
|
fontSize: large ? 19 : 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (trailing != null) trailing!,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: expanded ? double.infinity : null,
|
||||||
|
height: height,
|
||||||
|
child: Material(
|
||||||
|
color: enabled ? bg : AppColors.border,
|
||||||
|
borderRadius: AppRadius.brMd,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: enabled ? onPressed : null,
|
||||||
|
borderRadius: AppRadius.brMd,
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: large ? AppSpacing.xxl : AppSpacing.xl,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: AppRadius.brMd,
|
||||||
|
border: border == null
|
||||||
|
? null
|
||||||
|
: Border.all(color: border, width: 1.5),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: DefaultTextStyle.merge(
|
||||||
|
style: TextStyle(color: enabled ? fg : AppColors.textTertiary),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(Color, Color, Color?) get _palette => switch (tone) {
|
||||||
|
ButtonTone.primary => (
|
||||||
|
AppColors.primary,
|
||||||
|
AppColors.textOnPrimary,
|
||||||
|
null
|
||||||
|
),
|
||||||
|
ButtonTone.success => (AppColors.success, Colors.white, null),
|
||||||
|
ButtonTone.danger => (AppColors.danger, Colors.white, null),
|
||||||
|
ButtonTone.neutral => (
|
||||||
|
AppColors.surface,
|
||||||
|
AppColors.textPrimary,
|
||||||
|
AppColors.border
|
||||||
|
),
|
||||||
|
ButtonTone.ghost => (
|
||||||
|
AppColors.primarySurface,
|
||||||
|
AppColors.primary,
|
||||||
|
AppColors.primaryBorder
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
95
lib/core/widgets/status_pill.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../theme/app_dimens.dart';
|
||||||
|
import '../../domain/entities/customer.dart';
|
||||||
|
|
||||||
|
/// Compact labelled badge — stock states, tiers, live indicator.
|
||||||
|
class StatusPill extends StatelessWidget {
|
||||||
|
const StatusPill({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
this.color = AppColors.primary,
|
||||||
|
this.background,
|
||||||
|
this.icon,
|
||||||
|
this.dense = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final Color? background;
|
||||||
|
final IconData? icon;
|
||||||
|
final bool dense;
|
||||||
|
|
||||||
|
factory StatusPill.tier(MembershipTier tier, {bool dense = false}) {
|
||||||
|
final color = switch (tier) {
|
||||||
|
MembershipTier.bronze => AppColors.tierBronze,
|
||||||
|
MembershipTier.silver => AppColors.tierSilver,
|
||||||
|
MembershipTier.gold => AppColors.tierGold,
|
||||||
|
MembershipTier.platinum => AppColors.tierPlatinum,
|
||||||
|
};
|
||||||
|
return StatusPill(
|
||||||
|
label: tier.label.toUpperCase(),
|
||||||
|
color: color,
|
||||||
|
background: color.withValues(alpha: 0.12),
|
||||||
|
dense: dense,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory StatusPill.stock(double stock, {required int lowThreshold}) {
|
||||||
|
if (stock <= 0) {
|
||||||
|
return const StatusPill(
|
||||||
|
label: 'Out of stock',
|
||||||
|
color: AppColors.danger,
|
||||||
|
background: AppColors.dangerSurface,
|
||||||
|
dense: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stock <= lowThreshold) {
|
||||||
|
return StatusPill(
|
||||||
|
label: '${stock.toStringAsFixed(0)} left',
|
||||||
|
color: AppColors.warning,
|
||||||
|
background: AppColors.warningSurface,
|
||||||
|
dense: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return StatusPill(
|
||||||
|
label: '${stock.toStringAsFixed(0)} in stock',
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
background: Colors.transparent,
|
||||||
|
dense: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: dense ? AppSpacing.sm : AppSpacing.md,
|
||||||
|
vertical: dense ? 3 : AppSpacing.xs + 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background ?? color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: AppRadius.brPill,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, size: dense ? 11 : 13, color: color),
|
||||||
|
const SizedBox(width: AppSpacing.xs),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: dense ? 10.5 : 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
lib/data/datasources/local_store.dart
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import '../../domain/entities/customer.dart';
|
||||||
|
import '../../domain/entities/product.dart';
|
||||||
|
import '../../domain/entities/sync_event.dart';
|
||||||
|
import '../../domain/entities/transaction.dart';
|
||||||
|
import 'seed_data.dart';
|
||||||
|
|
||||||
|
/// On-terminal storage.
|
||||||
|
///
|
||||||
|
/// The terminal starts with an **empty catalogue**: nothing can be billed
|
||||||
|
/// until the cashier imports products. Everything after that — sales, parked
|
||||||
|
/// bills, queued events — lives here and survives without a connection.
|
||||||
|
///
|
||||||
|
/// Swapping this for Hive or SQLite changes nothing above the data layer.
|
||||||
|
class LocalStore {
|
||||||
|
LocalStore._();
|
||||||
|
|
||||||
|
static final LocalStore instance = LocalStore._();
|
||||||
|
|
||||||
|
final Map<String, Product> _products = {};
|
||||||
|
final Map<String, Customer> _customers = {};
|
||||||
|
final List<SaleTransaction> _transactions = [];
|
||||||
|
final List<ParkedBill> _parked = [];
|
||||||
|
final List<SyncEvent> _events = [];
|
||||||
|
|
||||||
|
int _invoiceSequence = 0;
|
||||||
|
|
||||||
|
DateTime? _lastImportAt;
|
||||||
|
String? _catalogueRevision;
|
||||||
|
|
||||||
|
/// Nothing to seed — the catalogue arrives via import.
|
||||||
|
Future<void> init() async {}
|
||||||
|
|
||||||
|
/// Test helper. Clears everything and optionally loads the demo catalogue
|
||||||
|
/// so fixtures don't have to run an import first.
|
||||||
|
Future<void> reset({bool withCatalogue = false}) async {
|
||||||
|
_products.clear();
|
||||||
|
_customers.clear();
|
||||||
|
_transactions.clear();
|
||||||
|
_parked.clear();
|
||||||
|
_events.clear();
|
||||||
|
_invoiceSequence = 0;
|
||||||
|
_lastImportAt = null;
|
||||||
|
_catalogueRevision = null;
|
||||||
|
|
||||||
|
if (withCatalogue) {
|
||||||
|
importCatalogue(
|
||||||
|
products: SeedData.products(),
|
||||||
|
customers: SeedData.customers(),
|
||||||
|
revision: 'seed',
|
||||||
|
at: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- Catalogue
|
||||||
|
/// True once a catalogue has been pulled. The POS refuses to bill until so.
|
||||||
|
bool get hasCatalogue => _products.isNotEmpty;
|
||||||
|
|
||||||
|
DateTime? get lastImportAt => _lastImportAt;
|
||||||
|
String? get catalogueRevision => _catalogueRevision;
|
||||||
|
|
||||||
|
/// Replaces the catalogue wholesale.
|
||||||
|
///
|
||||||
|
/// Stock levels already adjusted by local sales are preserved for products
|
||||||
|
/// that survive the re-import, so importing mid-shift does not resurrect
|
||||||
|
/// stock that has been sold.
|
||||||
|
void importCatalogue({
|
||||||
|
required List<Product> products,
|
||||||
|
required List<Customer> customers,
|
||||||
|
required String revision,
|
||||||
|
required DateTime at,
|
||||||
|
}) {
|
||||||
|
final priorStock = {
|
||||||
|
for (final p in _products.values) p.id: p.stock,
|
||||||
|
};
|
||||||
|
|
||||||
|
_products
|
||||||
|
..clear()
|
||||||
|
..addEntries(products.map((p) {
|
||||||
|
final held = priorStock[p.id];
|
||||||
|
return MapEntry(p.id, held == null ? p : p.copyWith(stock: held));
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Locally registered customers must not be wiped by a server pull.
|
||||||
|
for (final c in customers) {
|
||||||
|
_customers.putIfAbsent(c.id, () => c);
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastImportAt = at;
|
||||||
|
_catalogueRevision = revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- Products
|
||||||
|
List<Product> get products => _products.values.toList(growable: false);
|
||||||
|
|
||||||
|
void putProduct(Product p) => _products[p.id] = p;
|
||||||
|
|
||||||
|
Product? productById(String id) => _products[id];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- Customers
|
||||||
|
List<Customer> get customers => _customers.values.toList(growable: false);
|
||||||
|
|
||||||
|
void putCustomer(Customer c) => _customers[c.id] = c;
|
||||||
|
|
||||||
|
Customer? customerById(String id) => _customers[id];
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- Transactions
|
||||||
|
List<SaleTransaction> get transactions =>
|
||||||
|
List.unmodifiable(_transactions.reversed);
|
||||||
|
|
||||||
|
void addTransaction(SaleTransaction t) => _transactions.add(t);
|
||||||
|
|
||||||
|
int nextInvoiceSequence() => ++_invoiceSequence;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Parked bills
|
||||||
|
List<ParkedBill> get parked => List.unmodifiable(_parked);
|
||||||
|
|
||||||
|
void addParked(ParkedBill b) => _parked.add(b);
|
||||||
|
|
||||||
|
void removeParked(String id) => _parked.removeWhere((b) => b.id == id);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Sync events
|
||||||
|
List<SyncEvent> get events => List.unmodifiable(_events.reversed);
|
||||||
|
|
||||||
|
void addEvent(SyncEvent e) => _events.add(e);
|
||||||
|
|
||||||
|
/// Updates in place. Events are never removed, so a failed push stays
|
||||||
|
/// visible and retryable.
|
||||||
|
void updateEvent(SyncEvent e) {
|
||||||
|
final i = _events.indexWhere((x) => x.id == e.id);
|
||||||
|
if (i >= 0) _events[i] = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasUnsyncedEvents =>
|
||||||
|
_events.any((e) => e.status != SyncStatus.synced);
|
||||||
|
}
|
||||||
98
lib/data/datasources/remote_catalogue_source.dart
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import '../../domain/entities/customer.dart';
|
||||||
|
import '../../domain/entities/product.dart';
|
||||||
|
import 'seed_data.dart';
|
||||||
|
|
||||||
|
/// What one catalogue pull returns.
|
||||||
|
class CatalogueSnapshot {
|
||||||
|
const CatalogueSnapshot({
|
||||||
|
required this.products,
|
||||||
|
required this.customers,
|
||||||
|
required this.fetchedAt,
|
||||||
|
required this.revision,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<Product> products;
|
||||||
|
final List<Customer> customers;
|
||||||
|
final DateTime fetchedAt;
|
||||||
|
|
||||||
|
/// Server-side catalogue version, shown so the cashier can tell whether a
|
||||||
|
/// re-import actually changed anything.
|
||||||
|
final String revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raised when the catalogue cannot be pulled.
|
||||||
|
class CatalogueSyncException implements Exception {
|
||||||
|
const CatalogueSyncException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stands in for the back-office catalogue API.
|
||||||
|
///
|
||||||
|
/// The real implementation would issue an HTTP request; the contract is the
|
||||||
|
/// same, so only this class changes.
|
||||||
|
class RemoteCatalogueSource {
|
||||||
|
RemoteCatalogueSource();
|
||||||
|
|
||||||
|
/// Flipped from Settings to exercise the offline path.
|
||||||
|
bool simulateOffline = false;
|
||||||
|
|
||||||
|
/// Streams progress so the import screen can show a real bar rather than an
|
||||||
|
/// indeterminate spinner.
|
||||||
|
Future<CatalogueSnapshot> fetch({
|
||||||
|
void Function(double progress, String stage)? onProgress,
|
||||||
|
}) async {
|
||||||
|
const stages = [
|
||||||
|
(0.15, 'Contacting server…'),
|
||||||
|
(0.35, 'Authorising terminal…'),
|
||||||
|
(0.60, 'Downloading products…'),
|
||||||
|
(0.85, 'Downloading customers…'),
|
||||||
|
(1.00, 'Writing to local storage…'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final (progress, stage) in stages) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 320));
|
||||||
|
|
||||||
|
if (simulateOffline) {
|
||||||
|
throw const CatalogueSyncException(
|
||||||
|
'No connection to the catalogue server. '
|
||||||
|
'Check the network and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.call(progress, stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CatalogueSnapshot(
|
||||||
|
products: SeedData.products(),
|
||||||
|
customers: SeedData.customers(),
|
||||||
|
fetchedAt: DateTime.now(),
|
||||||
|
revision: 'rev-${DateTime.now().millisecondsSinceEpoch % 100000}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stands in for the back-office reporting API.
|
||||||
|
class RemoteReportSink {
|
||||||
|
RemoteReportSink();
|
||||||
|
|
||||||
|
bool simulateOffline = false;
|
||||||
|
|
||||||
|
/// Pushes one payload. Throws on failure so the caller can keep the event
|
||||||
|
/// queued rather than marking it sent.
|
||||||
|
Future<String> push(Map<String, Object?> payload) async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 900));
|
||||||
|
|
||||||
|
if (simulateOffline) {
|
||||||
|
throw const CatalogueSyncException(
|
||||||
|
'Could not reach the reporting server. '
|
||||||
|
'The report is still saved on this terminal.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ACK-${DateTime.now().millisecondsSinceEpoch % 1000000}';
|
||||||
|
}
|
||||||
|
}
|
||||||
542
lib/data/datasources/seed_data.dart
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
import '../../domain/entities/customer.dart';
|
||||||
|
import '../../domain/entities/product.dart';
|
||||||
|
|
||||||
|
/// Demo catalogue and customer book.
|
||||||
|
///
|
||||||
|
/// Barcodes are valid 13-digit EAN strings beginning with the Indian GS1
|
||||||
|
/// prefix `890`, so a real scanner can be tested against this data.
|
||||||
|
class SeedData {
|
||||||
|
const SeedData._();
|
||||||
|
|
||||||
|
static List<Product> products() => const [
|
||||||
|
// ------------------------------------------------------------ Dairy
|
||||||
|
Product(
|
||||||
|
id: 'p001',
|
||||||
|
name: 'Amul Milk 1L',
|
||||||
|
barcode: '8901234500011',
|
||||||
|
sku: 'DRY-MLK-1000',
|
||||||
|
category: ProductCategory.dairy,
|
||||||
|
price: 62,
|
||||||
|
mrp: 66,
|
||||||
|
stock: 50,
|
||||||
|
emoji: '🥛',
|
||||||
|
unit: UnitOfMeasure.litre,
|
||||||
|
gstRate: 0.05,
|
||||||
|
brand: 'Amul',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p002',
|
||||||
|
name: 'Amul Butter 500g',
|
||||||
|
barcode: '8901234500028',
|
||||||
|
sku: 'DRY-BTR-0500',
|
||||||
|
category: ProductCategory.dairy,
|
||||||
|
price: 245,
|
||||||
|
mrp: 265,
|
||||||
|
stock: 30,
|
||||||
|
emoji: '🧈',
|
||||||
|
gstRate: 0.12,
|
||||||
|
brand: 'Amul',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p003',
|
||||||
|
name: 'Curd 400g',
|
||||||
|
barcode: '8901234500035',
|
||||||
|
sku: 'DRY-CRD-0400',
|
||||||
|
category: ProductCategory.dairy,
|
||||||
|
price: 48,
|
||||||
|
mrp: 52,
|
||||||
|
stock: 40,
|
||||||
|
emoji: '🥣',
|
||||||
|
gstRate: 0.05,
|
||||||
|
brand: 'Nandini',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p004',
|
||||||
|
name: 'Paneer 200g',
|
||||||
|
barcode: '8901234500042',
|
||||||
|
sku: 'DRY-PNR-0200',
|
||||||
|
category: ProductCategory.dairy,
|
||||||
|
price: 90,
|
||||||
|
stock: 20,
|
||||||
|
emoji: '🧀',
|
||||||
|
gstRate: 0.05,
|
||||||
|
brand: 'Milky Mist',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p005',
|
||||||
|
name: 'Cheese Slices 200g',
|
||||||
|
barcode: '8901234500059',
|
||||||
|
sku: 'DRY-CHS-0200',
|
||||||
|
category: ProductCategory.dairy,
|
||||||
|
price: 135,
|
||||||
|
mrp: 145,
|
||||||
|
stock: 25,
|
||||||
|
emoji: '🧀',
|
||||||
|
gstRate: 0.12,
|
||||||
|
brand: 'Britannia',
|
||||||
|
),
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- Grocery
|
||||||
|
Product(
|
||||||
|
id: 'p010',
|
||||||
|
name: 'Basmati Rice 1kg',
|
||||||
|
barcode: '8901234500110',
|
||||||
|
sku: 'GRO-RCE-1000',
|
||||||
|
category: ProductCategory.grocery,
|
||||||
|
price: 180,
|
||||||
|
mrp: 199,
|
||||||
|
stock: 4,
|
||||||
|
emoji: '🍚',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0.05,
|
||||||
|
brand: 'India Gate',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p011',
|
||||||
|
name: 'Fortune Oil 1L',
|
||||||
|
barcode: '8901234500127',
|
||||||
|
sku: 'GRO-OIL-1000',
|
||||||
|
category: ProductCategory.grocery,
|
||||||
|
price: 145,
|
||||||
|
mrp: 160,
|
||||||
|
stock: 60,
|
||||||
|
emoji: '🛢️',
|
||||||
|
unit: UnitOfMeasure.litre,
|
||||||
|
gstRate: 0.05,
|
||||||
|
brand: 'Fortune',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p012',
|
||||||
|
name: 'Toor Dal 500g',
|
||||||
|
barcode: '8901234500134',
|
||||||
|
sku: 'GRO-DAL-0500',
|
||||||
|
category: ProductCategory.grocery,
|
||||||
|
price: 90,
|
||||||
|
stock: 45,
|
||||||
|
emoji: '🫘',
|
||||||
|
gstRate: 0.05,
|
||||||
|
brand: 'Tata Sampann',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p013',
|
||||||
|
name: 'Aashirvaad Atta 5kg',
|
||||||
|
barcode: '8901234500141',
|
||||||
|
sku: 'GRO-ATA-5000',
|
||||||
|
category: ProductCategory.grocery,
|
||||||
|
price: 285,
|
||||||
|
mrp: 310,
|
||||||
|
stock: 35,
|
||||||
|
emoji: '🌾',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0.05,
|
||||||
|
brand: 'Aashirvaad',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p014',
|
||||||
|
name: 'Sugar 1kg',
|
||||||
|
barcode: '8901234500158',
|
||||||
|
sku: 'GRO-SGR-1000',
|
||||||
|
category: ProductCategory.grocery,
|
||||||
|
price: 48,
|
||||||
|
stock: 80,
|
||||||
|
emoji: '🍬',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0.05,
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p015',
|
||||||
|
name: 'Maggi 2-min',
|
||||||
|
barcode: '8901234500165',
|
||||||
|
sku: 'GRO-MAG-0070',
|
||||||
|
category: ProductCategory.grocery,
|
||||||
|
price: 14,
|
||||||
|
stock: 120,
|
||||||
|
emoji: '🍜',
|
||||||
|
gstRate: 0.12,
|
||||||
|
brand: 'Nestlé',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p016',
|
||||||
|
name: 'Tata Salt 1kg',
|
||||||
|
barcode: '8901234500172',
|
||||||
|
sku: 'GRO-SLT-1000',
|
||||||
|
category: ProductCategory.grocery,
|
||||||
|
price: 28,
|
||||||
|
stock: 95,
|
||||||
|
emoji: '🧂',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0.05,
|
||||||
|
brand: 'Tata',
|
||||||
|
),
|
||||||
|
|
||||||
|
// -------------------------------------------------------- Beverages
|
||||||
|
Product(
|
||||||
|
id: 'p020',
|
||||||
|
name: 'Coca-Cola 600ml',
|
||||||
|
barcode: '8901234500219',
|
||||||
|
sku: 'BEV-COK-0600',
|
||||||
|
category: ProductCategory.beverages,
|
||||||
|
price: 40,
|
||||||
|
stock: 80,
|
||||||
|
emoji: '🥤',
|
||||||
|
unit: UnitOfMeasure.millilitre,
|
||||||
|
gstRate: 0.28,
|
||||||
|
brand: 'Coca-Cola',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p021',
|
||||||
|
name: 'Frooti 250ml',
|
||||||
|
barcode: '8901234500226',
|
||||||
|
sku: 'BEV-FRT-0250',
|
||||||
|
category: ProductCategory.beverages,
|
||||||
|
price: 15,
|
||||||
|
stock: 100,
|
||||||
|
emoji: '🥭',
|
||||||
|
gstRate: 0.12,
|
||||||
|
brand: 'Parle Agro',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p022',
|
||||||
|
name: 'Bisleri 1L',
|
||||||
|
barcode: '8901234500233',
|
||||||
|
sku: 'BEV-WTR-1000',
|
||||||
|
category: ProductCategory.beverages,
|
||||||
|
price: 20,
|
||||||
|
stock: 150,
|
||||||
|
emoji: '💧',
|
||||||
|
unit: UnitOfMeasure.litre,
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Bisleri',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p023',
|
||||||
|
name: 'Red Bull 250ml',
|
||||||
|
barcode: '8901234500240',
|
||||||
|
sku: 'BEV-RBL-0250',
|
||||||
|
category: ProductCategory.beverages,
|
||||||
|
price: 125,
|
||||||
|
stock: 40,
|
||||||
|
emoji: '🔋',
|
||||||
|
gstRate: 0.28,
|
||||||
|
brand: 'Red Bull',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p024',
|
||||||
|
name: 'Bru Coffee 100g',
|
||||||
|
barcode: '8901234500257',
|
||||||
|
sku: 'BEV-COF-0100',
|
||||||
|
category: ProductCategory.beverages,
|
||||||
|
price: 165,
|
||||||
|
mrp: 180,
|
||||||
|
stock: 30,
|
||||||
|
emoji: '☕',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Bru',
|
||||||
|
),
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- Snacks
|
||||||
|
Product(
|
||||||
|
id: 'p030',
|
||||||
|
name: 'Parle-G 800g',
|
||||||
|
barcode: '8901234500318',
|
||||||
|
sku: 'SNK-PGB-0800',
|
||||||
|
category: ProductCategory.snacks,
|
||||||
|
price: 40,
|
||||||
|
stock: 90,
|
||||||
|
emoji: '🍪',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Parle',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p031',
|
||||||
|
name: "Lay's Chips 26g",
|
||||||
|
barcode: '8901234500325',
|
||||||
|
sku: 'SNK-LAY-0026',
|
||||||
|
category: ProductCategory.snacks,
|
||||||
|
price: 20,
|
||||||
|
stock: 110,
|
||||||
|
emoji: '🥔',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: "Lay's",
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p032',
|
||||||
|
name: 'Dairy Milk 55g',
|
||||||
|
barcode: '8901234500332',
|
||||||
|
sku: 'SNK-DMK-0055',
|
||||||
|
category: ProductCategory.snacks,
|
||||||
|
price: 45,
|
||||||
|
stock: 75,
|
||||||
|
emoji: '🍫',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Cadbury',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p033',
|
||||||
|
name: 'Good Day 200g',
|
||||||
|
barcode: '8901234500349',
|
||||||
|
sku: 'SNK-GDY-0200',
|
||||||
|
category: ProductCategory.snacks,
|
||||||
|
price: 35,
|
||||||
|
stock: 85,
|
||||||
|
emoji: '🍪',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Britannia',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p034',
|
||||||
|
name: 'Haldiram Mixture 200g',
|
||||||
|
barcode: '8901234500356',
|
||||||
|
sku: 'SNK-HMX-0200',
|
||||||
|
category: ProductCategory.snacks,
|
||||||
|
price: 55,
|
||||||
|
stock: 60,
|
||||||
|
emoji: '🥜',
|
||||||
|
gstRate: 0.12,
|
||||||
|
brand: 'Haldiram',
|
||||||
|
),
|
||||||
|
|
||||||
|
// ---------------------------------------------------- Personal care
|
||||||
|
Product(
|
||||||
|
id: 'p040',
|
||||||
|
name: 'Colgate 200g',
|
||||||
|
barcode: '8901234500417',
|
||||||
|
sku: 'PER-CLG-0200',
|
||||||
|
category: ProductCategory.personalCare,
|
||||||
|
price: 110,
|
||||||
|
mrp: 125,
|
||||||
|
stock: 55,
|
||||||
|
emoji: '🪥',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Colgate',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p041',
|
||||||
|
name: 'Dove Soap 100g',
|
||||||
|
barcode: '8901234500424',
|
||||||
|
sku: 'PER-DVE-0100',
|
||||||
|
category: ProductCategory.personalCare,
|
||||||
|
price: 65,
|
||||||
|
stock: 70,
|
||||||
|
emoji: '🧼',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Dove',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p042',
|
||||||
|
name: 'Head & Shoulders 340ml',
|
||||||
|
barcode: '8901234500431',
|
||||||
|
sku: 'PER-HNS-0340',
|
||||||
|
category: ProductCategory.personalCare,
|
||||||
|
price: 385,
|
||||||
|
mrp: 420,
|
||||||
|
stock: 25,
|
||||||
|
emoji: '🧴',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'P&G',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p043',
|
||||||
|
name: 'Nivea Lotion 200ml',
|
||||||
|
barcode: '8901234500448',
|
||||||
|
sku: 'PER-NVA-0200',
|
||||||
|
category: ProductCategory.personalCare,
|
||||||
|
price: 240,
|
||||||
|
stock: 30,
|
||||||
|
emoji: '🧴',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Nivea',
|
||||||
|
),
|
||||||
|
|
||||||
|
// -------------------------------------------------------- Household
|
||||||
|
Product(
|
||||||
|
id: 'p050',
|
||||||
|
name: 'Surf Excel 1kg',
|
||||||
|
barcode: '8901234500516',
|
||||||
|
sku: 'HSE-SRF-1000',
|
||||||
|
category: ProductCategory.household,
|
||||||
|
price: 165,
|
||||||
|
mrp: 180,
|
||||||
|
stock: 45,
|
||||||
|
emoji: '🧺',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Surf Excel',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p051',
|
||||||
|
name: 'Vim Bar 300g',
|
||||||
|
barcode: '8901234500523',
|
||||||
|
sku: 'HSE-VIM-0300',
|
||||||
|
category: ProductCategory.household,
|
||||||
|
price: 30,
|
||||||
|
stock: 90,
|
||||||
|
emoji: '🧽',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Vim',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p052',
|
||||||
|
name: 'Harpic 500ml',
|
||||||
|
barcode: '8901234500530',
|
||||||
|
sku: 'HSE-HRP-0500',
|
||||||
|
category: ProductCategory.household,
|
||||||
|
price: 98,
|
||||||
|
stock: 40,
|
||||||
|
emoji: '🧴',
|
||||||
|
gstRate: 0.18,
|
||||||
|
brand: 'Harpic',
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p053',
|
||||||
|
name: 'Garbage Bags 30pc',
|
||||||
|
barcode: '8901234500547',
|
||||||
|
sku: 'HSE-GBG-0030',
|
||||||
|
category: ProductCategory.household,
|
||||||
|
price: 145,
|
||||||
|
stock: 35,
|
||||||
|
emoji: '🗑️',
|
||||||
|
gstRate: 0.18,
|
||||||
|
),
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- Fruits
|
||||||
|
Product(
|
||||||
|
id: 'p060',
|
||||||
|
name: 'Banana 1kg',
|
||||||
|
barcode: '8901234500615',
|
||||||
|
sku: 'FRT-BAN-1000',
|
||||||
|
category: ProductCategory.fruits,
|
||||||
|
price: 55,
|
||||||
|
stock: 40,
|
||||||
|
emoji: '🍌',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0,
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p061',
|
||||||
|
name: 'Apple Shimla 1kg',
|
||||||
|
barcode: '8901234500622',
|
||||||
|
sku: 'FRT-APL-1000',
|
||||||
|
category: ProductCategory.fruits,
|
||||||
|
price: 180,
|
||||||
|
stock: 25,
|
||||||
|
emoji: '🍎',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0,
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p062',
|
||||||
|
name: 'Alphonso Mango 1kg',
|
||||||
|
barcode: '8901234500639',
|
||||||
|
sku: 'FRT-MNG-1000',
|
||||||
|
category: ProductCategory.fruits,
|
||||||
|
price: 320,
|
||||||
|
stock: 15,
|
||||||
|
emoji: '🥭',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0,
|
||||||
|
),
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Vegetables
|
||||||
|
Product(
|
||||||
|
id: 'p070',
|
||||||
|
name: 'Tomato 1kg',
|
||||||
|
barcode: '8901234500714',
|
||||||
|
sku: 'VEG-TOM-1000',
|
||||||
|
category: ProductCategory.vegetables,
|
||||||
|
price: 40,
|
||||||
|
stock: 50,
|
||||||
|
emoji: '🍅',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0,
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p071',
|
||||||
|
name: 'Onion 1kg',
|
||||||
|
barcode: '8901234500721',
|
||||||
|
sku: 'VEG-ONI-1000',
|
||||||
|
category: ProductCategory.vegetables,
|
||||||
|
price: 35,
|
||||||
|
stock: 65,
|
||||||
|
emoji: '🧅',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0,
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p072',
|
||||||
|
name: 'Potato 1kg',
|
||||||
|
barcode: '8901234500738',
|
||||||
|
sku: 'VEG-POT-1000',
|
||||||
|
category: ProductCategory.vegetables,
|
||||||
|
price: 30,
|
||||||
|
stock: 70,
|
||||||
|
emoji: '🥔',
|
||||||
|
unit: UnitOfMeasure.kilogram,
|
||||||
|
gstRate: 0,
|
||||||
|
),
|
||||||
|
Product(
|
||||||
|
id: 'p073',
|
||||||
|
name: 'Carrot 500g',
|
||||||
|
barcode: '8901234500745',
|
||||||
|
sku: 'VEG-CAR-0500',
|
||||||
|
category: ProductCategory.vegetables,
|
||||||
|
price: 32,
|
||||||
|
stock: 8,
|
||||||
|
emoji: '🥕',
|
||||||
|
gstRate: 0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<Customer> customers() => [
|
||||||
|
Customer(
|
||||||
|
id: 'c001',
|
||||||
|
name: 'Abhishek',
|
||||||
|
mobile: '9876543210',
|
||||||
|
email: 'abhishek@example.com',
|
||||||
|
gender: Gender.male,
|
||||||
|
dateOfBirth: DateTime(1994, 3, 18),
|
||||||
|
loyaltyPoints: 320,
|
||||||
|
lifetimeSpend: 24500,
|
||||||
|
visitCount: 41,
|
||||||
|
createdAt: DateTime(2024, 1, 12),
|
||||||
|
lastVisitAt: DateTime.now().subtract(const Duration(days: 3)),
|
||||||
|
),
|
||||||
|
Customer(
|
||||||
|
id: 'c002',
|
||||||
|
name: 'Priya Raman',
|
||||||
|
mobile: '9812345678',
|
||||||
|
email: 'priya.r@example.com',
|
||||||
|
gender: Gender.female,
|
||||||
|
dateOfBirth: DateTime(1990, 7, 2),
|
||||||
|
loyaltyPoints: 1180,
|
||||||
|
lifetimeSpend: 68200,
|
||||||
|
visitCount: 96,
|
||||||
|
createdAt: DateTime(2023, 6, 4),
|
||||||
|
lastVisitAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
),
|
||||||
|
Customer(
|
||||||
|
id: 'c003',
|
||||||
|
name: 'Karthik S',
|
||||||
|
mobile: '9900112233',
|
||||||
|
gender: Gender.male,
|
||||||
|
loyaltyPoints: 45,
|
||||||
|
lifetimeSpend: 3200,
|
||||||
|
visitCount: 7,
|
||||||
|
createdAt: DateTime(2025, 2, 20),
|
||||||
|
lastVisitAt: DateTime.now().subtract(const Duration(days: 11)),
|
||||||
|
),
|
||||||
|
Customer(
|
||||||
|
id: 'c004',
|
||||||
|
name: 'Meena Lakshmi',
|
||||||
|
mobile: '9445566778',
|
||||||
|
email: 'meena.l@example.com',
|
||||||
|
gender: Gender.female,
|
||||||
|
dateOfBirth: DateTime(1986, 11, 30),
|
||||||
|
loyaltyPoints: 2640,
|
||||||
|
lifetimeSpend: 172000,
|
||||||
|
visitCount: 210,
|
||||||
|
createdAt: DateTime(2022, 9, 15),
|
||||||
|
lastVisitAt: DateTime.now().subtract(const Duration(hours: 20)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
96
lib/data/repositories/customer_repository_impl.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/extensions.dart';
|
||||||
|
import '../../domain/entities/customer.dart';
|
||||||
|
import '../../domain/repositories/customer_repository.dart';
|
||||||
|
import '../datasources/local_store.dart';
|
||||||
|
|
||||||
|
class CustomerRepositoryImpl implements CustomerRepository {
|
||||||
|
CustomerRepositoryImpl(this._store);
|
||||||
|
|
||||||
|
final LocalStore _store;
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
String _digits(String v) => v.replaceAll(RegExp(r'\D'), '');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Customer?> findByMobile(String mobile) async {
|
||||||
|
final needle = _digits(mobile);
|
||||||
|
return _store.customers
|
||||||
|
.firstWhereOrNull((c) => _digits(c.mobile) == needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Customer?> findById(String id) async => _store.customerById(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Customer> create(Customer customer) async {
|
||||||
|
final existing = await findByMobile(customer.mobile);
|
||||||
|
if (existing != null) {
|
||||||
|
throw StateError('A customer with this mobile number already exists.');
|
||||||
|
}
|
||||||
|
final created = Customer(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
name: customer.name.trim(),
|
||||||
|
mobile: _digits(customer.mobile),
|
||||||
|
email: customer.email?.trim().isEmpty ?? true
|
||||||
|
? null
|
||||||
|
: customer.email!.trim(),
|
||||||
|
gender: customer.gender,
|
||||||
|
dateOfBirth: customer.dateOfBirth,
|
||||||
|
loyaltyPoints: 0,
|
||||||
|
lifetimeSpend: 0,
|
||||||
|
visitCount: 0,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
_store.putCustomer(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Customer> update(Customer customer) async {
|
||||||
|
_store.putCustomer(customer);
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Customer> recordSale({
|
||||||
|
required String customerId,
|
||||||
|
required double amount,
|
||||||
|
required int pointsEarned,
|
||||||
|
required int pointsRedeemed,
|
||||||
|
}) async {
|
||||||
|
final current = _store.customerById(customerId);
|
||||||
|
if (current == null) {
|
||||||
|
throw StateError('Customer $customerId not found.');
|
||||||
|
}
|
||||||
|
final updated = current.copyWith(
|
||||||
|
loyaltyPoints:
|
||||||
|
(current.loyaltyPoints - pointsRedeemed + pointsEarned)
|
||||||
|
.clamp(0, 1 << 31),
|
||||||
|
lifetimeSpend: (current.lifetimeSpend + amount).asMoney,
|
||||||
|
visitCount: current.visitCount + 1,
|
||||||
|
lastVisitAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
_store.putCustomer(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Customer>> search(String query) async {
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return recent();
|
||||||
|
return _store.customers
|
||||||
|
.where((c) =>
|
||||||
|
c.name.toLowerCase().contains(q) || _digits(c.mobile).contains(q))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Customer>> recent({int limit = 20}) async {
|
||||||
|
final list = _store.customers.toList()
|
||||||
|
..sort((a, b) => (b.lastVisitAt ?? DateTime(2000))
|
||||||
|
.compareTo(a.lastVisitAt ?? DateTime(2000)));
|
||||||
|
return list.take(limit).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lib/data/repositories/product_repository_impl.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import '../../core/utils/extensions.dart';
|
||||||
|
import '../../domain/entities/product.dart';
|
||||||
|
import '../../domain/repositories/product_repository.dart';
|
||||||
|
import '../datasources/local_store.dart';
|
||||||
|
|
||||||
|
class ProductRepositoryImpl implements ProductRepository {
|
||||||
|
ProductRepositoryImpl(this._store);
|
||||||
|
|
||||||
|
final LocalStore _store;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Product>> getAll() async =>
|
||||||
|
_store.products.where((p) => p.isActive).toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Product>> getByCategory(ProductCategory category) async =>
|
||||||
|
_store.products
|
||||||
|
.where((p) => p.isActive && p.category == category)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Product?> findByBarcode(String barcode) async {
|
||||||
|
final needle = barcode.trim();
|
||||||
|
return _store.products.firstWhereOrNull(
|
||||||
|
(p) => p.barcode == needle && p.isActive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Product?> findById(String id) async => _store.productById(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Product>> search(String query) async {
|
||||||
|
final q = query.trim();
|
||||||
|
if (q.isEmpty) return getAll();
|
||||||
|
|
||||||
|
final results = _store.products.where((p) => p.isActive && p.matches(q));
|
||||||
|
|
||||||
|
// Rank exact barcode and SKU hits above fuzzy name matches so the top
|
||||||
|
// result is the one the cashier almost certainly meant.
|
||||||
|
final ranked = results.toList()
|
||||||
|
..sort((a, b) => _score(b, q).compareTo(_score(a, q)));
|
||||||
|
return ranked;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _score(Product p, String q) {
|
||||||
|
final lq = q.toLowerCase();
|
||||||
|
if (p.barcode == q) return 100;
|
||||||
|
if (p.sku.toLowerCase() == lq) return 90;
|
||||||
|
if (p.name.toLowerCase().startsWith(lq)) return 70;
|
||||||
|
if (p.name.toLowerCase().contains(lq)) return 50;
|
||||||
|
if (p.brand?.toLowerCase().contains(lq) ?? false) return 30;
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> decrementStock(Map<String, double> quantities) async {
|
||||||
|
quantities.forEach((id, qty) {
|
||||||
|
final p = _store.productById(id);
|
||||||
|
if (p == null) return;
|
||||||
|
final next = (p.stock - qty).clamp(0, double.infinity).toDouble();
|
||||||
|
_store.putProduct(p.copyWith(stock: next));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsert(Product product) async => _store.putProduct(product);
|
||||||
|
}
|
||||||
159
lib/data/repositories/sync_repository_impl.dart
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/formatters.dart';
|
||||||
|
import '../../domain/entities/shift_report.dart';
|
||||||
|
import '../../domain/entities/sync_event.dart';
|
||||||
|
import '../../domain/repositories/sync_repository.dart';
|
||||||
|
import '../datasources/local_store.dart';
|
||||||
|
import '../datasources/remote_catalogue_source.dart';
|
||||||
|
|
||||||
|
class SyncRepositoryImpl implements SyncRepository {
|
||||||
|
SyncRepositoryImpl(this._store, this._catalogue, this._reports);
|
||||||
|
|
||||||
|
final LocalStore _store;
|
||||||
|
final RemoteCatalogueSource _catalogue;
|
||||||
|
final RemoteReportSink _reports;
|
||||||
|
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get hasCatalogue => _store.hasCatalogue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
DateTime? get lastImportAt => _store.lastImportAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get catalogueRevision => _store.catalogueRevision;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<SyncEvent> get events => _store.events;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get hasUnsyncedEvents => _store.hasUnsyncedEvents;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SyncEvent> importCatalogue({
|
||||||
|
void Function(double progress, String stage)? onProgress,
|
||||||
|
}) async {
|
||||||
|
final event = SyncEvent(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
type: SyncEventType.catalogueImport,
|
||||||
|
status: SyncStatus.syncing,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
summary: 'Catalogue import started',
|
||||||
|
);
|
||||||
|
_store.addEvent(event);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final snapshot = await _catalogue.fetch(onProgress: onProgress);
|
||||||
|
|
||||||
|
_store.importCatalogue(
|
||||||
|
products: snapshot.products,
|
||||||
|
customers: snapshot.customers,
|
||||||
|
revision: snapshot.revision,
|
||||||
|
at: snapshot.fetchedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
final done = event.copyWith(
|
||||||
|
status: SyncStatus.synced,
|
||||||
|
syncedAt: DateTime.now(),
|
||||||
|
attempts: 1,
|
||||||
|
);
|
||||||
|
final settled = SyncEvent(
|
||||||
|
id: done.id,
|
||||||
|
type: done.type,
|
||||||
|
status: done.status,
|
||||||
|
createdAt: done.createdAt,
|
||||||
|
summary: '${snapshot.products.length} products, '
|
||||||
|
'${snapshot.customers.length} customers · ${snapshot.revision}',
|
||||||
|
payload: {
|
||||||
|
'products': snapshot.products.length,
|
||||||
|
'customers': snapshot.customers.length,
|
||||||
|
'revision': snapshot.revision,
|
||||||
|
},
|
||||||
|
syncedAt: done.syncedAt,
|
||||||
|
attempts: 1,
|
||||||
|
);
|
||||||
|
_store.updateEvent(settled);
|
||||||
|
return settled;
|
||||||
|
} catch (e) {
|
||||||
|
final failed = event.copyWith(
|
||||||
|
status: SyncStatus.failed,
|
||||||
|
error: e.toString(),
|
||||||
|
attempts: 1,
|
||||||
|
);
|
||||||
|
_store.updateEvent(failed);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ShiftReport buildShiftReport({
|
||||||
|
required DateTime businessDate,
|
||||||
|
required String terminalId,
|
||||||
|
required String cashierName,
|
||||||
|
}) {
|
||||||
|
return ShiftReport.fromTransactions(
|
||||||
|
transactions: _store.transactions,
|
||||||
|
businessDate: businessDate,
|
||||||
|
terminalId: terminalId,
|
||||||
|
cashierName: cashierName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SyncEvent> pushShiftReport(ShiftReport report) async {
|
||||||
|
// Queued first, so the data is durable before the network is touched.
|
||||||
|
final queued = SyncEvent(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
type: SyncEventType.shiftReport,
|
||||||
|
status: SyncStatus.pending,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
summary: '${report.billCount} bills · '
|
||||||
|
'${Formatters.money(report.grossSales)} · '
|
||||||
|
'${Formatters.date(report.businessDate)}',
|
||||||
|
payload: report.toPayload(),
|
||||||
|
);
|
||||||
|
_store.addEvent(queued);
|
||||||
|
|
||||||
|
return _attempt(queued);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SyncEvent> retry(String eventId) async {
|
||||||
|
final matches = _store.events.where((e) => e.id == eventId).toList();
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
throw StateError('No queued event with id $eventId');
|
||||||
|
}
|
||||||
|
final event = matches.first;
|
||||||
|
if (event.type == SyncEventType.catalogueImport) {
|
||||||
|
return importCatalogue();
|
||||||
|
}
|
||||||
|
return _attempt(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends one event, leaving it queued if the push fails.
|
||||||
|
Future<SyncEvent> _attempt(SyncEvent event) async {
|
||||||
|
_store.updateEvent(event.copyWith(status: SyncStatus.syncing));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _reports.push(event.payload);
|
||||||
|
final done = event.copyWith(
|
||||||
|
status: SyncStatus.synced,
|
||||||
|
syncedAt: DateTime.now(),
|
||||||
|
attempts: event.attempts + 1,
|
||||||
|
clearError: true,
|
||||||
|
);
|
||||||
|
_store.updateEvent(done);
|
||||||
|
return done.copyWith();
|
||||||
|
} catch (e) {
|
||||||
|
final failed = event.copyWith(
|
||||||
|
status: SyncStatus.failed,
|
||||||
|
error: e.toString(),
|
||||||
|
attempts: event.attempts + 1,
|
||||||
|
);
|
||||||
|
_store.updateEvent(failed);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
lib/data/repositories/transaction_repository_impl.dart
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import '../../core/utils/extensions.dart';
|
||||||
|
import '../../domain/entities/transaction.dart';
|
||||||
|
import '../../domain/repositories/transaction_repository.dart';
|
||||||
|
import '../datasources/local_store.dart';
|
||||||
|
|
||||||
|
class TransactionRepositoryImpl implements TransactionRepository {
|
||||||
|
TransactionRepositoryImpl(this._store);
|
||||||
|
|
||||||
|
final LocalStore _store;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SaleTransaction> save(SaleTransaction transaction) async {
|
||||||
|
_store.addTransaction(transaction);
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SaleTransaction>> history({int limit = 50}) async =>
|
||||||
|
_store.transactions.take(limit).toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SaleTransaction?> findByInvoice(String invoiceNumber) async =>
|
||||||
|
_store.transactions
|
||||||
|
.firstWhereOrNull((t) => t.invoiceNumber == invoiceNumber);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> nextInvoiceSequence() async => _store.nextInvoiceSequence();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> park(ParkedBill bill) async => _store.addParked(bill);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ParkedBill>> parkedBills() async => _store.parked;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeParked(String id) async => _store.removeParked(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<double> salesTotalForDay(DateTime day) async {
|
||||||
|
return _store.transactions
|
||||||
|
.where((t) =>
|
||||||
|
t.status == TransactionStatus.completed &&
|
||||||
|
t.createdAt.year == day.year &&
|
||||||
|
t.createdAt.month == day.month &&
|
||||||
|
t.createdAt.day == day.day)
|
||||||
|
.fold(0.0, (sum, t) => sum + t.total)
|
||||||
|
.asMoney;
|
||||||
|
}
|
||||||
|
}
|
||||||
238
lib/domain/entities/cart.dart
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/utils/extensions.dart';
|
||||||
|
import 'customer.dart';
|
||||||
|
import 'product.dart';
|
||||||
|
|
||||||
|
/// How a discount value should be interpreted.
|
||||||
|
enum DiscountType { none, percentage, flat }
|
||||||
|
|
||||||
|
/// A discount applied to a single line or to the whole bill.
|
||||||
|
class Discount extends Equatable {
|
||||||
|
const Discount({this.type = DiscountType.none, this.value = 0, this.reason});
|
||||||
|
|
||||||
|
final DiscountType type;
|
||||||
|
final double value;
|
||||||
|
final String? reason;
|
||||||
|
|
||||||
|
static const Discount none = Discount();
|
||||||
|
|
||||||
|
bool get isActive => type != DiscountType.none && value > 0;
|
||||||
|
|
||||||
|
/// Resolves the discount to rupees against [base], never exceeding it.
|
||||||
|
double amountOn(double base) {
|
||||||
|
if (!isActive || base <= 0) return 0;
|
||||||
|
final raw = switch (type) {
|
||||||
|
DiscountType.percentage => base * (value / 100),
|
||||||
|
DiscountType.flat => value,
|
||||||
|
DiscountType.none => 0.0,
|
||||||
|
};
|
||||||
|
return raw.clamp(0, base).toDouble().asMoney;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get label => switch (type) {
|
||||||
|
DiscountType.percentage => '${value.toStringAsFixed(0)}% off',
|
||||||
|
DiscountType.flat => 'Flat ${AppConstants.currencySymbol}$value off',
|
||||||
|
DiscountType.none => 'No discount',
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [type, value, reason];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One product line inside the cart.
|
||||||
|
class CartLine extends Equatable {
|
||||||
|
const CartLine({
|
||||||
|
required this.product,
|
||||||
|
required this.quantity,
|
||||||
|
this.discount = Discount.none,
|
||||||
|
this.addedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Product product;
|
||||||
|
final double quantity;
|
||||||
|
final Discount discount;
|
||||||
|
final DateTime? addedAt;
|
||||||
|
|
||||||
|
String get id => product.id;
|
||||||
|
|
||||||
|
/// Line value before discount, GST inclusive.
|
||||||
|
double get grossAmount => (product.price * quantity).asMoney;
|
||||||
|
|
||||||
|
double get discountAmount => discount.amountOn(grossAmount);
|
||||||
|
|
||||||
|
/// Payable for this line after discount, GST inclusive.
|
||||||
|
double get payable => (grossAmount - discountAmount).asMoney;
|
||||||
|
|
||||||
|
/// Taxable value inside [payable].
|
||||||
|
double get taxableValue => (payable / (1 + product.gstRate)).asMoney;
|
||||||
|
|
||||||
|
/// GST rupees inside [payable].
|
||||||
|
double get taxAmount => (payable - taxableValue).asMoney;
|
||||||
|
|
||||||
|
double get cgst => (taxAmount / 2).asMoney;
|
||||||
|
double get sgst => (taxAmount / 2).asMoney;
|
||||||
|
|
||||||
|
double get mrpSavings => product.hasDiscount
|
||||||
|
? (product.savings * quantity).asMoney
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
bool get exceedsStock => quantity > product.stock;
|
||||||
|
|
||||||
|
CartLine copyWith({double? quantity, Discount? discount}) => CartLine(
|
||||||
|
product: product,
|
||||||
|
quantity: quantity ?? this.quantity,
|
||||||
|
discount: discount ?? this.discount,
|
||||||
|
addedAt: addedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [product.id, quantity, discount];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live bill. Immutable — every mutation returns a new instance, which
|
||||||
|
/// keeps the Riverpod notifier predictable and makes undo trivial.
|
||||||
|
class Cart extends Equatable {
|
||||||
|
const Cart({
|
||||||
|
this.lines = const [],
|
||||||
|
this.customer,
|
||||||
|
this.billDiscount = Discount.none,
|
||||||
|
this.pointsRedeemed = 0,
|
||||||
|
this.note,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<CartLine> lines;
|
||||||
|
final Customer? customer;
|
||||||
|
final Discount billDiscount;
|
||||||
|
final int pointsRedeemed;
|
||||||
|
final String? note;
|
||||||
|
|
||||||
|
static const Cart empty = Cart();
|
||||||
|
|
||||||
|
bool get isEmpty => lines.isEmpty;
|
||||||
|
bool get isNotEmpty => lines.isNotEmpty;
|
||||||
|
bool get isWalkIn => customer == null;
|
||||||
|
|
||||||
|
int get lineCount => lines.length;
|
||||||
|
|
||||||
|
double get totalQuantity =>
|
||||||
|
lines.fold(0.0, (sum, l) => sum + l.quantity);
|
||||||
|
|
||||||
|
/// Sum of line values before any bill-level discount, GST inclusive.
|
||||||
|
double get subtotal =>
|
||||||
|
lines.fold(0.0, (sum, l) => sum + l.payable).asMoney;
|
||||||
|
|
||||||
|
/// Discounts applied at the individual line level.
|
||||||
|
double get lineDiscountTotal =>
|
||||||
|
lines.fold(0.0, (sum, l) => sum + l.discountAmount).asMoney;
|
||||||
|
|
||||||
|
/// Automatic discount earned through the customer's membership tier.
|
||||||
|
Discount get membershipDiscount {
|
||||||
|
final rate = customer?.tier.discountRate ?? 0;
|
||||||
|
if (rate <= 0) return Discount.none;
|
||||||
|
return Discount(
|
||||||
|
type: DiscountType.percentage,
|
||||||
|
value: rate * 100,
|
||||||
|
reason: '${customer!.tier.label} member',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double get membershipDiscountAmount =>
|
||||||
|
membershipDiscount.amountOn(subtotal);
|
||||||
|
|
||||||
|
double get manualBillDiscountAmount => billDiscount.amountOn(subtotal);
|
||||||
|
|
||||||
|
/// All bill-level reductions combined.
|
||||||
|
double get billDiscountTotal =>
|
||||||
|
(membershipDiscountAmount + manualBillDiscountAmount)
|
||||||
|
.clamp(0, subtotal)
|
||||||
|
.toDouble()
|
||||||
|
.asMoney;
|
||||||
|
|
||||||
|
double get loyaltyRedemptionValue =>
|
||||||
|
(pointsRedeemed * AppConstants.loyaltyPointValue).asMoney;
|
||||||
|
|
||||||
|
/// Payable after every discount, GST inclusive, before round-off.
|
||||||
|
double get netAmount {
|
||||||
|
final v = subtotal - billDiscountTotal - loyaltyRedemptionValue;
|
||||||
|
return v.clamp(0, double.infinity).toDouble().asMoney;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proportion of the bill remaining after bill-level reductions. Used to
|
||||||
|
/// spread those reductions fairly across lines when apportioning GST.
|
||||||
|
double get _billFactor => subtotal <= 0 ? 1 : netAmount / subtotal;
|
||||||
|
|
||||||
|
/// GST payable across the bill, after apportioning bill-level discounts.
|
||||||
|
double get taxAmount =>
|
||||||
|
lines.fold(0.0, (sum, l) => sum + l.taxAmount * _billFactor).asMoney;
|
||||||
|
|
||||||
|
double get cgst => (taxAmount / 2).asMoney;
|
||||||
|
double get sgst => (taxAmount / 2).asMoney;
|
||||||
|
|
||||||
|
/// Taxable value across the bill.
|
||||||
|
double get taxableAmount => (netAmount - taxAmount).asMoney;
|
||||||
|
|
||||||
|
/// GST broken out per slab — required on a compliant tax invoice.
|
||||||
|
Map<double, double> get taxBreakdown {
|
||||||
|
final map = <double, double>{};
|
||||||
|
for (final line in lines) {
|
||||||
|
final rate = line.product.gstRate;
|
||||||
|
map[rate] = ((map[rate] ?? 0) + line.taxAmount * _billFactor).asMoney;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get grandTotal => netAmount.roundedToRupee;
|
||||||
|
|
||||||
|
/// The paise adjustment shown as "Round Off" on the bill.
|
||||||
|
double get roundOff => (grandTotal - netAmount).asMoney;
|
||||||
|
|
||||||
|
double get mrpSavingsTotal =>
|
||||||
|
lines.fold(0.0, (sum, l) => sum + l.mrpSavings).asMoney;
|
||||||
|
|
||||||
|
/// Everything the shopper saved on this bill.
|
||||||
|
double get totalSavings =>
|
||||||
|
(mrpSavingsTotal + lineDiscountTotal + billDiscountTotal).asMoney;
|
||||||
|
|
||||||
|
/// Points this sale will earn. Walk-in customers earn nothing.
|
||||||
|
int get pointsEarned {
|
||||||
|
if (customer == null) return 0;
|
||||||
|
return (grandTotal / AppConstants.loyaltyRupeesPerPoint).floor();
|
||||||
|
}
|
||||||
|
|
||||||
|
int get maxRedeemablePoints {
|
||||||
|
final c = customer;
|
||||||
|
if (c == null) return 0;
|
||||||
|
final byBalance = c.loyaltyPoints;
|
||||||
|
final byBill =
|
||||||
|
(subtotal - billDiscountTotal) ~/ AppConstants.loyaltyPointValue;
|
||||||
|
return byBalance < byBill ? byBalance : byBill;
|
||||||
|
}
|
||||||
|
|
||||||
|
CartLine? lineFor(String productId) =>
|
||||||
|
lines.firstWhereOrNull((l) => l.product.id == productId);
|
||||||
|
|
||||||
|
bool contains(String productId) => lineFor(productId) != null;
|
||||||
|
|
||||||
|
Cart copyWith({
|
||||||
|
List<CartLine>? lines,
|
||||||
|
Customer? customer,
|
||||||
|
bool clearCustomer = false,
|
||||||
|
Discount? billDiscount,
|
||||||
|
int? pointsRedeemed,
|
||||||
|
String? note,
|
||||||
|
}) {
|
||||||
|
return Cart(
|
||||||
|
lines: lines ?? this.lines,
|
||||||
|
customer: clearCustomer ? null : (customer ?? this.customer),
|
||||||
|
billDiscount: billDiscount ?? this.billDiscount,
|
||||||
|
pointsRedeemed: pointsRedeemed ?? this.pointsRedeemed,
|
||||||
|
note: note ?? this.note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props =>
|
||||||
|
[lines, customer, billDiscount, pointsRedeemed, note];
|
||||||
|
}
|
||||||
123
lib/domain/entities/customer.dart
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
|
||||||
|
enum Gender {
|
||||||
|
male('Male'),
|
||||||
|
female('Female'),
|
||||||
|
other('Other'),
|
||||||
|
unspecified('Prefer not to say');
|
||||||
|
|
||||||
|
const Gender(this.label);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loyalty tier, derived from lifetime spend.
|
||||||
|
enum MembershipTier {
|
||||||
|
bronze('Bronze', 0, 0.0),
|
||||||
|
silver('Silver', 10000, 0.02),
|
||||||
|
gold('Gold', 50000, 0.05),
|
||||||
|
platinum('Platinum', 150000, 0.08);
|
||||||
|
|
||||||
|
const MembershipTier(this.label, this.threshold, this.discountRate);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Lifetime spend in rupees required to reach this tier.
|
||||||
|
final double threshold;
|
||||||
|
|
||||||
|
/// Automatic bill discount granted to members of this tier.
|
||||||
|
final double discountRate;
|
||||||
|
|
||||||
|
static MembershipTier forSpend(double lifetimeSpend) {
|
||||||
|
return MembershipTier.values.lastWhere(
|
||||||
|
(t) => lifetimeSpend >= t.threshold,
|
||||||
|
orElse: () => MembershipTier.bronze,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MembershipTier? get next {
|
||||||
|
final i = index;
|
||||||
|
return i < MembershipTier.values.length - 1
|
||||||
|
? MembershipTier.values[i + 1]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registered shopper. A `null` customer on a sale means walk-in.
|
||||||
|
class Customer extends Equatable {
|
||||||
|
const Customer({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.mobile,
|
||||||
|
this.email,
|
||||||
|
this.gender = Gender.unspecified,
|
||||||
|
this.dateOfBirth,
|
||||||
|
this.loyaltyPoints = 0,
|
||||||
|
this.lifetimeSpend = 0,
|
||||||
|
this.visitCount = 0,
|
||||||
|
this.createdAt,
|
||||||
|
this.lastVisitAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String mobile;
|
||||||
|
final String? email;
|
||||||
|
final Gender gender;
|
||||||
|
final DateTime? dateOfBirth;
|
||||||
|
final int loyaltyPoints;
|
||||||
|
final double lifetimeSpend;
|
||||||
|
final int visitCount;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? lastVisitAt;
|
||||||
|
|
||||||
|
MembershipTier get tier => MembershipTier.forSpend(lifetimeSpend);
|
||||||
|
|
||||||
|
/// Cash value of the points currently held.
|
||||||
|
double get redeemableValue =>
|
||||||
|
loyaltyPoints * AppConstants.loyaltyPointValue;
|
||||||
|
|
||||||
|
/// Rupees of additional spend needed to reach the next tier.
|
||||||
|
double? get spendToNextTier {
|
||||||
|
final next = tier.next;
|
||||||
|
if (next == null) return null;
|
||||||
|
return (next.threshold - lifetimeSpend).clamp(0, double.infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isBirthdayToday {
|
||||||
|
final dob = dateOfBirth;
|
||||||
|
if (dob == null) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return dob.month == now.month && dob.day == now.day;
|
||||||
|
}
|
||||||
|
|
||||||
|
Customer copyWith({
|
||||||
|
String? name,
|
||||||
|
String? email,
|
||||||
|
Gender? gender,
|
||||||
|
DateTime? dateOfBirth,
|
||||||
|
int? loyaltyPoints,
|
||||||
|
double? lifetimeSpend,
|
||||||
|
int? visitCount,
|
||||||
|
DateTime? lastVisitAt,
|
||||||
|
}) {
|
||||||
|
return Customer(
|
||||||
|
id: id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
mobile: mobile,
|
||||||
|
email: email ?? this.email,
|
||||||
|
gender: gender ?? this.gender,
|
||||||
|
dateOfBirth: dateOfBirth ?? this.dateOfBirth,
|
||||||
|
loyaltyPoints: loyaltyPoints ?? this.loyaltyPoints,
|
||||||
|
lifetimeSpend: lifetimeSpend ?? this.lifetimeSpend,
|
||||||
|
visitCount: visitCount ?? this.visitCount,
|
||||||
|
createdAt: createdAt,
|
||||||
|
lastVisitAt: lastVisitAt ?? this.lastVisitAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, name, mobile, loyaltyPoints, lifetimeSpend];
|
||||||
|
}
|
||||||
134
lib/domain/entities/product.dart
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
|
||||||
|
/// Merchandise categories shown as filter chips on the POS dashboard.
|
||||||
|
enum ProductCategory {
|
||||||
|
dairy('Dairy', '🥛'),
|
||||||
|
grocery('Grocery', '🛒'),
|
||||||
|
fruits('Fruits', '🍎'),
|
||||||
|
vegetables('Vegetables', '🥦'),
|
||||||
|
beverages('Beverages', '🥤'),
|
||||||
|
snacks('Snacks', '🍪'),
|
||||||
|
personalCare('Personal Care', '🧴'),
|
||||||
|
household('Household', '🏠');
|
||||||
|
|
||||||
|
const ProductCategory(this.label, this.emoji);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unit of measure — drives whether fractional quantities are permitted.
|
||||||
|
enum UnitOfMeasure {
|
||||||
|
piece('pc'),
|
||||||
|
kilogram('kg'),
|
||||||
|
gram('g'),
|
||||||
|
litre('L'),
|
||||||
|
millilitre('ml'),
|
||||||
|
pack('pack');
|
||||||
|
|
||||||
|
const UnitOfMeasure(this.symbol);
|
||||||
|
|
||||||
|
final String symbol;
|
||||||
|
|
||||||
|
bool get allowsFractional =>
|
||||||
|
this == UnitOfMeasure.kilogram || this == UnitOfMeasure.litre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sellable item in the catalogue.
|
||||||
|
class Product extends Equatable {
|
||||||
|
const Product({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.barcode,
|
||||||
|
required this.sku,
|
||||||
|
required this.category,
|
||||||
|
required this.price,
|
||||||
|
required this.stock,
|
||||||
|
this.mrp,
|
||||||
|
this.emoji = '📦',
|
||||||
|
this.imageUrl,
|
||||||
|
this.unit = UnitOfMeasure.piece,
|
||||||
|
this.gstRate = AppConstants.defaultGstRate,
|
||||||
|
this.brand,
|
||||||
|
this.isActive = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String barcode;
|
||||||
|
final String sku;
|
||||||
|
final ProductCategory category;
|
||||||
|
|
||||||
|
/// Selling price per [unit], inclusive of GST (Indian retail convention).
|
||||||
|
final double price;
|
||||||
|
|
||||||
|
/// Printed maximum retail price, used to display savings.
|
||||||
|
final double? mrp;
|
||||||
|
|
||||||
|
final double stock;
|
||||||
|
final String emoji;
|
||||||
|
final String? imageUrl;
|
||||||
|
final UnitOfMeasure unit;
|
||||||
|
final double gstRate;
|
||||||
|
final String? brand;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
bool get isOutOfStock => stock <= 0;
|
||||||
|
bool get isLowStock =>
|
||||||
|
stock > 0 && stock <= AppConstants.lowStockThreshold;
|
||||||
|
|
||||||
|
bool get hasDiscount => mrp != null && mrp! > price;
|
||||||
|
|
||||||
|
double get savings => hasDiscount ? (mrp! - price) : 0;
|
||||||
|
|
||||||
|
double get discountPercent =>
|
||||||
|
hasDiscount ? ((mrp! - price) / mrp!) * 100 : 0;
|
||||||
|
|
||||||
|
/// Price stripped of embedded GST — the taxable value.
|
||||||
|
double get netPrice => price / (1 + gstRate);
|
||||||
|
|
||||||
|
/// GST rupees embedded inside [price].
|
||||||
|
double get taxPerUnit => price - netPrice;
|
||||||
|
|
||||||
|
/// Fuzzy match used by the search bar across name, barcode, SKU and brand.
|
||||||
|
bool matches(String query) {
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return true;
|
||||||
|
return name.toLowerCase().contains(q) ||
|
||||||
|
barcode.toLowerCase().contains(q) ||
|
||||||
|
sku.toLowerCase().contains(q) ||
|
||||||
|
(brand?.toLowerCase().contains(q) ?? false) ||
|
||||||
|
category.label.toLowerCase().contains(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
Product copyWith({
|
||||||
|
String? name,
|
||||||
|
double? price,
|
||||||
|
double? mrp,
|
||||||
|
double? stock,
|
||||||
|
ProductCategory? category,
|
||||||
|
bool? isActive,
|
||||||
|
}) {
|
||||||
|
return Product(
|
||||||
|
id: id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
barcode: barcode,
|
||||||
|
sku: sku,
|
||||||
|
category: category ?? this.category,
|
||||||
|
price: price ?? this.price,
|
||||||
|
mrp: mrp ?? this.mrp,
|
||||||
|
stock: stock ?? this.stock,
|
||||||
|
emoji: emoji,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
unit: unit,
|
||||||
|
gstRate: gstRate,
|
||||||
|
brand: brand,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, name, barcode, sku, price, stock, isActive];
|
||||||
|
}
|
||||||
152
lib/domain/entities/shift_report.dart
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/extensions.dart';
|
||||||
|
import 'transaction.dart';
|
||||||
|
|
||||||
|
/// Everything the back office needs from a day at this terminal.
|
||||||
|
///
|
||||||
|
/// Computed from the locally stored transactions, so it can be produced with
|
||||||
|
/// no connection and pushed whenever one is available.
|
||||||
|
class ShiftReport extends Equatable {
|
||||||
|
const ShiftReport({
|
||||||
|
required this.businessDate,
|
||||||
|
required this.terminalId,
|
||||||
|
required this.cashierName,
|
||||||
|
required this.billCount,
|
||||||
|
required this.itemCount,
|
||||||
|
required this.grossSales,
|
||||||
|
required this.taxCollected,
|
||||||
|
required this.discountGiven,
|
||||||
|
required this.roundOff,
|
||||||
|
required this.paymentBreakdown,
|
||||||
|
required this.loyaltyPointsIssued,
|
||||||
|
required this.loyaltyPointsRedeemed,
|
||||||
|
this.firstBillAt,
|
||||||
|
this.lastBillAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime businessDate;
|
||||||
|
final String terminalId;
|
||||||
|
final String cashierName;
|
||||||
|
|
||||||
|
final int billCount;
|
||||||
|
|
||||||
|
/// Total units sold across every bill.
|
||||||
|
final double itemCount;
|
||||||
|
|
||||||
|
final double grossSales;
|
||||||
|
final double taxCollected;
|
||||||
|
final double discountGiven;
|
||||||
|
final double roundOff;
|
||||||
|
|
||||||
|
final Map<PaymentMethod, double> paymentBreakdown;
|
||||||
|
|
||||||
|
final int loyaltyPointsIssued;
|
||||||
|
final int loyaltyPointsRedeemed;
|
||||||
|
|
||||||
|
final DateTime? firstBillAt;
|
||||||
|
final DateTime? lastBillAt;
|
||||||
|
|
||||||
|
/// A zeroed report for a day with no trading. Not a const — [DateTime]
|
||||||
|
/// cannot appear in a constant expression.
|
||||||
|
factory ShiftReport.blank({
|
||||||
|
required DateTime businessDate,
|
||||||
|
required String terminalId,
|
||||||
|
required String cashierName,
|
||||||
|
}) =>
|
||||||
|
ShiftReport(
|
||||||
|
businessDate: businessDate,
|
||||||
|
terminalId: terminalId,
|
||||||
|
cashierName: cashierName,
|
||||||
|
billCount: 0,
|
||||||
|
itemCount: 0,
|
||||||
|
grossSales: 0,
|
||||||
|
taxCollected: 0,
|
||||||
|
discountGiven: 0,
|
||||||
|
roundOff: 0,
|
||||||
|
paymentBreakdown: const {},
|
||||||
|
loyaltyPointsIssued: 0,
|
||||||
|
loyaltyPointsRedeemed: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get isEmpty => billCount == 0;
|
||||||
|
|
||||||
|
double get averageBasket =>
|
||||||
|
billCount == 0 ? 0 : (grossSales / billCount).asMoney;
|
||||||
|
|
||||||
|
double get netOfTax => (grossSales - taxCollected).asMoney;
|
||||||
|
|
||||||
|
/// Builds the report from the day's transactions.
|
||||||
|
factory ShiftReport.fromTransactions({
|
||||||
|
required List<SaleTransaction> transactions,
|
||||||
|
required DateTime businessDate,
|
||||||
|
required String terminalId,
|
||||||
|
required String cashierName,
|
||||||
|
}) {
|
||||||
|
final completed = transactions
|
||||||
|
.where((t) =>
|
||||||
|
t.status == TransactionStatus.completed &&
|
||||||
|
t.createdAt.year == businessDate.year &&
|
||||||
|
t.createdAt.month == businessDate.month &&
|
||||||
|
t.createdAt.day == businessDate.day)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
|
|
||||||
|
final byMethod = <PaymentMethod, double>{};
|
||||||
|
for (final t in completed) {
|
||||||
|
for (final p in t.payments) {
|
||||||
|
byMethod[p.method] = ((byMethod[p.method] ?? 0) + p.amount).asMoney;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ShiftReport(
|
||||||
|
businessDate: businessDate,
|
||||||
|
terminalId: terminalId,
|
||||||
|
cashierName: cashierName,
|
||||||
|
billCount: completed.length,
|
||||||
|
itemCount:
|
||||||
|
completed.fold(0.0, (s, t) => s + t.cart.totalQuantity),
|
||||||
|
grossSales: completed.fold(0.0, (s, t) => s + t.total).asMoney,
|
||||||
|
taxCollected:
|
||||||
|
completed.fold(0.0, (s, t) => s + t.cart.taxAmount).asMoney,
|
||||||
|
discountGiven: completed
|
||||||
|
.fold(0.0,
|
||||||
|
(s, t) => s + t.cart.billDiscountTotal + t.cart.lineDiscountTotal)
|
||||||
|
.asMoney,
|
||||||
|
roundOff: completed.fold(0.0, (s, t) => s + t.cart.roundOff).asMoney,
|
||||||
|
paymentBreakdown: byMethod,
|
||||||
|
loyaltyPointsIssued:
|
||||||
|
completed.fold(0, (s, t) => s + t.pointsEarned),
|
||||||
|
loyaltyPointsRedeemed:
|
||||||
|
completed.fold(0, (s, t) => s + t.pointsRedeemed),
|
||||||
|
firstBillAt: completed.isEmpty ? null : completed.first.createdAt,
|
||||||
|
lastBillAt: completed.isEmpty ? null : completed.last.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The JSON body that would be sent to the back office.
|
||||||
|
Map<String, Object?> toPayload() => {
|
||||||
|
'business_date': businessDate.toIso8601String().substring(0, 10),
|
||||||
|
'terminal_id': terminalId,
|
||||||
|
'cashier': cashierName,
|
||||||
|
'bill_count': billCount,
|
||||||
|
'item_count': itemCount,
|
||||||
|
'gross_sales': grossSales,
|
||||||
|
'net_of_tax': netOfTax,
|
||||||
|
'tax_collected': taxCollected,
|
||||||
|
'discount_given': discountGiven,
|
||||||
|
'round_off': roundOff,
|
||||||
|
'average_basket': averageBasket,
|
||||||
|
'loyalty_points_issued': loyaltyPointsIssued,
|
||||||
|
'loyalty_points_redeemed': loyaltyPointsRedeemed,
|
||||||
|
'first_bill_at': firstBillAt?.toIso8601String(),
|
||||||
|
'last_bill_at': lastBillAt?.toIso8601String(),
|
||||||
|
'payments': {
|
||||||
|
for (final e in paymentBreakdown.entries) e.key.name: e.value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props =>
|
||||||
|
[businessDate, terminalId, billCount, grossSales];
|
||||||
|
}
|
||||||
63
lib/domain/entities/store_account.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// What a staff member is allowed to do.
|
||||||
|
enum StaffRole {
|
||||||
|
admin('Admin', 'Full access to every module'),
|
||||||
|
manager('Manager', 'Sales, inventory and reports'),
|
||||||
|
cashier('Cashier', 'Billing and customers only');
|
||||||
|
|
||||||
|
const StaffRole(this.label, this.description);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
bool get canVoidSale => this != StaffRole.cashier;
|
||||||
|
bool get canEditPricing => this == StaffRole.admin;
|
||||||
|
bool get canViewReports => this != StaffRole.cashier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A person who signs in at the terminal.
|
||||||
|
class StaffUser extends Equatable {
|
||||||
|
const StaffUser({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.role,
|
||||||
|
required this.pin,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final StaffRole role;
|
||||||
|
|
||||||
|
/// Four-digit quick-unlock code. Never rendered.
|
||||||
|
final String pin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, name, role];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registered outlet this terminal belongs to.
|
||||||
|
class StoreAccount extends Equatable {
|
||||||
|
const StoreAccount({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.email,
|
||||||
|
required this.address,
|
||||||
|
required this.gstin,
|
||||||
|
required this.phone,
|
||||||
|
required this.staff,
|
||||||
|
this.plan = 'Business',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String email;
|
||||||
|
final String address;
|
||||||
|
final String gstin;
|
||||||
|
final String phone;
|
||||||
|
final List<StaffUser> staff;
|
||||||
|
final String plan;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, email];
|
||||||
|
}
|
||||||
85
lib/domain/entities/sync_event.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// The two moments this terminal talks to the server.
|
||||||
|
enum SyncEventType {
|
||||||
|
catalogueImport('Catalogue Import', 'Pulled products from the server'),
|
||||||
|
shiftReport('Shift Report', 'Pushed the day\'s takings to the server');
|
||||||
|
|
||||||
|
const SyncEventType(this.label, this.description);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
bool get isInbound => this == SyncEventType.catalogueImport;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
/// Held locally, not yet sent. Nothing is ever discarded in this state.
|
||||||
|
pending('Pending'),
|
||||||
|
syncing('Syncing'),
|
||||||
|
synced('Synced'),
|
||||||
|
failed('Failed');
|
||||||
|
|
||||||
|
const SyncStatus(this.label);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
bool get isTerminal => this == SyncStatus.synced;
|
||||||
|
bool get needsAttention => this == SyncStatus.failed || this == SyncStatus.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A durable record of one sync attempt.
|
||||||
|
///
|
||||||
|
/// Events are never deleted on failure — a failed push stays queued so the
|
||||||
|
/// day's takings survive a dropped connection.
|
||||||
|
class SyncEvent extends Equatable {
|
||||||
|
const SyncEvent({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.summary,
|
||||||
|
this.payload = const {},
|
||||||
|
this.syncedAt,
|
||||||
|
this.error,
|
||||||
|
this.attempts = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final SyncEventType type;
|
||||||
|
final SyncStatus status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// One-line description shown in the events log.
|
||||||
|
final String summary;
|
||||||
|
|
||||||
|
/// What would be transmitted. Kept so a retry needs no recomputation.
|
||||||
|
final Map<String, Object?> payload;
|
||||||
|
|
||||||
|
final DateTime? syncedAt;
|
||||||
|
final String? error;
|
||||||
|
final int attempts;
|
||||||
|
|
||||||
|
SyncEvent copyWith({
|
||||||
|
SyncStatus? status,
|
||||||
|
DateTime? syncedAt,
|
||||||
|
String? error,
|
||||||
|
bool clearError = false,
|
||||||
|
int? attempts,
|
||||||
|
}) {
|
||||||
|
return SyncEvent(
|
||||||
|
id: id,
|
||||||
|
type: type,
|
||||||
|
status: status ?? this.status,
|
||||||
|
createdAt: createdAt,
|
||||||
|
summary: summary,
|
||||||
|
payload: payload,
|
||||||
|
syncedAt: syncedAt ?? this.syncedAt,
|
||||||
|
error: clearError ? null : (error ?? this.error),
|
||||||
|
attempts: attempts ?? this.attempts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, status, attempts, syncedAt];
|
||||||
|
}
|
||||||
133
lib/domain/entities/transaction.dart
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/extensions.dart';
|
||||||
|
import 'cart.dart';
|
||||||
|
import 'customer.dart';
|
||||||
|
|
||||||
|
enum PaymentMethod {
|
||||||
|
cash('Cash', '💵', true),
|
||||||
|
card('Card', '💳', false),
|
||||||
|
upi('UPI', '📱', false),
|
||||||
|
wallet('Wallet', '👛', false),
|
||||||
|
giftCard('Gift Card', '🎁', false),
|
||||||
|
loyalty('Loyalty Points', '⭐', false);
|
||||||
|
|
||||||
|
const PaymentMethod(this.label, this.emoji, this.needsChange);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String emoji;
|
||||||
|
|
||||||
|
/// Only cash tenders can be over-paid and produce change.
|
||||||
|
final bool needsChange;
|
||||||
|
|
||||||
|
/// Non-cash tenders normally capture a reference number.
|
||||||
|
bool get needsReference =>
|
||||||
|
this == PaymentMethod.card ||
|
||||||
|
this == PaymentMethod.upi ||
|
||||||
|
this == PaymentMethod.giftCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single tender against a bill. A split payment holds several of these.
|
||||||
|
class PaymentSplit extends Equatable {
|
||||||
|
const PaymentSplit({
|
||||||
|
required this.method,
|
||||||
|
required this.amount,
|
||||||
|
this.tendered,
|
||||||
|
this.reference,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PaymentMethod method;
|
||||||
|
|
||||||
|
/// Amount settled by this tender.
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
/// Cash handed over — may exceed [amount].
|
||||||
|
final double? tendered;
|
||||||
|
|
||||||
|
/// Card approval code, UPI txn id, gift card number.
|
||||||
|
final String? reference;
|
||||||
|
|
||||||
|
double get change {
|
||||||
|
if (!method.needsChange || tendered == null) return 0;
|
||||||
|
final diff = tendered! - amount;
|
||||||
|
return diff > 0 ? diff.asMoney : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [method, amount, tendered, reference];
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransactionStatus { completed, parked, voided, refunded }
|
||||||
|
|
||||||
|
/// An immutable record of a finished sale.
|
||||||
|
class SaleTransaction extends Equatable {
|
||||||
|
const SaleTransaction({
|
||||||
|
required this.id,
|
||||||
|
required this.invoiceNumber,
|
||||||
|
required this.cart,
|
||||||
|
required this.payments,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.cashierName,
|
||||||
|
this.status = TransactionStatus.completed,
|
||||||
|
this.terminalId = 'TERM-01',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String invoiceNumber;
|
||||||
|
final Cart cart;
|
||||||
|
final List<PaymentSplit> payments;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String cashierName;
|
||||||
|
final TransactionStatus status;
|
||||||
|
final String terminalId;
|
||||||
|
|
||||||
|
Customer? get customer => cart.customer;
|
||||||
|
|
||||||
|
double get total => cart.grandTotal;
|
||||||
|
|
||||||
|
double get amountPaid =>
|
||||||
|
payments.fold(0.0, (sum, p) => sum + p.amount).asMoney;
|
||||||
|
|
||||||
|
double get amountTendered => payments
|
||||||
|
.fold(0.0, (sum, p) => sum + (p.tendered ?? p.amount))
|
||||||
|
.asMoney;
|
||||||
|
|
||||||
|
double get changeDue =>
|
||||||
|
payments.fold(0.0, (sum, p) => sum + p.change).asMoney;
|
||||||
|
|
||||||
|
double get balanceDue => (total - amountPaid).clamp(0, double.infinity);
|
||||||
|
|
||||||
|
bool get isFullySettled => balanceDue <= 0.001;
|
||||||
|
|
||||||
|
bool get isSplit => payments.length > 1;
|
||||||
|
|
||||||
|
int get pointsEarned => cart.pointsEarned;
|
||||||
|
int get pointsRedeemed => cart.pointsRedeemed;
|
||||||
|
|
||||||
|
String get paymentSummary =>
|
||||||
|
payments.map((p) => p.method.label).toSet().join(' + ');
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, invoiceNumber, createdAt, status];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bill set aside so the cashier can serve the next shopper.
|
||||||
|
class ParkedBill extends Equatable {
|
||||||
|
const ParkedBill({
|
||||||
|
required this.id,
|
||||||
|
required this.cart,
|
||||||
|
required this.parkedAt,
|
||||||
|
this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final Cart cart;
|
||||||
|
final DateTime parkedAt;
|
||||||
|
final String? label;
|
||||||
|
|
||||||
|
String get displayLabel =>
|
||||||
|
label ?? cart.customer?.name ?? 'Walk-in #${id.substring(0, 4)}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, parkedAt];
|
||||||
|
}
|
||||||