velmart-picker/lib/features/picking/picking_screen.dart
Fibe Agent 5ba2691eac 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)
2026-04-23 21:43:07 +00:00

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