second commit

This commit is contained in:
2026-07-29 11:41:53 +05:30
parent fcccf22bac
commit d72522e737
211 changed files with 19260 additions and 0 deletions

45
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

View 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 = "../.."
}

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

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

View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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

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

View 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
View 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)
}

View 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

View 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

View 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")

Binary file not shown.

Binary file not shown.

Binary file not shown.

34
ios/.gitignore vendored Normal file
View 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

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

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View 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 */;
}

View File

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

View File

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

View File

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

View 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 &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<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>

View File

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

View File

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

View File

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

View 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)
}
}

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View 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.

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

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

View File

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

View File

@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View 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
View 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
View 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());
});

View 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';
}

View 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';
}

View 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,
),
);
},
);
}

View 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();
}

View 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');
}
}

View 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();
}

View 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)],
);
}

View 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;
}

View 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;
}

View 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),
);
}
}

View 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,
);
}

View 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;
}
}

View 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')}';
}
}

View 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);
}
}

View 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!,
],
],
),
),
);
}
}

View 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,
),
);
}
}

View 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,
),
),
),
);
}
}

View 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
),
};
}

View 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,
),
),
],
),
);
}
}

View 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);
}

View 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}';
}
}

View 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)),
),
];
}

View 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();
}
}

View 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);
}

View 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;
}
}
}

View 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;
}
}

View 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];
}

View 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];
}

View 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];
}

View 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];
}

View 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];
}

View 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];
}

View 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];
}

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