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