- 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)
302 lines
11 KiB
Dart
302 lines
11 KiB
Dart
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)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|