feat: initial Velmart Picker Flutter app
- All 5 batch steps: Picking, Sorting, Clarification, Slots, Handoff - go_router navigation with step indicator - graphql_flutter client wired up (endpoint via env var) - Mock data layer swappable with real GraphQL service - Item types: normal, cold, frozen, alcohol, clarify - Storage slot assignment (cell/freezer/fridge)
This commit is contained in:
commit
5ba2691eac
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Flutter/Dart
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
build/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android/.gradle
|
||||||
|
android/local.properties
|
||||||
|
android/key.properties
|
||||||
|
android/app/google-services.json
|
||||||
|
|
||||||
|
# Web
|
||||||
|
web/
|
||||||
|
!web/index.html
|
||||||
|
!web/manifest.json
|
||||||
|
!web/favicon.png
|
||||||
|
!web/icons/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
33
.metadata
Normal file
33
.metadata
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7"
|
||||||
|
channel: "[user-branch]"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||||
|
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||||
|
- platform: android
|
||||||
|
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||||
|
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||||
|
- platform: web
|
||||||
|
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||||
|
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||||
|
|
||||||
|
# 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'
|
||||||
17
README.md
Normal file
17
README.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# velmart_picker
|
||||||
|
|
||||||
|
A new Flutter project.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||||
|
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||||
|
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||||
|
|
||||||
|
For help getting started with Flutter development, view the
|
||||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
28
analysis_options.yaml
Normal file
28
analysis_options.yaml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
# The lint rules applied to this project can be customized in the
|
||||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
# included above or to enable additional rules. A list of all available lints
|
||||||
|
# and their documentation is published at https://dart.dev/lints.
|
||||||
|
#
|
||||||
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
|
# section below, it can also be suppressed for a single line of code
|
||||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
14
android/.gitignore
vendored
Normal file
14
android/.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/to/reference-keystore
|
||||||
|
key.properties
|
||||||
|
**/*.keystore
|
||||||
|
**/*.jks
|
||||||
44
android/app/build.gradle.kts
Normal file
44
android/app/build.gradle.kts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.velmart_picker"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.example.velmart_picker"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
7
android/app/src/debug/AndroidManifest.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
45
android/app/src/main/AndroidManifest.xml
Normal file
45
android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="velmart_picker"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.example.velmart_picker
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
18
android/app/src/main/res/values-night/styles.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
18
android/app/src/main/res/values/styles.xml
Normal file
18
android/app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
7
android/app/src/profile/AndroidManifest.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
24
android/build.gradle.kts
Normal file
24
android/build.gradle.kts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newBuildDir: Directory =
|
||||||
|
rootProject.layout.buildDirectory
|
||||||
|
.dir("../../build")
|
||||||
|
.get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(":app")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
2
android/gradle.properties
Normal file
2
android/gradle.properties
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||||
26
android/settings.gradle.kts
Normal file
26
android/settings.gradle.kts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
pluginManagement {
|
||||||
|
val flutterSdkPath =
|
||||||
|
run {
|
||||||
|
val properties = java.util.Properties()
|
||||||
|
file("local.properties").inputStream().use { properties.load(it) }
|
||||||
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||||
|
flutterSdkPath
|
||||||
|
}
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
|
id("com.android.application") version "8.11.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ghcr.io/cirruslabs/flutter:stable
|
||||||
|
working_dir: /app
|
||||||
|
command: >
|
||||||
|
bash -c "flutter pub get &&
|
||||||
|
flutter run -d web-server
|
||||||
|
--web-port 8080
|
||||||
|
--web-hostname 0.0.0.0
|
||||||
|
--dart-define=GRAPHQL_ENDPOINT=$${GRAPHQL_ENDPOINT:-https://your-magento-store.com/graphql}"
|
||||||
|
environment:
|
||||||
|
- GRAPHQL_ENDPOINT=${GRAPHQL_ENDPOINT:-https://your-magento-store.com/graphql}
|
||||||
|
labels:
|
||||||
|
- fibe.gg/name=velmart-picker
|
||||||
|
- fibe.gg/port=8080
|
||||||
|
- fibe.gg/production=false
|
||||||
|
- traefik.enable=true
|
||||||
90
lib/app.dart
Normal file
90
lib/app.dart
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'features/auth/auth_screen.dart';
|
||||||
|
import 'features/clarification/clarification_screen.dart';
|
||||||
|
import 'features/handoff/handoff_screen.dart';
|
||||||
|
import 'features/order_detail/order_detail_screen.dart';
|
||||||
|
import 'features/orders/orders_list_screen.dart';
|
||||||
|
import 'features/picking/picking_screen.dart';
|
||||||
|
import 'features/slots/slots_screen.dart';
|
||||||
|
import 'features/sorting/sorting_screen.dart';
|
||||||
|
import 'core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
final _router = GoRouter(
|
||||||
|
initialLocation: '/orders',
|
||||||
|
redirect: (context, state) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final token = prefs.getString('auth_token');
|
||||||
|
final onAuth = state.matchedLocation == '/auth';
|
||||||
|
if (token == null && !onAuth) return '/auth';
|
||||||
|
if (token != null && onAuth) return '/orders';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/auth',
|
||||||
|
builder: (_, __) => const AuthScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/orders',
|
||||||
|
builder: (_, __) => const OrdersListScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: ':orderId',
|
||||||
|
builder: (_, state) => OrderDetailScreen(
|
||||||
|
orderId: int.parse(state.pathParameters['orderId']!),
|
||||||
|
),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'picking/:segmentId',
|
||||||
|
builder: (_, state) => PickingScreen(
|
||||||
|
orderId: int.parse(state.pathParameters['orderId']!),
|
||||||
|
segmentId: int.parse(state.pathParameters['segmentId']!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'sorting',
|
||||||
|
builder: (_, state) => SortingScreen(
|
||||||
|
orderId: int.parse(state.pathParameters['orderId']!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'clarification',
|
||||||
|
builder: (_, state) => ClarificationScreen(
|
||||||
|
orderId: int.parse(state.pathParameters['orderId']!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'slots',
|
||||||
|
builder: (_, state) => SlotsScreen(
|
||||||
|
orderId: int.parse(state.pathParameters['orderId']!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'handoff',
|
||||||
|
builder: (_, state) => HandoffScreen(
|
||||||
|
orderId: int.parse(state.pathParameters['orderId']!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
class VelmartPickerApp extends StatelessWidget {
|
||||||
|
const VelmartPickerApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp.router(
|
||||||
|
title: 'Велмарт Комплектовщик',
|
||||||
|
theme: AppTheme.theme,
|
||||||
|
routerConfig: _router,
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
lib/core/theme/app_theme.dart
Normal file
88
lib/core/theme/app_theme.dart
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AppColors {
|
||||||
|
static const Color primary = Color(0xFF1B5E20);
|
||||||
|
static const Color primaryLight = Color(0xFF388E3C);
|
||||||
|
static const Color accent = Color(0xFFFF6F00);
|
||||||
|
static const Color surface = Color(0xFFFFFFFF);
|
||||||
|
static const Color background = Color(0xFFF5F5F5);
|
||||||
|
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||||
|
static const Color textPrimary = Color(0xFF1A1A1A);
|
||||||
|
static const Color textSecondary = Color(0xFF757575);
|
||||||
|
static const Color divider = Color(0xFFE0E0E0);
|
||||||
|
static const Color success = Color(0xFF2E7D32);
|
||||||
|
static const Color warning = Color(0xFFF57F17);
|
||||||
|
static const Color error = Color(0xFFC62828);
|
||||||
|
static const Color info = Color(0xFF0277BD);
|
||||||
|
|
||||||
|
// Status colors
|
||||||
|
static const Color statusNew = Color(0xFF1565C0);
|
||||||
|
static const Color statusInProgress = Color(0xFFE65100);
|
||||||
|
static const Color statusPicked = Color(0xFF2E7D32);
|
||||||
|
static const Color statusStored = Color(0xFF6A1B9A);
|
||||||
|
static const Color statusIssued = Color(0xFF424242);
|
||||||
|
|
||||||
|
// Item type colors
|
||||||
|
static const Color itemNormal = Color(0xFFFFFFFF);
|
||||||
|
static const Color itemCold = Color(0xFFE3F2FD);
|
||||||
|
static const Color itemFrozen = Color(0xFFE8EAF6);
|
||||||
|
static const Color itemAlcohol = Color(0xFFFFF8E1);
|
||||||
|
static const Color itemClarify = Color(0xFFFFF3E0);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppTheme {
|
||||||
|
static ThemeData get theme => ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: AppColors.primary,
|
||||||
|
primary: AppColors.primary,
|
||||||
|
secondary: AppColors.accent,
|
||||||
|
surface: AppColors.surface,
|
||||||
|
error: AppColors.error,
|
||||||
|
),
|
||||||
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cardTheme: CardTheme(
|
||||||
|
elevation: 1,
|
||||||
|
color: AppColors.cardBackground,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 52),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
headlineMedium: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
|
||||||
|
headlineSmall: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
|
||||||
|
titleLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||||
|
titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||||
|
bodyLarge: TextStyle(fontSize: 16, color: AppColors.textPrimary),
|
||||||
|
bodyMedium: TextStyle(fontSize: 14, color: AppColors.textPrimary),
|
||||||
|
bodySmall: TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||||
|
labelLarge: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surface,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(color: AppColors.divider, thickness: 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
62
lib/core/widgets/order_progress_bar.dart
Normal file
62
lib/core/widgets/order_progress_bar.dart
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
class OrderProgressBar extends StatelessWidget {
|
||||||
|
final int picked;
|
||||||
|
final int total;
|
||||||
|
final bool showLabel;
|
||||||
|
|
||||||
|
const OrderProgressBar({
|
||||||
|
super.key,
|
||||||
|
required this.picked,
|
||||||
|
required this.total,
|
||||||
|
this.showLabel = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final progress = total == 0 ? 0.0 : picked / total;
|
||||||
|
final isComplete = picked == total;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (showLabel)
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$picked / $total артикулів',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
if (isComplete)
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, size: 14, color: AppColors.success),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text('100%', style: TextStyle(fontSize: 12, color: AppColors.success, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
'${(progress * 100).toInt()}%',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (showLabel) const SizedBox(height: 6),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
minHeight: 6,
|
||||||
|
backgroundColor: AppColors.divider,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
isComplete ? AppColors.success : AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
lib/core/widgets/step_indicator.dart
Normal file
83
lib/core/widgets/step_indicator.dart
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../data/models/models.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
class StepIndicator extends StatelessWidget {
|
||||||
|
final BatchStep currentStep;
|
||||||
|
|
||||||
|
const StepIndicator({super.key, required this.currentStep});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final steps = BatchStep.values;
|
||||||
|
return Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: steps.map((step) {
|
||||||
|
final isActive = step == currentStep;
|
||||||
|
final isDone = step.index < currentStep.index;
|
||||||
|
return Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_StepDot(isActive: isActive, isDone: isDone, step: step),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
step.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: isActive ? FontWeight.w700 : FontWeight.w400,
|
||||||
|
color: isActive
|
||||||
|
? AppColors.primary
|
||||||
|
: isDone
|
||||||
|
? AppColors.success
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StepDot extends StatelessWidget {
|
||||||
|
final bool isActive;
|
||||||
|
final bool isDone;
|
||||||
|
final BatchStep step;
|
||||||
|
|
||||||
|
const _StepDot({required this.isActive, required this.isDone, required this.step});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isActive
|
||||||
|
? AppColors.primary
|
||||||
|
: isDone
|
||||||
|
? AppColors.success
|
||||||
|
: AppColors.divider,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: isDone
|
||||||
|
? const Icon(Icons.check, color: Colors.white, size: 16)
|
||||||
|
: Text(
|
||||||
|
'${step.index + 1}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isActive ? Colors.white : AppColors.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
lib/data/graphql/graphql_client.dart
Normal file
31
lib/data/graphql/graphql_client.dart
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import 'package:graphql_flutter/graphql_flutter.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Configure and provide the GraphQL client.
|
||||||
|
/// Set [kGraphQLEndpoint] to your Magento GraphQL endpoint.
|
||||||
|
const String kGraphQLEndpoint = String.fromEnvironment(
|
||||||
|
'GRAPHQL_ENDPOINT',
|
||||||
|
defaultValue: 'https://your-magento-store.com/graphql',
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<GraphQLClient> buildGraphQLClient() async {
|
||||||
|
await initHiveForFlutter();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final token = prefs.getString('auth_token') ?? '';
|
||||||
|
|
||||||
|
final authLink = AuthLink(getToken: () async => token.isNotEmpty ? 'Bearer $token' : '');
|
||||||
|
|
||||||
|
final httpLink = HttpLink(kGraphQLEndpoint);
|
||||||
|
|
||||||
|
final link = authLink.concat(httpLink);
|
||||||
|
|
||||||
|
return GraphQLClient(
|
||||||
|
link: link,
|
||||||
|
cache: GraphQLCache(store: HiveStore()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ValueNotifier<GraphQLClient> buildGraphQLClientNotifier(GraphQLClient client) {
|
||||||
|
return ValueNotifier(client);
|
||||||
|
}
|
||||||
175
lib/data/graphql/queries.dart
Normal file
175
lib/data/graphql/queries.dart
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/// GraphQL queries and mutations for the Velmart Picker app.
|
||||||
|
/// These map directly to the Magento picker_request schema defined in the backend.
|
||||||
|
|
||||||
|
class PickerQueries {
|
||||||
|
// ─── Auth ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const String requestOtp = r'''
|
||||||
|
mutation RequestOtp($phone: String!) {
|
||||||
|
pickerRequestOtp(phone: $phone) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
static const String verifyOtp = r'''
|
||||||
|
mutation VerifyOtp($phone: String!, $code: String!) {
|
||||||
|
pickerVerifyOtp(phone: $phone, code: $code) {
|
||||||
|
token
|
||||||
|
picker {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
// ─── Orders / Batches ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const String getOrderQueue = r'''
|
||||||
|
query GetOrderQueue($shopId: Int!, $status: [PickerRequestStatus]) {
|
||||||
|
pickerRequests(shopId: $shopId, status: $status) {
|
||||||
|
items {
|
||||||
|
requestId
|
||||||
|
orderNumber: order { incrementId }
|
||||||
|
amount: order { grandTotal }
|
||||||
|
slotTime: deadline
|
||||||
|
status
|
||||||
|
totalQty
|
||||||
|
totalWeight
|
||||||
|
customer: order {
|
||||||
|
customerFirstname
|
||||||
|
customerLastname
|
||||||
|
customerPhone: billingAddress { telephone }
|
||||||
|
customerComment: order { customerNote }
|
||||||
|
}
|
||||||
|
segments: items {
|
||||||
|
id
|
||||||
|
name: zone { name }
|
||||||
|
zone: zone { id }
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
sku
|
||||||
|
name: orderItem { name }
|
||||||
|
price: orderItem { price }
|
||||||
|
planQty: qty
|
||||||
|
actualQty
|
||||||
|
status
|
||||||
|
type
|
||||||
|
imageUrl: orderItem { product { thumbnail { url } } }
|
||||||
|
substitutes {
|
||||||
|
id
|
||||||
|
sku
|
||||||
|
name
|
||||||
|
priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
static const String getOrderDetail = r'''
|
||||||
|
query GetOrderDetail($requestId: Int!) {
|
||||||
|
pickerRequest(id: $requestId) {
|
||||||
|
requestId
|
||||||
|
status
|
||||||
|
totalQty
|
||||||
|
totalWeight
|
||||||
|
deadline
|
||||||
|
order {
|
||||||
|
incrementId
|
||||||
|
grandTotal
|
||||||
|
customerFirstname
|
||||||
|
customerLastname
|
||||||
|
customerNote
|
||||||
|
billingAddress { telephone }
|
||||||
|
}
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
sku
|
||||||
|
qty
|
||||||
|
actualQty
|
||||||
|
status
|
||||||
|
type
|
||||||
|
weight
|
||||||
|
orderItem { name price product { thumbnail { url } } }
|
||||||
|
zone { id name }
|
||||||
|
substitutes { id sku name priority }
|
||||||
|
slot { id name type maxOrders }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
// ─── Item mutations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const String markItemPicked = r'''
|
||||||
|
mutation MarkItemPicked($itemId: Int!, $actualQty: Float!) {
|
||||||
|
pickerMarkItemPicked(itemId: $itemId, actualQty: $actualQty) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
actualQty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
static const String markItemNotFound = r'''
|
||||||
|
mutation MarkItemNotFound($itemId: Int!) {
|
||||||
|
pickerMarkItemNotFound(itemId: $itemId) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
static const String replaceItem = r'''
|
||||||
|
mutation ReplaceItem($itemId: Int!, $substituteSku: String!, $actualQty: Float!) {
|
||||||
|
pickerReplaceItem(itemId: $itemId, substituteSku: $substituteSku, actualQty: $actualQty) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
sku
|
||||||
|
actualQty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
// ─── Slots ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const String getAvailableSlots = r'''
|
||||||
|
query GetAvailableSlots($shopId: Int!) {
|
||||||
|
pickerAvailableSlots(shopId: $shopId) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
type
|
||||||
|
maxOrders
|
||||||
|
currentOrders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
static const String assignSlots = r'''
|
||||||
|
mutation AssignSlots($requestId: Int!, $slotIds: [Int!]!) {
|
||||||
|
pickerAssignSlots(requestId: $requestId, slotIds: $slotIds) {
|
||||||
|
success
|
||||||
|
slots { id name type }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
// ─── Finalize ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const String finalizeOrder = r'''
|
||||||
|
mutation FinalizeOrder($requestId: Int!) {
|
||||||
|
pickerFinalizeOrder(requestId: $requestId) {
|
||||||
|
success
|
||||||
|
requestId
|
||||||
|
derivedRequestId
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
}
|
||||||
222
lib/data/mock/mock_data.dart
Normal file
222
lib/data/mock/mock_data.dart
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import '../models/models.dart';
|
||||||
|
|
||||||
|
/// Static mock data — swap this layer with GraphQL service when backend is ready.
|
||||||
|
class MockData {
|
||||||
|
static List<Order> get orders => [
|
||||||
|
Order(
|
||||||
|
id: 1001,
|
||||||
|
orderNumber: '#10234',
|
||||||
|
amount: 1850.50,
|
||||||
|
slotTime: DateTime.now().add(const Duration(minutes: 25)),
|
||||||
|
status: OrderStatus.newOrder,
|
||||||
|
customer: const Customer(
|
||||||
|
name: 'Костянтин',
|
||||||
|
phone: '+380671234567',
|
||||||
|
comment: 'Будь-ласка, найбільш свіже мясо, щоб могла їсти дитина!',
|
||||||
|
),
|
||||||
|
segments: [
|
||||||
|
OrderSegment(
|
||||||
|
id: 1,
|
||||||
|
name: 'М\'ясо та риба',
|
||||||
|
zone: 'A1',
|
||||||
|
items: [
|
||||||
|
OrderItem(
|
||||||
|
id: 101,
|
||||||
|
sku: 'SKU-001',
|
||||||
|
name: 'Курятина філе охолоджена',
|
||||||
|
category: 'М\'ясо',
|
||||||
|
price: 189.90,
|
||||||
|
planQty: 1,
|
||||||
|
type: ItemType.cold,
|
||||||
|
substitutes: [
|
||||||
|
const OrderItemSubstitute(sku: 'SKU-002', name: 'Курятина стегно охол.', priority: 1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
OrderItem(
|
||||||
|
id: 102,
|
||||||
|
sku: 'SKU-003',
|
||||||
|
name: 'Яловичина вирізка охол.',
|
||||||
|
category: 'М\'ясо',
|
||||||
|
price: 320.00,
|
||||||
|
planQty: 0.5,
|
||||||
|
type: ItemType.cold,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
OrderSegment(
|
||||||
|
id: 2,
|
||||||
|
name: 'Молочні продукти',
|
||||||
|
zone: 'B2',
|
||||||
|
items: [
|
||||||
|
OrderItem(
|
||||||
|
id: 103,
|
||||||
|
sku: 'SKU-010',
|
||||||
|
name: 'Молоко Яготинське 2.5% 1л',
|
||||||
|
category: 'Молоко',
|
||||||
|
price: 45.50,
|
||||||
|
planQty: 2,
|
||||||
|
type: ItemType.cold,
|
||||||
|
),
|
||||||
|
OrderItem(
|
||||||
|
id: 104,
|
||||||
|
sku: 'SKU-011',
|
||||||
|
name: 'Сир кисломолочний 9% 200г',
|
||||||
|
category: 'Сир',
|
||||||
|
price: 38.90,
|
||||||
|
planQty: 3,
|
||||||
|
type: ItemType.cold,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
OrderSegment(
|
||||||
|
id: 3,
|
||||||
|
name: 'Заморожені',
|
||||||
|
zone: 'C1',
|
||||||
|
items: [
|
||||||
|
OrderItem(
|
||||||
|
id: 105,
|
||||||
|
sku: 'SKU-020',
|
||||||
|
name: 'Вареники з картоплею 800г',
|
||||||
|
category: 'Заморожені',
|
||||||
|
price: 89.90,
|
||||||
|
planQty: 2,
|
||||||
|
type: ItemType.frozen,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
OrderSegment(
|
||||||
|
id: 4,
|
||||||
|
name: 'Алкоголь',
|
||||||
|
zone: 'D3',
|
||||||
|
items: [
|
||||||
|
OrderItem(
|
||||||
|
id: 106,
|
||||||
|
sku: 'SKU-030',
|
||||||
|
name: 'Вино Коблево Шардоне 0.75л',
|
||||||
|
category: 'Вино',
|
||||||
|
price: 225.00,
|
||||||
|
planQty: 1,
|
||||||
|
type: ItemType.alcohol,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Order(
|
||||||
|
id: 1002,
|
||||||
|
orderNumber: '#10235',
|
||||||
|
amount: 640.00,
|
||||||
|
slotTime: DateTime.now().add(const Duration(minutes: 45)),
|
||||||
|
status: OrderStatus.inProgress,
|
||||||
|
customer: const Customer(
|
||||||
|
name: 'Оксана',
|
||||||
|
phone: '+380507654321',
|
||||||
|
),
|
||||||
|
segments: [
|
||||||
|
OrderSegment(
|
||||||
|
id: 5,
|
||||||
|
name: 'Бакалія',
|
||||||
|
zone: 'E1',
|
||||||
|
items: [
|
||||||
|
OrderItem(
|
||||||
|
id: 201,
|
||||||
|
sku: 'SKU-040',
|
||||||
|
name: 'Гречка ТМ Жменька 800г',
|
||||||
|
category: 'Крупи',
|
||||||
|
price: 55.90,
|
||||||
|
planQty: 2,
|
||||||
|
status: ItemStatus.picked,
|
||||||
|
actualQty: 2,
|
||||||
|
),
|
||||||
|
OrderItem(
|
||||||
|
id: 202,
|
||||||
|
sku: 'SKU-041',
|
||||||
|
name: 'Олія соняшникова Чумак 1л',
|
||||||
|
category: 'Олія',
|
||||||
|
price: 89.90,
|
||||||
|
planQty: 1,
|
||||||
|
status: ItemStatus.picked,
|
||||||
|
actualQty: 1,
|
||||||
|
),
|
||||||
|
OrderItem(
|
||||||
|
id: 203,
|
||||||
|
sku: 'SKU-042',
|
||||||
|
name: 'Цукор білий 1кг',
|
||||||
|
category: 'Бакалія',
|
||||||
|
price: 48.90,
|
||||||
|
planQty: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
OrderSegment(
|
||||||
|
id: 6,
|
||||||
|
name: 'Овочі та фрукти',
|
||||||
|
zone: 'F2',
|
||||||
|
items: [
|
||||||
|
OrderItem(
|
||||||
|
id: 204,
|
||||||
|
sku: 'SKU-050',
|
||||||
|
name: 'Банани імпортні',
|
||||||
|
category: 'Фрукти',
|
||||||
|
price: 49.90,
|
||||||
|
planQty: 1.5,
|
||||||
|
type: ItemType.clarify,
|
||||||
|
substitutes: [
|
||||||
|
const OrderItemSubstitute(sku: 'SKU-051', name: 'Яблуко Голден', priority: 1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Order(
|
||||||
|
id: 1003,
|
||||||
|
orderNumber: '#10236',
|
||||||
|
amount: 2340.00,
|
||||||
|
slotTime: DateTime.now().add(const Duration(minutes: 10)),
|
||||||
|
status: OrderStatus.newOrder,
|
||||||
|
customer: const Customer(
|
||||||
|
name: 'Андрій',
|
||||||
|
phone: '+380931112233',
|
||||||
|
comment: 'Без замін, будь ласка',
|
||||||
|
),
|
||||||
|
segments: [
|
||||||
|
OrderSegment(
|
||||||
|
id: 7,
|
||||||
|
name: 'Хліб та випічка',
|
||||||
|
zone: 'G1',
|
||||||
|
items: [
|
||||||
|
OrderItem(
|
||||||
|
id: 301,
|
||||||
|
sku: 'SKU-060',
|
||||||
|
name: 'Хліб Бородинський 400г',
|
||||||
|
category: 'Хліб',
|
||||||
|
price: 42.50,
|
||||||
|
planQty: 2,
|
||||||
|
),
|
||||||
|
OrderItem(
|
||||||
|
id: 302,
|
||||||
|
sku: 'SKU-061',
|
||||||
|
name: 'Батон нарізний 500г',
|
||||||
|
category: 'Хліб',
|
||||||
|
price: 38.90,
|
||||||
|
planQty: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<StorageSlot> get availableSlots => [
|
||||||
|
const StorageSlot(id: 1, type: StorageSlotType.cell, name: 'Ячейка 1', maxOrders: 3),
|
||||||
|
const StorageSlot(id: 2, type: StorageSlotType.cell, name: 'Ячейка 2', maxOrders: 3),
|
||||||
|
const StorageSlot(id: 3, type: StorageSlotType.cell, name: 'Ячейка 3', maxOrders: 3),
|
||||||
|
const StorageSlot(id: 4, type: StorageSlotType.cell, name: 'Ячейка 4', maxOrders: 3),
|
||||||
|
const StorageSlot(id: 5, type: StorageSlotType.cell, name: 'Ячейка 5', maxOrders: 3),
|
||||||
|
const StorageSlot(id: 6, type: StorageSlotType.freezer, name: 'Морозилка A', maxOrders: 5),
|
||||||
|
const StorageSlot(id: 7, type: StorageSlotType.freezer, name: 'Морозилка B', maxOrders: 5),
|
||||||
|
const StorageSlot(id: 8, type: StorageSlotType.fridge, name: 'Холодильник 1', maxOrders: 4),
|
||||||
|
const StorageSlot(id: 9, type: StorageSlotType.fridge, name: 'Холодильник 2', maxOrders: 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
233
lib/data/models/models.dart
Normal file
233
lib/data/models/models.dart
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum OrderStatus { newOrder, inProgress, picked, stored, issued }
|
||||||
|
|
||||||
|
enum ItemStatus { newItem, inProgress, picked, notFound, replaced }
|
||||||
|
|
||||||
|
enum ItemType { normal, cold, frozen, alcohol, clarify }
|
||||||
|
|
||||||
|
enum BatchStatus { newBatch, inProgress, completed, cancelled }
|
||||||
|
|
||||||
|
enum StorageSlotType { cell, freezer, fridge }
|
||||||
|
|
||||||
|
// ─── Models ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Customer {
|
||||||
|
final String name;
|
||||||
|
final String phone;
|
||||||
|
final String? comment;
|
||||||
|
|
||||||
|
const Customer({required this.name, required this.phone, this.comment});
|
||||||
|
|
||||||
|
factory Customer.fromJson(Map<String, dynamic> j) => Customer(
|
||||||
|
name: j['name'] as String,
|
||||||
|
phone: j['phone'] as String,
|
||||||
|
comment: j['comment'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StorageSlot {
|
||||||
|
final int id;
|
||||||
|
final StorageSlotType type;
|
||||||
|
final String name;
|
||||||
|
final int maxOrders;
|
||||||
|
|
||||||
|
const StorageSlot({required this.id, required this.type, required this.name, required this.maxOrders});
|
||||||
|
|
||||||
|
String get typeLabel {
|
||||||
|
switch (type) {
|
||||||
|
case StorageSlotType.cell:
|
||||||
|
return 'Ячейка';
|
||||||
|
case StorageSlotType.freezer:
|
||||||
|
return 'Морозилка';
|
||||||
|
case StorageSlotType.fridge:
|
||||||
|
return 'Холодильник';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
factory StorageSlot.fromJson(Map<String, dynamic> j) => StorageSlot(
|
||||||
|
id: j['id'] as int,
|
||||||
|
type: StorageSlotType.values.firstWhere((e) => e.name == j['type']),
|
||||||
|
name: j['name'] as String,
|
||||||
|
maxOrders: j['maxOrders'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderItemSubstitute {
|
||||||
|
final String sku;
|
||||||
|
final String name;
|
||||||
|
final int priority;
|
||||||
|
|
||||||
|
const OrderItemSubstitute({required this.sku, required this.name, required this.priority});
|
||||||
|
|
||||||
|
factory OrderItemSubstitute.fromJson(Map<String, dynamic> j) => OrderItemSubstitute(
|
||||||
|
sku: j['sku'] as String,
|
||||||
|
name: j['name'] as String,
|
||||||
|
priority: j['priority'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderItem {
|
||||||
|
final int id;
|
||||||
|
final String sku;
|
||||||
|
final String name;
|
||||||
|
final String? imageUrl;
|
||||||
|
final String category;
|
||||||
|
final double price;
|
||||||
|
final double planQty;
|
||||||
|
double actualQty;
|
||||||
|
ItemStatus status;
|
||||||
|
ItemType type;
|
||||||
|
final List<OrderItemSubstitute> substitutes;
|
||||||
|
StorageSlot? assignedSlot;
|
||||||
|
|
||||||
|
OrderItem({
|
||||||
|
required this.id,
|
||||||
|
required this.sku,
|
||||||
|
required this.name,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.category,
|
||||||
|
required this.price,
|
||||||
|
required this.planQty,
|
||||||
|
this.actualQty = 0,
|
||||||
|
this.status = ItemStatus.newItem,
|
||||||
|
this.type = ItemType.normal,
|
||||||
|
this.substitutes = const [],
|
||||||
|
this.assignedSlot,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isCompleted => status == ItemStatus.picked || status == ItemStatus.replaced || status == ItemStatus.notFound;
|
||||||
|
|
||||||
|
factory OrderItem.fromJson(Map<String, dynamic> j) => OrderItem(
|
||||||
|
id: j['id'] as int,
|
||||||
|
sku: j['sku'] as String,
|
||||||
|
name: j['name'] as String,
|
||||||
|
imageUrl: j['imageUrl'] as String?,
|
||||||
|
category: j['category'] as String,
|
||||||
|
price: (j['price'] as num).toDouble(),
|
||||||
|
planQty: (j['planQty'] as num).toDouble(),
|
||||||
|
actualQty: (j['actualQty'] as num?)?.toDouble() ?? 0,
|
||||||
|
status: ItemStatus.values.firstWhere(
|
||||||
|
(e) => e.name == (j['status'] ?? 'newItem'),
|
||||||
|
orElse: () => ItemStatus.newItem,
|
||||||
|
),
|
||||||
|
type: ItemType.values.firstWhere(
|
||||||
|
(e) => e.name == (j['type'] ?? 'normal'),
|
||||||
|
orElse: () => ItemType.normal,
|
||||||
|
),
|
||||||
|
substitutes: (j['substitutes'] as List<dynamic>? ?? [])
|
||||||
|
.map((s) => OrderItemSubstitute.fromJson(s as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderSegment {
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final String zone;
|
||||||
|
final List<OrderItem> items;
|
||||||
|
|
||||||
|
const OrderSegment({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.zone,
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
int get pickedCount => items.where((i) => i.isCompleted).length;
|
||||||
|
int get totalCount => items.length;
|
||||||
|
bool get isCompleted => pickedCount == totalCount;
|
||||||
|
double get totalWeight => items.fold(0, (sum, i) => sum + i.planQty);
|
||||||
|
|
||||||
|
factory OrderSegment.fromJson(Map<String, dynamic> j) => OrderSegment(
|
||||||
|
id: j['id'] as int,
|
||||||
|
name: j['name'] as String,
|
||||||
|
zone: j['zone'] as String,
|
||||||
|
items: (j['items'] as List<dynamic>)
|
||||||
|
.map((i) => OrderItem.fromJson(i as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Order {
|
||||||
|
final int id;
|
||||||
|
final String orderNumber;
|
||||||
|
final double amount;
|
||||||
|
final DateTime slotTime;
|
||||||
|
OrderStatus status;
|
||||||
|
final Customer customer;
|
||||||
|
final List<OrderSegment> segments;
|
||||||
|
final List<StorageSlot> assignedSlots;
|
||||||
|
|
||||||
|
Order({
|
||||||
|
required this.id,
|
||||||
|
required this.orderNumber,
|
||||||
|
required this.amount,
|
||||||
|
required this.slotTime,
|
||||||
|
this.status = OrderStatus.newOrder,
|
||||||
|
required this.customer,
|
||||||
|
required this.segments,
|
||||||
|
this.assignedSlots = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
int get totalItems => segments.fold(0, (sum, s) => sum + s.totalCount);
|
||||||
|
int get pickedItems => segments.fold(0, (sum, s) => sum + s.pickedCount);
|
||||||
|
bool get isFullyPicked => pickedItems == totalItems;
|
||||||
|
double get progress => totalItems == 0 ? 0 : pickedItems / totalItems;
|
||||||
|
|
||||||
|
String get statusLabel {
|
||||||
|
switch (status) {
|
||||||
|
case OrderStatus.newOrder:
|
||||||
|
return 'Новий';
|
||||||
|
case OrderStatus.inProgress:
|
||||||
|
return 'В сборці';
|
||||||
|
case OrderStatus.picked:
|
||||||
|
return 'Зібрано';
|
||||||
|
case OrderStatus.stored:
|
||||||
|
return 'В зберіганні';
|
||||||
|
case OrderStatus.issued:
|
||||||
|
return 'Видано';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Order.fromJson(Map<String, dynamic> j) => Order(
|
||||||
|
id: j['id'] as int,
|
||||||
|
orderNumber: j['orderNumber'] as String,
|
||||||
|
amount: (j['amount'] as num).toDouble(),
|
||||||
|
slotTime: DateTime.parse(j['slotTime'] as String),
|
||||||
|
status: OrderStatus.values.firstWhere(
|
||||||
|
(e) => e.name == (j['status'] ?? 'newOrder'),
|
||||||
|
orElse: () => OrderStatus.newOrder,
|
||||||
|
),
|
||||||
|
customer: Customer.fromJson(j['customer'] as Map<String, dynamic>),
|
||||||
|
segments: (j['segments'] as List<dynamic>)
|
||||||
|
.map((s) => OrderSegment.fromJson(s as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
assignedSlots: (j['assignedSlots'] as List<dynamic>? ?? [])
|
||||||
|
.map((s) => StorageSlot.fromJson(s as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Batch Step ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum BatchStep { picking, sorting, clarification, slots, handoff }
|
||||||
|
|
||||||
|
extension BatchStepX on BatchStep {
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case BatchStep.picking:
|
||||||
|
return 'Збір';
|
||||||
|
case BatchStep.sorting:
|
||||||
|
return 'Сортування';
|
||||||
|
case BatchStep.clarification:
|
||||||
|
return 'Уточнення';
|
||||||
|
case BatchStep.slots:
|
||||||
|
return 'Ячейки';
|
||||||
|
case BatchStep.handoff:
|
||||||
|
return 'Видача';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int get index => BatchStep.values.indexOf(this);
|
||||||
|
}
|
||||||
100
lib/data/services/order_service.dart
Normal file
100
lib/data/services/order_service.dart
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import '../models/models.dart';
|
||||||
|
import '../mock/mock_data.dart';
|
||||||
|
|
||||||
|
/// Abstract interface — swap MockOrderService for GraphQLOrderService
|
||||||
|
/// when the backend is ready. No other code changes needed.
|
||||||
|
abstract class OrderService {
|
||||||
|
Future<List<Order>> getOrderQueue({OrderStatus? status});
|
||||||
|
Future<Order> getOrderDetail(int orderId);
|
||||||
|
Future<void> markItemPicked(int itemId, double actualQty);
|
||||||
|
Future<void> markItemNotFound(int itemId);
|
||||||
|
Future<void> replaceItem(int itemId, String substituteSku, double actualQty);
|
||||||
|
Future<List<StorageSlot>> getAvailableSlots();
|
||||||
|
Future<void> assignSlots(int orderId, List<int> slotIds);
|
||||||
|
Future<void> finalizeOrder(int orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mock implementation — works entirely in-memory with fake data.
|
||||||
|
class MockOrderService implements OrderService {
|
||||||
|
final List<Order> _orders = MockData.orders;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Order>> getOrderQueue({OrderStatus? status}) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
if (status == null) return List.from(_orders);
|
||||||
|
return _orders.where((o) => o.status == status).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Order> getOrderDetail(int orderId) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
return _orders.firstWhere((o) => o.id == orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markItemPicked(int itemId, double actualQty) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 150));
|
||||||
|
for (final order in _orders) {
|
||||||
|
for (final segment in order.segments) {
|
||||||
|
final item = segment.items.where((i) => i.id == itemId).firstOrNull;
|
||||||
|
if (item != null) {
|
||||||
|
item.status = ItemStatus.picked;
|
||||||
|
item.actualQty = actualQty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markItemNotFound(int itemId) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 150));
|
||||||
|
for (final order in _orders) {
|
||||||
|
for (final segment in order.segments) {
|
||||||
|
final item = segment.items.where((i) => i.id == itemId).firstOrNull;
|
||||||
|
if (item != null) {
|
||||||
|
item.status = ItemStatus.notFound;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> replaceItem(int itemId, String substituteSku, double actualQty) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 150));
|
||||||
|
for (final order in _orders) {
|
||||||
|
for (final segment in order.segments) {
|
||||||
|
final item = segment.items.where((i) => i.id == itemId).firstOrNull;
|
||||||
|
if (item != null) {
|
||||||
|
item.status = ItemStatus.replaced;
|
||||||
|
item.actualQty = actualQty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<StorageSlot>> getAvailableSlots() async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
return MockData.availableSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> assignSlots(int orderId, List<int> slotIds) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
final order = _orders.firstWhere((o) => o.id == orderId);
|
||||||
|
final slots = MockData.availableSlots.where((s) => slotIds.contains(s.id)).toList();
|
||||||
|
order.assignedSlots
|
||||||
|
..clear()
|
||||||
|
..addAll(slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> finalizeOrder(int orderId) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
final order = _orders.firstWhere((o) => o.id == orderId);
|
||||||
|
order.status = OrderStatus.issued;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
lib/features/auth/auth_screen.dart
Normal file
119
lib/features/auth/auth_screen.dart
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
class AuthScreen extends StatefulWidget {
|
||||||
|
const AuthScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthScreen> createState() => _AuthScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthScreenState extends State<AuthScreen> {
|
||||||
|
final _phoneController = TextEditingController();
|
||||||
|
final _otpController = TextEditingController();
|
||||||
|
bool _otpSent = false;
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
Future<void> _sendOtp() async {
|
||||||
|
if (_phoneController.text.length < 10) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
// TODO: call PickerQueries.requestOtp via GraphQL
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_otpSent = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _verifyOtp() async {
|
||||||
|
if (_otpController.text.length < 4) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
// TODO: call PickerQueries.verifyOtp via GraphQL
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('auth_token', 'mock_token_123');
|
||||||
|
if (mounted) context.go('/orders');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
const Text(
|
||||||
|
'Велмарт',
|
||||||
|
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Colors.white),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Комплектовщик',
|
||||||
|
style: TextStyle(fontSize: 18, color: Colors.white70),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Авторизація', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextField(
|
||||||
|
controller: _phoneController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
enabled: !_otpSent,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Номер телефону',
|
||||||
|
prefixText: '+380 ',
|
||||||
|
hintText: '67 123 4567',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_otpSent) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _otpController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 6,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Код підтвердження',
|
||||||
|
hintText: '000000',
|
||||||
|
counterText: '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(() => _otpSent = false),
|
||||||
|
child: const Text('Змінити номер'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loading ? null : (_otpSent ? _verifyOtp : _sendOtp),
|
||||||
|
child: _loading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(_otpSent ? 'Підтвердити' : 'Отримати код'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
192
lib/features/clarification/clarification_screen.dart
Normal file
192
lib/features/clarification/clarification_screen.dart
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/step_indicator.dart';
|
||||||
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/services/order_service.dart';
|
||||||
|
|
||||||
|
class ClarificationScreen extends StatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
const ClarificationScreen({super.key, required this.orderId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ClarificationScreen> createState() => _ClarificationScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClarificationScreenState extends State<ClarificationScreen> {
|
||||||
|
final _service = MockOrderService();
|
||||||
|
Order? _order;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final order = await _service.getOrderDetail(widget.orderId);
|
||||||
|
if (mounted) setState(() { _order = order; _loading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
List<OrderItem> get _clarifyItems {
|
||||||
|
final result = <OrderItem>[];
|
||||||
|
for (final seg in (_order?.segments ?? [])) {
|
||||||
|
result.addAll(seg.items.where((i) => i.type == ItemType.clarify && !i.isCompleted));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _callCustomer() async {
|
||||||
|
if (_order == null) return;
|
||||||
|
final uri = Uri(scheme: 'tel', path: _order!.customer.phone);
|
||||||
|
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_order?.orderNumber ?? ''),
|
||||||
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop()),
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
const StepIndicator(currentStep: BatchStep.clarification),
|
||||||
|
Expanded(
|
||||||
|
child: _clarifyItems.isEmpty
|
||||||
|
? _buildNoClarify()
|
||||||
|
: _buildClarifyList(),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => context.push('/orders/${widget.orderId}/slots'),
|
||||||
|
child: const Text('Далі: Ячейки'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNoClarify() {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, size: 64, color: AppColors.success),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Немає товарів для уточнення', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildClarifyList() {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.warning.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.warning.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.warning_amber, color: AppColors.warning),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text('Зателефонуйте клієнту для уточнення ${_clarifyItems.length} товарів',
|
||||||
|
style: const TextStyle(color: AppColors.warning)),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _callCustomer,
|
||||||
|
icon: const Icon(Icons.call),
|
||||||
|
label: const Text('Дзвонити'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
..._clarifyItems.map((item) => _ClarifyItemCard(
|
||||||
|
item: item,
|
||||||
|
service: _service,
|
||||||
|
onUpdated: () => setState(() {}),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClarifyItemCard extends StatelessWidget {
|
||||||
|
final OrderItem item;
|
||||||
|
final OrderService service;
|
||||||
|
final VoidCallback onUpdated;
|
||||||
|
|
||||||
|
const _ClarifyItemCard({required this.item, required this.service, required this.onUpdated});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
color: AppColors.itemClarify,
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(item.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
|
Text('${item.planQty} шт • ${item.price} грн',
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (item.substitutes.isNotEmpty) ...[
|
||||||
|
const Text('Можливі заміни:', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
...item.substitutes.map((sub) => InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
await service.replaceItem(item.id, sub.sku, item.planQty);
|
||||||
|
item.status = ItemStatus.replaced;
|
||||||
|
onUpdated();
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.swap_horiz, size: 16, color: AppColors.primary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(sub.name, style: const TextStyle(fontSize: 13, color: AppColors.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await service.markItemNotFound(item.id);
|
||||||
|
item.status = ItemStatus.notFound;
|
||||||
|
onUpdated();
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(foregroundColor: AppColors.error),
|
||||||
|
child: const Text('Не замінювати'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
229
lib/features/handoff/handoff_screen.dart
Normal file
229
lib/features/handoff/handoff_screen.dart
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/step_indicator.dart';
|
||||||
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/services/order_service.dart';
|
||||||
|
|
||||||
|
class HandoffScreen extends StatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
const HandoffScreen({super.key, required this.orderId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HandoffScreen> createState() => _HandoffScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HandoffScreenState extends State<HandoffScreen> {
|
||||||
|
final _service = MockOrderService();
|
||||||
|
Order? _order;
|
||||||
|
bool _loading = true;
|
||||||
|
bool _finalizing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final order = await _service.getOrderDetail(widget.orderId);
|
||||||
|
if (mounted) setState(() { _order = order; _loading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _finalize() async {
|
||||||
|
setState(() => _finalizing = true);
|
||||||
|
await _service.finalizeOrder(widget.orderId);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Замовлення видано!'), backgroundColor: AppColors.success),
|
||||||
|
);
|
||||||
|
context.go('/orders');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_order?.orderNumber ?? ''),
|
||||||
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop()),
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
const StepIndicator(currentStep: BatchStep.handoff),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Summary card
|
||||||
|
_SummaryCard(order: _order!),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Slots summary
|
||||||
|
if (_order!.assignedSlots.isNotEmpty) ...[
|
||||||
|
const Text('Ячейки зберігання',
|
||||||
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._buildSlotChips(_order!.assignedSlots),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
// Items summary
|
||||||
|
const Text('Підсумок збору',
|
||||||
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_ItemsSummary(order: _order!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _finalizing ? null : _finalize,
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: AppColors.success),
|
||||||
|
child: _finalizing
|
||||||
|
? const SizedBox(height: 20, width: 20,
|
||||||
|
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: const Text('Зібрано та видано'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildSlotChips(List<StorageSlot> slots) {
|
||||||
|
return [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: slots.map((slot) {
|
||||||
|
final color = slot.type == StorageSlotType.freezer
|
||||||
|
? Colors.indigo
|
||||||
|
: slot.type == StorageSlotType.fridge
|
||||||
|
? AppColors.info
|
||||||
|
: AppColors.primary;
|
||||||
|
return Chip(
|
||||||
|
label: Text(slot.name, style: TextStyle(color: color, fontWeight: FontWeight.w600)),
|
||||||
|
backgroundColor: color.withOpacity(0.1),
|
||||||
|
side: BorderSide(color: color.withOpacity(0.4)),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SummaryCard extends StatelessWidget {
|
||||||
|
final Order order;
|
||||||
|
const _SummaryCard({required this.order});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final notPicked = order.segments
|
||||||
|
.expand((s) => s.items)
|
||||||
|
.where((i) => i.status == ItemStatus.notFound)
|
||||||
|
.toList();
|
||||||
|
final replaced = order.segments
|
||||||
|
.expand((s) => s.items)
|
||||||
|
.where((i) => i.status == ItemStatus.replaced)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person, color: AppColors.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(order.customer.name,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
Text(order.customer.phone, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 20),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_stat('Зібрано', '${order.pickedItems}/${order.totalItems}', AppColors.success),
|
||||||
|
if (replaced.isNotEmpty) _stat('Замінено', '${replaced.length}', AppColors.warning),
|
||||||
|
if (notPicked.isNotEmpty) _stat('Відсутні', '${notPicked.length}', AppColors.error),
|
||||||
|
_stat('Сума', '${order.amount.toStringAsFixed(0)} грн', AppColors.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (order.customer.comment != null) ...[
|
||||||
|
const Divider(height: 20),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.comment, size: 16, color: AppColors.warning),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(child: Text(order.customer.comment!,
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppColors.warning))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stat(String label, String value, Color color) => Column(
|
||||||
|
children: [
|
||||||
|
Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color)),
|
||||||
|
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textSecondary)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ItemsSummary extends StatelessWidget {
|
||||||
|
final Order order;
|
||||||
|
const _ItemsSummary({required this.order});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final allItems = order.segments.expand((s) => s.items).toList();
|
||||||
|
return Column(
|
||||||
|
children: allItems.map((item) {
|
||||||
|
final icon = item.status == ItemStatus.picked
|
||||||
|
? Icons.check_circle
|
||||||
|
: item.status == ItemStatus.replaced
|
||||||
|
? Icons.swap_horiz
|
||||||
|
: Icons.cancel;
|
||||||
|
final color = item.status == ItemStatus.picked
|
||||||
|
? AppColors.success
|
||||||
|
: item.status == ItemStatus.replaced
|
||||||
|
? AppColors.warning
|
||||||
|
: AppColors.error;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(item.name, style: const TextStyle(fontSize: 14))),
|
||||||
|
Text('${item.actualQty}/${item.planQty}',
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
207
lib/features/order_detail/order_detail_screen.dart
Normal file
207
lib/features/order_detail/order_detail_screen.dart
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/order_progress_bar.dart';
|
||||||
|
import '../../core/widgets/step_indicator.dart';
|
||||||
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/services/order_service.dart';
|
||||||
|
|
||||||
|
class OrderDetailScreen extends StatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
const OrderDetailScreen({super.key, required this.orderId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OrderDetailScreen> createState() => _OrderDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrderDetailScreenState extends State<OrderDetailScreen> {
|
||||||
|
final _service = MockOrderService();
|
||||||
|
Order? _order;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final order = await _service.getOrderDetail(widget.orderId);
|
||||||
|
if (mounted) setState(() { _order = order; _loading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
void _callCustomer() async {
|
||||||
|
if (_order == null) return;
|
||||||
|
final uri = Uri(scheme: 'tel', path: _order!.customer.phone);
|
||||||
|
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_order?.orderNumber ?? 'Замовлення'),
|
||||||
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop()),
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _order == null
|
||||||
|
? const Center(child: Text('Замовлення не знайдено'))
|
||||||
|
: _buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
final order = _order!;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const StepIndicator(currentStep: BatchStep.picking),
|
||||||
|
// Customer info bar
|
||||||
|
Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(order.customer.name,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
if (order.customer.comment != null)
|
||||||
|
Text(order.customer.comment!,
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.warning),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.call, color: AppColors.primary),
|
||||||
|
onPressed: _callCustomer,
|
||||||
|
tooltip: 'Зателефонувати',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
// Overall progress
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: OrderProgressBar(picked: order.pickedItems, total: order.totalItems),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
// Segments list
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: order.segments.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final segment = order.segments[i];
|
||||||
|
return _SegmentCard(
|
||||||
|
segment: segment,
|
||||||
|
onTap: () => context.push('/orders/${order.id}/picking/${segment.id}'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom action
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: order.isFullyPicked
|
||||||
|
? () => context.push('/orders/${order.id}/sorting')
|
||||||
|
: null,
|
||||||
|
child: const Text('Далі: Сортування'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SegmentCard extends StatelessWidget {
|
||||||
|
final OrderSegment segment;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _SegmentCard({required this.segment, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(segment.name,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
Text('Зона ${segment.zone}',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (segment.isCompleted)
|
||||||
|
const Icon(Icons.check_circle, color: AppColors.success)
|
||||||
|
else
|
||||||
|
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
OrderProgressBar(
|
||||||
|
picked: segment.pickedCount,
|
||||||
|
total: segment.totalCount,
|
||||||
|
showLabel: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
children: _buildTypeTags(segment),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildTypeTags(OrderSegment segment) {
|
||||||
|
final types = segment.items.map((i) => i.type).toSet();
|
||||||
|
return types.map((type) {
|
||||||
|
switch (type) {
|
||||||
|
case ItemType.cold:
|
||||||
|
return _tag('Холодне', AppColors.info);
|
||||||
|
case ItemType.frozen:
|
||||||
|
return _tag('Морозилка', Colors.indigo);
|
||||||
|
case ItemType.alcohol:
|
||||||
|
return _tag('Алкоголь', AppColors.warning);
|
||||||
|
case ItemType.clarify:
|
||||||
|
return _tag('Уточнення', AppColors.accent);
|
||||||
|
case ItemType.normal:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tag(String label, Color color) => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withOpacity(0.4)),
|
||||||
|
),
|
||||||
|
child: Text(label, style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w600)),
|
||||||
|
);
|
||||||
|
}
|
||||||
200
lib/features/orders/orders_list_screen.dart
Normal file
200
lib/features/orders/orders_list_screen.dart
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/order_progress_bar.dart';
|
||||||
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/services/order_service.dart';
|
||||||
|
|
||||||
|
class OrdersListScreen extends StatefulWidget {
|
||||||
|
const OrdersListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OrdersListScreen> createState() => _OrdersListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrdersListScreenState extends State<OrdersListScreen> with SingleTickerProviderStateMixin {
|
||||||
|
final _service = MockOrderService();
|
||||||
|
late TabController _tabController;
|
||||||
|
List<Order> _allOrders = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
|
_loadOrders();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadOrders() async {
|
||||||
|
final orders = await _service.getOrderQueue();
|
||||||
|
if (mounted) setState(() { _allOrders = orders; _loading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Order> get _newOrders => _allOrders.where((o) => o.status == OrderStatus.newOrder).toList();
|
||||||
|
List<Order> get _inProgress => _allOrders.where((o) => o.status == OrderStatus.inProgress).toList();
|
||||||
|
List<Order> get _completed => _allOrders.where((o) => o.status == OrderStatus.picked || o.status == OrderStatus.stored || o.status == OrderStatus.issued).toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Замовлення'),
|
||||||
|
actions: [
|
||||||
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadOrders),
|
||||||
|
IconButton(icon: const Icon(Icons.search), onPressed: () => context.push('/orders/search')),
|
||||||
|
],
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
labelColor: Colors.white,
|
||||||
|
unselectedLabelColor: Colors.white60,
|
||||||
|
indicatorColor: Colors.white,
|
||||||
|
tabs: [
|
||||||
|
Tab(text: 'Нові (${_newOrders.length})'),
|
||||||
|
Tab(text: 'В роботі (${_inProgress.length})'),
|
||||||
|
Tab(text: 'Завершені'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_OrdersList(orders: _newOrders, onRefresh: _loadOrders),
|
||||||
|
_OrdersList(orders: _inProgress, onRefresh: _loadOrders),
|
||||||
|
_OrdersList(orders: _completed, onRefresh: _loadOrders),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrdersList extends StatelessWidget {
|
||||||
|
final List<Order> orders;
|
||||||
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
const _OrdersList({required this.orders, required this.onRefresh});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (orders.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.inbox, size: 64, color: AppColors.divider),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Немає замовлень', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async => onRefresh(),
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: orders.length,
|
||||||
|
itemBuilder: (context, i) => _OrderCard(order: orders[i]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrderCard extends StatelessWidget {
|
||||||
|
final Order order;
|
||||||
|
|
||||||
|
const _OrderCard({required this.order});
|
||||||
|
|
||||||
|
Color get _statusColor {
|
||||||
|
switch (order.status) {
|
||||||
|
case OrderStatus.newOrder: return AppColors.statusNew;
|
||||||
|
case OrderStatus.inProgress: return AppColors.statusInProgress;
|
||||||
|
case OrderStatus.picked: return AppColors.statusPicked;
|
||||||
|
case OrderStatus.stored: return AppColors.statusStored;
|
||||||
|
case OrderStatus.issued: return AppColors.statusIssued;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isUrgent => order.slotTime.difference(DateTime.now()).inMinutes < 20;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final timeLeft = order.slotTime.difference(DateTime.now());
|
||||||
|
final timeLabel = timeLeft.isNegative
|
||||||
|
? 'Прострочено'
|
||||||
|
: '${timeLeft.inMinutes} хв до видачі';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => context.push('/orders/${order.id}'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(order.orderNumber,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _statusColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: _statusColor.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Text(order.statusLabel,
|
||||||
|
style: TextStyle(fontSize: 12, color: _statusColor, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 14,
|
||||||
|
color: _isUrgent ? AppColors.error : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
timeLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: _isUrgent ? AppColors.error : AppColors.textSecondary,
|
||||||
|
fontWeight: _isUrgent ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
DateFormat('HH:mm').format(order.slotTime),
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_outline, size: 14, color: AppColors.textSecondary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(order.customer.name, style: const TextStyle(fontSize: 13)),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${order.amount.toStringAsFixed(0)} грн',
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OrderProgressBar(picked: order.pickedItems, total: order.totalItems),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
301
lib/features/picking/picking_screen.dart
Normal file
301
lib/features/picking/picking_screen.dart
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/step_indicator.dart';
|
||||||
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/services/order_service.dart';
|
||||||
|
|
||||||
|
class PickingScreen extends StatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
final int segmentId;
|
||||||
|
|
||||||
|
const PickingScreen({super.key, required this.orderId, required this.segmentId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PickingScreen> createState() => _PickingScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PickingScreenState extends State<PickingScreen> {
|
||||||
|
final _service = MockOrderService();
|
||||||
|
Order? _order;
|
||||||
|
OrderSegment? _segment;
|
||||||
|
int _currentItemIndex = 0;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final order = await _service.getOrderDetail(widget.orderId);
|
||||||
|
final segment = order.segments.firstWhere((s) => s.id == widget.segmentId);
|
||||||
|
final firstUndone = segment.items.indexWhere((i) => !i.isCompleted);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_order = order;
|
||||||
|
_segment = segment;
|
||||||
|
_currentItemIndex = firstUndone >= 0 ? firstUndone : 0;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OrderItem? get _currentItem => _segment?.items.elementAtOrNull(_currentItemIndex);
|
||||||
|
|
||||||
|
void _onItemDone() {
|
||||||
|
final items = _segment!.items;
|
||||||
|
final next = items.indexWhere((i) => !i.isCompleted, _currentItemIndex + 1);
|
||||||
|
if (next >= 0) {
|
||||||
|
setState(() => _currentItemIndex = next);
|
||||||
|
} else {
|
||||||
|
// Segment done — go back to order detail
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_order?.orderNumber ?? ''),
|
||||||
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop()),
|
||||||
|
actions: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'${_segment?.pickedCount ?? 0}/${_segment?.totalCount ?? 0}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _loading || _currentItem == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
const StepIndicator(currentStep: BatchStep.picking),
|
||||||
|
Expanded(
|
||||||
|
child: _ItemCard(
|
||||||
|
item: _currentItem!,
|
||||||
|
service: _service,
|
||||||
|
onDone: () {
|
||||||
|
setState(() {});
|
||||||
|
_onItemDone();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ItemCard extends StatefulWidget {
|
||||||
|
final OrderItem item;
|
||||||
|
final OrderService service;
|
||||||
|
final VoidCallback onDone;
|
||||||
|
|
||||||
|
const _ItemCard({required this.item, required this.service, required this.onDone});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ItemCard> createState() => _ItemCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ItemCardState extends State<_ItemCard> {
|
||||||
|
final _qtyController = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
bool _showSubstitutes = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_qtyController.text = widget.item.planQty.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Color get _cardColor {
|
||||||
|
switch (widget.item.type) {
|
||||||
|
case ItemType.cold: return AppColors.itemCold;
|
||||||
|
case ItemType.frozen: return AppColors.itemFrozen;
|
||||||
|
case ItemType.alcohol: return AppColors.itemAlcohol;
|
||||||
|
case ItemType.clarify: return AppColors.itemClarify;
|
||||||
|
case ItemType.normal: return AppColors.itemNormal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _typeLabel {
|
||||||
|
switch (widget.item.type) {
|
||||||
|
case ItemType.cold: return '❄️ Холодне';
|
||||||
|
case ItemType.frozen: return '🧊 Морозилка';
|
||||||
|
case ItemType.alcohol: return '🍷 Алкоголь';
|
||||||
|
case ItemType.clarify: return '❓ Уточнення';
|
||||||
|
case ItemType.normal: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markPicked() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
final qty = double.tryParse(_qtyController.text) ?? widget.item.planQty;
|
||||||
|
await widget.service.markItemPicked(widget.item.id, qty);
|
||||||
|
widget.item.status = ItemStatus.picked;
|
||||||
|
widget.item.actualQty = qty;
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
widget.onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markNotFound() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
await widget.service.markItemNotFound(widget.item.id);
|
||||||
|
widget.item.status = ItemStatus.notFound;
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
widget.onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _replaceWith(OrderItemSubstitute sub) async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
final qty = double.tryParse(_qtyController.text) ?? widget.item.planQty;
|
||||||
|
await widget.service.replaceItem(widget.item.id, sub.sku, qty);
|
||||||
|
widget.item.status = ItemStatus.replaced;
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
widget.onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final item = widget.item;
|
||||||
|
return Container(
|
||||||
|
color: _cardColor,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_typeLabel.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(_typeLabel, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
// Product image + name
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
color: AppColors.divider,
|
||||||
|
child: item.imageUrl != null
|
||||||
|
? Image.network(item.imageUrl!, fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => const Icon(Icons.image_not_supported, size: 40))
|
||||||
|
: const Icon(Icons.shopping_bag_outlined, size: 40, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(item.name,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(item.category,
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('${item.price.toStringAsFixed(2)} грн',
|
||||||
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Plan / Actual
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Text('Потрібно', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('${item.planQty}',
|
||||||
|
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: AppColors.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const VerticalDivider(width: 1),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Text('Зібрано', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SizedBox(
|
||||||
|
width: 100,
|
||||||
|
child: TextField(
|
||||||
|
controller: _qtyController,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
|
||||||
|
decoration: const InputDecoration(border: UnderlineInputBorder(), contentPadding: EdgeInsets.zero),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// Actions
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _loading ? null : _markPicked,
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
label: const Text('Зібрано'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _loading ? null : () => setState(() => _showSubstitutes = !_showSubstitutes),
|
||||||
|
icon: const Icon(Icons.swap_horiz),
|
||||||
|
label: const Text('Замінити'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size(double.infinity, 52),
|
||||||
|
side: const BorderSide(color: AppColors.warning),
|
||||||
|
foregroundColor: AppColors.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _loading ? null : _markNotFound,
|
||||||
|
icon: const Icon(Icons.remove_circle_outline, color: AppColors.error),
|
||||||
|
label: const Text('Немає товару', style: TextStyle(color: AppColors.error)),
|
||||||
|
style: TextButton.styleFrom(minimumSize: const Size(double.infinity, 52)),
|
||||||
|
),
|
||||||
|
// Substitutes
|
||||||
|
if (_showSubstitutes && item.substitutes.isNotEmpty) ...[
|
||||||
|
const Divider(),
|
||||||
|
const Text('Замінити на:', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...item.substitutes.map((sub) => Card(
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(sub.name),
|
||||||
|
subtitle: Text(sub.sku),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||||
|
onTap: _loading ? null : () => _replaceWith(sub),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
if (_showSubstitutes && item.substitutes.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: Text('Немає доступних замін', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
lib/features/slots/slots_screen.dart
Normal file
196
lib/features/slots/slots_screen.dart
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/step_indicator.dart';
|
||||||
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/services/order_service.dart';
|
||||||
|
|
||||||
|
class SlotsScreen extends StatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
const SlotsScreen({super.key, required this.orderId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SlotsScreen> createState() => _SlotsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SlotsScreenState extends State<SlotsScreen> {
|
||||||
|
final _service = MockOrderService();
|
||||||
|
Order? _order;
|
||||||
|
List<StorageSlot> _slots = [];
|
||||||
|
final Set<int> _selectedSlotIds = {};
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final results = await Future.wait([
|
||||||
|
_service.getOrderDetail(widget.orderId),
|
||||||
|
_service.getAvailableSlots(),
|
||||||
|
]);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_order = results[0] as Order;
|
||||||
|
_slots = results[1] as List<StorageSlot>;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _needsFreezer => _order?.segments
|
||||||
|
.expand((s) => s.items)
|
||||||
|
.any((i) => i.type == ItemType.frozen && i.isCompleted) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
bool get _needsFridge => _order?.segments
|
||||||
|
.expand((s) => s.items)
|
||||||
|
.any((i) => i.type == ItemType.cold && i.isCompleted) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
List<StorageSlot> get _relevantSlots {
|
||||||
|
return _slots.where((s) {
|
||||||
|
if (s.type == StorageSlotType.cell) return true;
|
||||||
|
if (s.type == StorageSlotType.freezer && _needsFreezer) return true;
|
||||||
|
if (s.type == StorageSlotType.fridge && _needsFridge) return true;
|
||||||
|
return false;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmSlots() async {
|
||||||
|
if (_selectedSlotIds.isEmpty) return;
|
||||||
|
await _service.assignSlots(widget.orderId, _selectedSlotIds.toList());
|
||||||
|
if (mounted) context.push('/orders/${widget.orderId}/handoff');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_order?.orderNumber ?? ''),
|
||||||
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop()),
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
const StepIndicator(currentStep: BatchStep.slots),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Оберіть ячейки для зберігання',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Обрано: ${_selectedSlotIds.length}',
|
||||||
|
style: const TextStyle(color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
children: [
|
||||||
|
_buildSlotGroup('Ячейки', StorageSlotType.cell),
|
||||||
|
if (_needsFreezer) _buildSlotGroup('Морозилка', StorageSlotType.freezer),
|
||||||
|
if (_needsFridge) _buildSlotGroup('Холодильник', StorageSlotType.fridge),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _selectedSlotIds.isEmpty ? null : _confirmSlots,
|
||||||
|
child: const Text('Далі: Видача'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSlotGroup(String title, StorageSlotType type) {
|
||||||
|
final slots = _relevantSlots.where((s) => s.type == type).toList();
|
||||||
|
if (slots.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textSecondary)),
|
||||||
|
),
|
||||||
|
GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
childAspectRatio: 1.5,
|
||||||
|
),
|
||||||
|
itemCount: slots.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final slot = slots[i];
|
||||||
|
final selected = _selectedSlotIds.contains(slot.id);
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: () => setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedSlotIds.remove(slot.id);
|
||||||
|
} else {
|
||||||
|
_selectedSlotIds.add(slot.id);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? AppColors.primary : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? AppColors.primary : AppColors.divider,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
type == StorageSlotType.cell
|
||||||
|
? Icons.inbox
|
||||||
|
: type == StorageSlotType.freezer
|
||||||
|
? Icons.ac_unit
|
||||||
|
: Icons.kitchen,
|
||||||
|
color: selected ? Colors.white : AppColors.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
slot.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: selected ? Colors.white : AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
lib/features/sorting/sorting_screen.dart
Normal file
146
lib/features/sorting/sorting_screen.dart
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/step_indicator.dart';
|
||||||
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/services/order_service.dart';
|
||||||
|
|
||||||
|
class SortingScreen extends StatefulWidget {
|
||||||
|
final int orderId;
|
||||||
|
const SortingScreen({super.key, required this.orderId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SortingScreen> createState() => _SortingScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SortingScreenState extends State<SortingScreen> {
|
||||||
|
final _service = MockOrderService();
|
||||||
|
Order? _order;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final order = await _service.getOrderDetail(widget.orderId);
|
||||||
|
if (mounted) setState(() { _order = order; _loading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Groups items by their storage type for sorting.
|
||||||
|
Map<ItemType, List<OrderItem>> get _grouped {
|
||||||
|
final result = <ItemType, List<OrderItem>>{};
|
||||||
|
for (final seg in (_order?.segments ?? [])) {
|
||||||
|
for (final item in seg.items) {
|
||||||
|
result.putIfAbsent(item.type, () => []).add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_order?.orderNumber ?? ''),
|
||||||
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop()),
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
const StepIndicator(currentStep: BatchStep.sorting),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Розкладіть товари по типах',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'Перевірте та розсортуйте зібрані товари',
|
||||||
|
style: TextStyle(color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
..._grouped.entries.map((entry) => _SortGroup(type: entry.key, items: entry.value)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => context.push('/orders/${widget.orderId}/clarification'),
|
||||||
|
child: const Text('Далі: Уточнення'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SortGroup extends StatelessWidget {
|
||||||
|
final ItemType type;
|
||||||
|
final List<OrderItem> items;
|
||||||
|
|
||||||
|
const _SortGroup({required this.type, required this.items});
|
||||||
|
|
||||||
|
String get _title {
|
||||||
|
switch (type) {
|
||||||
|
case ItemType.cold: return '❄️ Холодні товари';
|
||||||
|
case ItemType.frozen: return '🧊 Заморожені';
|
||||||
|
case ItemType.alcohol: return '🍷 Алкоголь';
|
||||||
|
case ItemType.clarify: return '❓ Товари для уточнення';
|
||||||
|
case ItemType.normal: return '📦 Звичайні';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color get _color {
|
||||||
|
switch (type) {
|
||||||
|
case ItemType.cold: return AppColors.itemCold;
|
||||||
|
case ItemType.frozen: return AppColors.itemFrozen;
|
||||||
|
case ItemType.alcohol: return AppColors.itemAlcohol;
|
||||||
|
case ItemType.clarify: return AppColors.itemClarify;
|
||||||
|
case ItemType.normal: return Colors.white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final confirmedItems = items.where((i) => i.isCompleted).toList();
|
||||||
|
if (confirmedItems.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: _color,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(_title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...confirmedItems.map((item) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check_circle_outline, size: 18, color: AppColors.success),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(item.name, style: const TextStyle(fontSize: 14))),
|
||||||
|
Text('${item.actualQty}/${item.planQty}',
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
lib/main.dart
Normal file
6
lib/main.dart
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'app.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
runApp(const VelmartPickerApp());
|
||||||
|
}
|
||||||
213
pubspec.lock
Normal file
213
pubspec.lock
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.1"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
cupertino_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cupertino_icons
|
||||||
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.9"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.3"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.0"
|
||||||
|
flutter_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker
|
||||||
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.2"
|
||||||
|
leak_tracker_flutter_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_flutter_testing
|
||||||
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.10"
|
||||||
|
leak_tracker_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_testing
|
||||||
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.19"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.17.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.10"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.1.0"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.11.5 <4.0.0"
|
||||||
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
27
pubspec.yaml
Normal file
27
pubspec.yaml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
name: velmart_picker
|
||||||
|
description: Velmart order picker app for warehouse staff
|
||||||
|
publish_to: 'none'
|
||||||
|
version: 1.0.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.11.5
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
graphql_flutter: ^5.1.2
|
||||||
|
go_router: ^14.6.3
|
||||||
|
provider: ^6.1.2
|
||||||
|
intl: ^0.19.0
|
||||||
|
shared_preferences: ^2.3.2
|
||||||
|
cached_network_image: ^3.4.1
|
||||||
|
url_launcher: ^6.3.1
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
30
test/widget_test.dart
Normal file
30
test/widget_test.dart
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// This is a basic Flutter widget test.
|
||||||
|
//
|
||||||
|
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||||
|
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||||
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:velmart_picker/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
|
// Build our app and trigger a frame.
|
||||||
|
await tester.pumpWidget(const MyApp());
|
||||||
|
|
||||||
|
// Verify that our counter starts at 0.
|
||||||
|
expect(find.text('0'), findsOneWidget);
|
||||||
|
expect(find.text('1'), findsNothing);
|
||||||
|
|
||||||
|
// Tap the '+' icon and trigger a frame.
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify that our counter has incremented.
|
||||||
|
expect(find.text('0'), findsNothing);
|
||||||
|
expect(find.text('1'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user