diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3820a95
--- /dev/null
+++ b/.gitignore
@@ -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
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..768732a
--- /dev/null
+++ b/.metadata
@@ -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'
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..74a5682
--- /dev/null
+++ b/analysis_options.yaml
@@ -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
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..b91bb34
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -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 = "../.."
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..aab3ce0
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/example/nearle_pos/MainActivity.kt b/android/app/src/main/kotlin/com/example/nearle_pos/MainActivity.kt
new file mode 100644
index 0000000..a9f05de
--- /dev/null
+++ b/android/app/src/main/kotlin/com/example/nearle_pos/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.example.nearle_pos
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..e96108c
--- /dev/null
+++ b/android/gradle.properties
@@ -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
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..2d428bf
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..c21f0c5
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -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")
diff --git a/assets/sounds/beep_error.wav b/assets/sounds/beep_error.wav
new file mode 100644
index 0000000..715ab6f
Binary files /dev/null and b/assets/sounds/beep_error.wav differ
diff --git a/assets/sounds/beep_success.wav b/assets/sounds/beep_success.wav
new file mode 100644
index 0000000..25edfa0
Binary files /dev/null and b/assets/sounds/beep_success.wav differ
diff --git a/assets/sounds/charge_complete.wav b/assets/sounds/charge_complete.wav
new file mode 100644
index 0000000..641c1e3
Binary files /dev/null and b/assets/sounds/charge_complete.wav differ
diff --git a/ios/.gitignore b/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/ios/.gitignore
@@ -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
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..391a902
--- /dev/null
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+
+
diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Release.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..10f232d
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -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 = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ 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 = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.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 */;
+}
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..c3fedb2
--- /dev/null
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..1d526a1
--- /dev/null
+++ b/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..c30b367
--- /dev/null
+++ b/ios/Runner/AppDelegate.swift
@@ -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)
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..d36b1fa
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -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"
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000..dc9ada4
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000..7353c41
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000..797d452
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000..6ed2d93
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000..4cd7b00
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000..fe73094
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000..321773c
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..797d452
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000..502f463
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000..0ec3034
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000..0ec3034
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000..e9f5fea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..84ac32a
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000..8953cba
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000..0467bf1
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -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"
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -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.
\ No newline at end of file
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
new file mode 100644
index 0000000..c65aa8f
--- /dev/null
+++ b/ios/Runner/Info.plist
@@ -0,0 +1,70 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Nearle Pos
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ nearle_pos
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift
new file mode 100644
index 0000000..b9ce8ea
--- /dev/null
+++ b/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}
diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/ios/RunnerTests/RunnerTests.swift
@@ -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.
+ }
+
+}
diff --git a/lib/app/app.dart b/lib/app/app.dart
new file mode 100644
index 0000000..932201e
--- /dev/null
+++ b/lib/app/app.dart
@@ -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(),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/app/providers.dart b/lib/app/providers.dart
new file mode 100644
index 0000000..a51b93c
--- /dev/null
+++ b/lib/app/providers.dart
@@ -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((ref) => LocalStore.instance);
+
+// ---------------------------------------------------------- Repositories
+final productRepositoryProvider = Provider(
+ (ref) => ProductRepositoryImpl(ref.watch(localStoreProvider)),
+);
+
+final customerRepositoryProvider = Provider(
+ (ref) => CustomerRepositoryImpl(ref.watch(localStoreProvider)),
+);
+
+final transactionRepositoryProvider = Provider(
+ (ref) => TransactionRepositoryImpl(ref.watch(localStoreProvider)),
+);
+
+/// Simulated back-office endpoints. Held as singletons so the offline toggle
+/// in Settings affects every call.
+final remoteCatalogueProvider =
+ Provider((ref) => RemoteCatalogueSource());
+
+final remoteReportSinkProvider =
+ Provider((ref) => RemoteReportSink());
+
+final syncRepositoryProvider = Provider(
+ (ref) => SyncRepositoryImpl(
+ ref.watch(localStoreProvider),
+ ref.watch(remoteCatalogueProvider),
+ ref.watch(remoteReportSinkProvider),
+ ),
+);
+
+// ------------------------------------------------------------- Use cases
+final checkoutSaleProvider = Provider(
+ (ref) => CheckoutSale(
+ productRepository: ref.watch(productRepositoryProvider),
+ customerRepository: ref.watch(customerRepositoryProvider),
+ transactionRepository: ref.watch(transactionRepositoryProvider),
+ ),
+);
+
+// -------------------------------------------------------------- Services
+final soundServiceProvider =
+ Provider((ref) => SoundService.instance);
+
+final receiptServiceProvider =
+ Provider((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(
+ (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((ref) async* {
+ yield DateTime.now();
+ yield* Stream.periodic(const Duration(seconds: 20), (_) => DateTime.now());
+});
diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart
new file mode 100644
index 0000000..4fbe5ff
--- /dev/null
+++ b/lib/core/constants/app_constants.dart
@@ -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';
+}
diff --git a/lib/core/constants/asset_paths.dart b/lib/core/constants/asset_paths.dart
new file mode 100644
index 0000000..c833145
--- /dev/null
+++ b/lib/core/constants/asset_paths.dart
@@ -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';
+}
diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart
new file mode 100644
index 0000000..1e2685b
--- /dev/null
+++ b/lib/core/router/app_router.dart
@@ -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((ref) {
+ // GoRouter re-evaluates `redirect` whenever this notifier fires.
+ final authChanged = ValueNotifier(
+ ref.read(authControllerProvider).isAuthenticated,
+ );
+ ref.listen(
+ 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 _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 _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(
+ begin: const Offset(0, 0.03),
+ end: Offset.zero,
+ ).animate(curved),
+ child: child,
+ ),
+ );
+ },
+ );
+}
diff --git a/lib/core/services/barcode_service.dart b/lib/core/services/barcode_service.dart
new file mode 100644
index 0000000..b1798d1
--- /dev/null
+++ b/lib/core/services/barcode_service.dart
@@ -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();
+}
diff --git a/lib/core/services/receipt_service.dart b/lib/core/services/receipt_service.dart
new file mode 100644
index 0000000..05c27a4
--- /dev/null
+++ b/lib/core/services/receipt_service.dart
@@ -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 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 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 printWithDialog(SaleTransaction txn) async {
+ final bytes = await build(txn);
+ await Printing.layoutPdf(
+ onLayout: (_) async => bytes,
+ name: txn.invoiceNumber,
+ );
+ }
+
+ Future 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 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');
+ }
+}
diff --git a/lib/core/services/sound_service.dart b/lib/core/services/sound_service.dart
new file mode 100644
index 0000000..1f4fdcf
--- /dev/null
+++ b/lib/core/services/sound_service.dart
@@ -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 preload() async {
+ try {
+ await _player.setReleaseMode(ReleaseMode.stop);
+ } catch (e) {
+ debugPrint('SoundService preload failed: $e');
+ }
+ }
+
+ Future scanSuccess() => _play(AssetPaths.beepSuccess, haptic: true);
+
+ Future scanError() => _play(AssetPaths.beepError, heavy: true);
+
+ Future saleComplete() => _play(AssetPaths.chargeComplete);
+
+ Future _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 dispose() => _player.dispose();
+}
diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart
new file mode 100644
index 0000000..4fc41d3
--- /dev/null
+++ b/lib/core/theme/app_colors.dart
@@ -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 shadowSm = [
+ BoxShadow(color: Color(0x0D17131C), blurRadius: 6, offset: Offset(0, 2)),
+ ];
+
+ static const List shadowMd = [
+ BoxShadow(color: Color(0x1417131C), blurRadius: 16, offset: Offset(0, 6)),
+ ];
+
+ static const List 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)],
+ );
+}
diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart
new file mode 100644
index 0000000..14ab9cb
--- /dev/null
+++ b/lib/core/theme/app_dimens.dart
@@ -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;
+}
diff --git a/lib/core/theme/app_layout.dart b/lib/core/theme/app_layout.dart
new file mode 100644
index 0000000..fdf0273
--- /dev/null
+++ b/lib/core/theme/app_layout.dart
@@ -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;
+}
diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart
new file mode 100644
index 0000000..cfa1f25
--- /dev/null
+++ b/lib/core/theme/app_theme.dart
@@ -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),
+ );
+ }
+}
diff --git a/lib/core/theme/app_typography.dart b/lib/core/theme/app_typography.dart
new file mode 100644
index 0000000..50cdb8e
--- /dev/null
+++ b/lib/core/theme/app_typography.dart
@@ -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,
+ );
+}
diff --git a/lib/core/utils/extensions.dart b/lib/core/utils/extensions.dart
new file mode 100644
index 0000000..55f750b
--- /dev/null
+++ b/lib/core/utils/extensions.dart
@@ -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({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 on Iterable {
+ T? firstWhereOrNull(bool Function(T) test) {
+ for (final element in this) {
+ if (test(element)) return element;
+ }
+ return null;
+ }
+}
diff --git a/lib/core/utils/formatters.dart b/lib/core/utils/formatters.dart
new file mode 100644
index 0000000..055e1ce
--- /dev/null
+++ b/lib/core/utils/formatters.dart
@@ -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')}';
+ }
+}
diff --git a/lib/core/utils/validators.dart b/lib/core/utils/validators.dart
new file mode 100644
index 0000000..e0c1a8a
--- /dev/null
+++ b/lib/core/utils/validators.dart
@@ -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);
+ }
+}
diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart
new file mode 100644
index 0000000..c7c35f2
--- /dev/null
+++ b/lib/core/widgets/empty_state.dart
@@ -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!,
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/core/widgets/glass_card.dart b/lib/core/widgets/glass_card.dart
new file mode 100644
index 0000000..dc787a4
--- /dev/null
+++ b/lib/core/widgets/glass_card.dart
@@ -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? 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,
+ ),
+ );
+ }
+}
diff --git a/lib/core/widgets/numeric_keypad.dart b/lib/core/widgets/numeric_keypad.dart
new file mode 100644
index 0000000..d9fccee
--- /dev/null
+++ b/lib/core/widgets/numeric_keypad.dart
@@ -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 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 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,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/core/widgets/primary_button.dart b/lib/core/widgets/primary_button.dart
new file mode 100644
index 0000000..9e7fa62
--- /dev/null
+++ b/lib/core/widgets/primary_button.dart
@@ -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
+ ),
+ };
+}
diff --git a/lib/core/widgets/status_pill.dart b/lib/core/widgets/status_pill.dart
new file mode 100644
index 0000000..fdf3a89
--- /dev/null
+++ b/lib/core/widgets/status_pill.dart
@@ -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,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart
new file mode 100644
index 0000000..ae6c98d
--- /dev/null
+++ b/lib/data/datasources/local_store.dart
@@ -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 _products = {};
+ final Map _customers = {};
+ final List _transactions = [];
+ final List _parked = [];
+ final List _events = [];
+
+ int _invoiceSequence = 0;
+
+ DateTime? _lastImportAt;
+ String? _catalogueRevision;
+
+ /// Nothing to seed — the catalogue arrives via import.
+ Future init() async {}
+
+ /// Test helper. Clears everything and optionally loads the demo catalogue
+ /// so fixtures don't have to run an import first.
+ Future 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 products,
+ required List 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 get products => _products.values.toList(growable: false);
+
+ void putProduct(Product p) => _products[p.id] = p;
+
+ Product? productById(String id) => _products[id];
+
+ // -------------------------------------------------------------- Customers
+ List get customers => _customers.values.toList(growable: false);
+
+ void putCustomer(Customer c) => _customers[c.id] = c;
+
+ Customer? customerById(String id) => _customers[id];
+
+ // ----------------------------------------------------------- Transactions
+ List get transactions =>
+ List.unmodifiable(_transactions.reversed);
+
+ void addTransaction(SaleTransaction t) => _transactions.add(t);
+
+ int nextInvoiceSequence() => ++_invoiceSequence;
+
+ // ------------------------------------------------------------ Parked bills
+ List 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 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);
+}
diff --git a/lib/data/datasources/remote_catalogue_source.dart b/lib/data/datasources/remote_catalogue_source.dart
new file mode 100644
index 0000000..73832bb
--- /dev/null
+++ b/lib/data/datasources/remote_catalogue_source.dart
@@ -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 products;
+ final List 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 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.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 push(Map payload) async {
+ await Future.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}';
+ }
+}
diff --git a/lib/data/datasources/seed_data.dart b/lib/data/datasources/seed_data.dart
new file mode 100644
index 0000000..11d51c5
--- /dev/null
+++ b/lib/data/datasources/seed_data.dart
@@ -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 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 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)),
+ ),
+ ];
+}
diff --git a/lib/data/repositories/customer_repository_impl.dart b/lib/data/repositories/customer_repository_impl.dart
new file mode 100644
index 0000000..83d543b
--- /dev/null
+++ b/lib/data/repositories/customer_repository_impl.dart
@@ -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 findByMobile(String mobile) async {
+ final needle = _digits(mobile);
+ return _store.customers
+ .firstWhereOrNull((c) => _digits(c.mobile) == needle);
+ }
+
+ @override
+ Future findById(String id) async => _store.customerById(id);
+
+ @override
+ Future 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 update(Customer customer) async {
+ _store.putCustomer(customer);
+ return customer;
+ }
+
+ @override
+ Future 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> 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> 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();
+ }
+}
diff --git a/lib/data/repositories/product_repository_impl.dart b/lib/data/repositories/product_repository_impl.dart
new file mode 100644
index 0000000..442aca0
--- /dev/null
+++ b/lib/data/repositories/product_repository_impl.dart
@@ -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> getAll() async =>
+ _store.products.where((p) => p.isActive).toList();
+
+ @override
+ Future> getByCategory(ProductCategory category) async =>
+ _store.products
+ .where((p) => p.isActive && p.category == category)
+ .toList();
+
+ @override
+ Future findByBarcode(String barcode) async {
+ final needle = barcode.trim();
+ return _store.products.firstWhereOrNull(
+ (p) => p.barcode == needle && p.isActive,
+ );
+ }
+
+ @override
+ Future findById(String id) async => _store.productById(id);
+
+ @override
+ Future> 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 decrementStock(Map 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 upsert(Product product) async => _store.putProduct(product);
+}
diff --git a/lib/data/repositories/sync_repository_impl.dart b/lib/data/repositories/sync_repository_impl.dart
new file mode 100644
index 0000000..6f16c1b
--- /dev/null
+++ b/lib/data/repositories/sync_repository_impl.dart
@@ -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 get events => _store.events;
+
+ @override
+ bool get hasUnsyncedEvents => _store.hasUnsyncedEvents;
+
+ @override
+ Future 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 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 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 _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;
+ }
+ }
+}
diff --git a/lib/data/repositories/transaction_repository_impl.dart b/lib/data/repositories/transaction_repository_impl.dart
new file mode 100644
index 0000000..e23584b
--- /dev/null
+++ b/lib/data/repositories/transaction_repository_impl.dart
@@ -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 save(SaleTransaction transaction) async {
+ _store.addTransaction(transaction);
+ return transaction;
+ }
+
+ @override
+ Future> history({int limit = 50}) async =>
+ _store.transactions.take(limit).toList();
+
+ @override
+ Future findByInvoice(String invoiceNumber) async =>
+ _store.transactions
+ .firstWhereOrNull((t) => t.invoiceNumber == invoiceNumber);
+
+ @override
+ Future nextInvoiceSequence() async => _store.nextInvoiceSequence();
+
+ @override
+ Future park(ParkedBill bill) async => _store.addParked(bill);
+
+ @override
+ Future> parkedBills() async => _store.parked;
+
+ @override
+ Future removeParked(String id) async => _store.removeParked(id);
+
+ @override
+ Future 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;
+ }
+}
diff --git a/lib/domain/entities/cart.dart b/lib/domain/entities/cart.dart
new file mode 100644
index 0000000..7155b36
--- /dev/null
+++ b/lib/domain/entities/cart.dart
@@ -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