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:
Fibe Agent 2026-04-23 21:43:07 +00:00
commit 5ba2691eac
45 changed files with 3312 additions and 0 deletions

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

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,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 = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="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>

View File

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

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

17
docker-compose.yml Normal file
View 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
View 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,
);
}
}

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

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

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

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

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

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

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

View 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 ? 'Підтвердити' : 'Отримати код'),
),
],
),
),
],
),
),
),
);
}
}

View 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('Не замінювати'),
),
),
],
),
],
),
),
);
}
}

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

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

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

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

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

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

@ -0,0 +1,6 @@
import 'package:flutter/material.dart';
import 'app.dart';
void main() {
runApp(const VelmartPickerApp());
}

213
pubspec.lock Normal file
View 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
View 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
View 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);
});
}