import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pad_scanner/services/app_config_service.dart'; import 'package:pad_scanner/services/scanner_service.dart'; import 'package:pad_scanner/services/code_parser.dart'; import 'package:pad_scanner/services/api_service.dart'; import 'package:pad_scanner/services/boxing_context.dart'; import 'package:pad_scanner/services/feedback_service.dart'; import 'package:pad_scanner/pages/boxing_detail_page.dart'; import 'package:pad_scanner/widgets/status_bar.dart'; // === 装箱模式 === enum BoxingMode { singleCode, // 单码装箱 multiCode, // 多码凑箱 } class BoxingPageArguments { final BoxingMode initialMode; final List autoScanCodes; const BoxingPageArguments({ required this.initialMode, required this.autoScanCodes, }); } // === 页面阶段 === enum _Phase { waiting, // 等待扫码 scanned, // 已扫码,显示信息 submitted, // 已提交成功 } class _ManyToOnePackedItem { final int boxItemId; final String zongpaiNo; final String? paichanNo; final String? workOrderNo; final int boxNo; final int quantity; final int? totalQuantity; const _ManyToOnePackedItem({ required this.boxItemId, required this.zongpaiNo, this.paichanNo, required this.workOrderNo, required this.boxNo, required this.quantity, this.totalQuantity, }); _ManyToOnePackedItem copyWith({ int? boxNo, int? quantity, int? totalQuantity, }) { return _ManyToOnePackedItem( boxItemId: boxItemId, zongpaiNo: zongpaiNo, paichanNo: paichanNo, workOrderNo: workOrderNo, boxNo: boxNo ?? this.boxNo, quantity: quantity ?? this.quantity, totalQuantity: totalQuantity ?? this.totalQuantity, ); } } // === 主页面 === class BoxingPage extends StatefulWidget { final BoxingPageArguments? arguments; const BoxingPage({super.key, this.arguments}); @override State createState() => _BoxingPageState(); } class _BoxingPageState extends State with WidgetsBindingObserver { final _scannerService = ScannerService(); final _apiService = ApiService(); final _feedbackService = FeedbackService(); final _focusNode = FocusNode(); StreamSubscription? _scanSubscription; // 模式 BoxingMode _mode = BoxingMode.singleCode; // 阶段 _Phase _phase = _Phase.waiting; // 当前总排号 String? _zongpaiNo; // 排产号信息(来自后端查询) String? _paichanNo; String? _workOrderNo; int? _erpQuantity; List _currentZongpaiBoxes = []; List _existingBoxes = []; int _maxBoxNo = 0; // 输入 final _boxNoController = TextEditingController(); final _quantityController = TextEditingController(); final _boxNoFocusNode = FocusNode(); final _quantityFocusNode = FocusNode(); // 多码凑箱:本次操作已确认装入当前箱的明细 final _manyToOnePackedItems = <_ManyToOnePackedItem>[]; int? _editingPackedItemId; String _editingPackedQuantity = ''; int? _deletingPackedItemId; int? _editingAssignedItemId; String _editingAssignedQuantity = ''; int? _deletingAssignedItemId; String? _lastScannedPaichanNo; String? _paichanSwitchNotice; // 提交中 bool _isSubmitting = false; // 单码装箱:上次提交的箱号(用于继续添加预填) int? _lastBoxNo; CurrentZongpaiBoxData? _editingBox; // 重复箱号 bool _isDuplicateBoxNo = false; // Status bar state StatusDotColor _statusDot = StatusDotColor.blue; String _statusText = '等待扫码'; String? _statusOverrideText; StatusDotColor? _statusOverrideDot; bool _isAutoProcessing = false; final _autoWarnings = []; // 多码凑箱:已完成装箱时可跳转的箱号 int? _completedJumpBoxNo; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); final arguments = widget.arguments; if (arguments != null) { _mode = arguments.initialMode; } _boxNoController.addListener(_onBoxNoTextChanged); _scanSubscription = _scannerService.scanResults.listen(_onScan); if (arguments != null && arguments.autoScanCodes.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { _processAutoScanCodes(arguments.autoScanCodes); }); } WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus()); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _boxNoController.removeListener(_onBoxNoTextChanged); _scanSubscription?.cancel(); _focusNode.dispose(); _boxNoController.dispose(); _quantityController.dispose(); _boxNoFocusNode.dispose(); _quantityFocusNode.dispose(); _feedbackService.dispose(); super.dispose(); } void _requestFocus() { if (mounted) { _focusNode.requestFocus(); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _requestFocus(); } } void _onKeyEvent(KeyEvent event) { if (event is! KeyDownEvent || _isAutoProcessing) return; if (_isModeSwitchKey(event)) { _cycleMode(); } if (_isFinishBoxKey(event)) { final hasItems = _visibleManyToOnePackedItems.isNotEmpty; if (hasItems && _mode == BoxingMode.multiCode) { _finishManyToOneBox(); } } if (_completedJumpBoxNo != null && _isJumpToBoxKey(event)) { _jumpToCompletedBox(); } } bool _isModeSwitchKey(KeyEvent event) { final key = event.logicalKey; return key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonRight1; } bool _isFinishBoxKey(KeyEvent event) { return event.logicalKey == LogicalKeyboardKey.gameButtonLeft2; } bool _isJumpToBoxKey(KeyEvent event) { return event.logicalKey == LogicalKeyboardKey.gameButtonRight2; } void _onBoxNoTextChanged() { if (!mounted || _mode != BoxingMode.multiCode) return; setState(() { _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; }); } // === 模式切换 === String get _modeLabel { switch (_mode) { case BoxingMode.singleCode: return '单码装箱'; case BoxingMode.multiCode: return '多码凑箱'; } } void _cycleMode() { _feedbackService.trigger(FeedbackEvent.modeSwitch); setState(() { switch (_mode) { case BoxingMode.singleCode: _mode = BoxingMode.multiCode; case BoxingMode.multiCode: _mode = BoxingMode.singleCode; } _resetState(); }); } void _resetState() { _phase = _Phase.waiting; _zongpaiNo = null; _paichanNo = null; _workOrderNo = null; _erpQuantity = null; _currentZongpaiBoxes = []; _existingBoxes = []; _maxBoxNo = 0; _boxNoController.clear(); _quantityController.clear(); _manyToOnePackedItems.clear(); _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; _editingAssignedItemId = null; _editingAssignedQuantity = ''; _deletingAssignedItemId = null; _lastScannedPaichanNo = null; _paichanSwitchNotice = null; _isSubmitting = false; _lastBoxNo = null; _editingBox = null; _isDuplicateBoxNo = false; _statusOverrideText = null; _statusOverrideDot = null; } // === 扫码处理 === void _onScan(ScanResult result) { if (_isAutoProcessing) { return; } final parsed = CodeParser.parse(result.barcode); if (parsed.type != CodeType.zongpaiNo) { _feedbackService.trigger(FeedbackEvent.scanInvalid); _showStatusOverride( '无效码,请重新扫描', StatusDotColor.red, const Duration(seconds: 2), ); return; } final zongpai = parsed.value; // 三种模式都允许后扫入的总排号覆盖当前扫码区;未确认数据不会入库。 _queryBoxInfo(zongpai); } Future _queryBoxInfo(String zongpai) async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return false; } final result = await _apiService.fetchBoxInfo( baseUrl: baseUrl, zongpaiNo: zongpai, ); if (!mounted) return false; if (!result.success) { final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; _feedbackService.trigger( isNetwork ? FeedbackEvent.networkError : FeedbackEvent.scanInvalid, ); _showStatusOverride( result.errorMessage ?? '查询失败', isNetwork ? StatusDotColor.yellow : StatusDotColor.red, const Duration(seconds: 2), ); return false; } // 判断该总牌号是否已全部装箱完毕 final packedQuantity = result.currentZongpaiBoxes.fold( 0, (sum, item) => sum + item.quantity, ); final totalQuantity = result.quantity ?? 0; final alreadyCompleted = totalQuantity > 0 && packedQuantity >= totalQuantity; _feedbackService.trigger( alreadyCompleted ? FeedbackEvent.alreadyCompleted : FeedbackEvent.scanValid, ); setState(() { _zongpaiNo = zongpai; _paichanNo = result.paichanNo; _workOrderNo = result.workOrderNo; _erpQuantity = result.quantity; _currentZongpaiBoxes = result.currentZongpaiBoxes; _existingBoxes = result.existingBoxes; _maxBoxNo = result.maxBoxNo; _phase = _Phase.scanned; _isDuplicateBoxNo = false; _editingBox = null; _editingAssignedItemId = null; _editingAssignedQuantity = ''; _deletingAssignedItemId = null; _completedJumpBoxNo = null; _statusOverrideText = null; if (_mode == BoxingMode.singleCode) { _paichanSwitchNotice = null; } // 根据模式自动填充 _applyAutoFill(); }); return true; } Future _processAutoScanCodes(List codes) async { if (!mounted) return; setState(() { _isAutoProcessing = true; _autoWarnings.clear(); _statusOverrideText = '正在同步转运数据...'; _statusOverrideDot = StatusDotColor.orange; }); if (_mode == BoxingMode.singleCode) { final code = codes.first; final ok = await _queryBoxInfo(code); if (!ok && mounted) { setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略')); } } else { for (final code in codes) { if (!mounted) return; final queried = await _queryBoxInfo(code); if (!queried) { if (mounted) { setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略')); } continue; } if (!_canSubmit) { if (mounted) { setState(() => _autoWarnings.add('总排号 $code 暂不能装箱,已忽略')); } continue; } final saved = await _submit(); if (!saved && mounted) { setState(() => _autoWarnings.add('总排号 $code 自动装箱失败,请手动处理')); } } } if (!mounted) return; setState(() { _isAutoProcessing = false; _statusOverrideText = null; _statusOverrideDot = null; }); _focusQuantityInput(); _requestFocus(); } void _applyAutoFill() { switch (_mode) { case BoxingMode.singleCode: final remaining = _remainingQuantity; if (remaining <= 0) { _boxNoController.clear(); _quantityController.clear(); _statusOverrideText = '该总排号已全部装箱完毕'; _statusOverrideDot = StatusDotColor.red; return; } if (_lastBoxNo != null) { _boxNoController.text = (_lastBoxNo! + 1).toString(); } else { _boxNoController.text = (_maxBoxNo + 1).toString(); } _quantityController.text = remaining.toString(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _mode == BoxingMode.singleCode) { _quantityFocusNode.requestFocus(); } }); case BoxingMode.multiCode: final decision = decideMultiCodePaichanContext( lastPaichanNo: _lastScannedPaichanNo, currentPaichanNo: _paichanNo, currentBoxNoText: _boxNoController.text, maxBoxNo: _maxBoxNo, ); if (decision.isSwitched) { _manyToOnePackedItems.clear(); _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; _paichanSwitchNotice = '排产号已切换:${_paichanNo ?? "--"}'; _feedbackService.trigger(FeedbackEvent.paichanSwitch); } else { _paichanSwitchNotice = null; } final boxNoToApply = decision.boxNoToApply; if (boxNoToApply != null) { _boxNoController.text = boxNoToApply.toString(); } _lastScannedPaichanNo = _paichanNo; final remaining = _remainingQuantity; if (remaining <= 0) { _quantityController.clear(); if (_manyToOnePackedItems.isEmpty && _currentZongpaiBoxes.isNotEmpty) { _completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo; } _statusOverrideText = '该总排号已全部装箱完毕'; _statusOverrideDot = StatusDotColor.red; return; } _quantityController.text = remaining.toString(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _mode == BoxingMode.multiCode) { _quantityFocusNode.requestFocus(); } }); } _isDuplicateBoxNo = _boxNoIsDuplicate(); } // === 重复箱号检测 === bool _boxNoIsDuplicate() { final boxNo = int.tryParse(_boxNoController.text); if (boxNo == null) { return false; } return switch (_mode) { BoxingMode.multiCode => false, BoxingMode.singleCode => _currentZongpaiBoxes.any((b) { if (b.boxNo != boxNo) return false; return _editingBox == null || b.boxItemId != _editingBox!.boxItemId; }), }; } void _checkDuplicateBoxNo() { setState(() => _isDuplicateBoxNo = _boxNoIsDuplicate()); } // === 提交 === int get _packedQuantity { return _currentZongpaiBoxes.fold( 0, (sum, item) => sum + item.quantity, ); } int get _remainingQuantity { final total = _erpQuantity ?? 0; return total - _packedQuantity; } bool get _quantityTooHigh { if (_remainingQuantity <= 0) return false; final qty = int.tryParse(_quantityController.text); return qty != null && qty > _remainingQuantity; } bool get _canSubmit { if (_isSubmitting || _phase != _Phase.scanned) return false; if (_zongpaiNo == null) return false; final boxNo = int.tryParse(_boxNoController.text); final qty = int.tryParse(_quantityController.text); if (boxNo == null || boxNo <= 0 || qty == null || qty <= 0) return false; if (_remainingQuantity <= 0) return false; if (_quantityTooHigh) return false; if (_isDuplicateBoxNo) return false; return true; } void _submitFromKeyboard() { if (_canSubmit) { _submit(); } } void _focusQuantityInput() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _quantityFocusNode.requestFocus(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); }); } int? get _currentManyToOneBoxNo { final boxNo = int.tryParse(_boxNoController.text.trim()); if (boxNo == null || boxNo <= 0) return null; return boxNo; } List<_ManyToOnePackedItem> get _visibleManyToOnePackedItems { final boxNo = _currentManyToOneBoxNo; if (boxNo == null) return const []; final byItemId = {}; for (final box in _existingBoxes.where((box) => box.boxNo == boxNo)) { for (final item in box.items) { final boxItemId = item.boxItemId; if (boxItemId == null) continue; byItemId[boxItemId] = _ManyToOnePackedItem( boxItemId: boxItemId, zongpaiNo: item.zongpaiNo, paichanNo: _paichanNo, workOrderNo: item.workOrderNo, boxNo: boxNo, quantity: item.quantity, totalQuantity: item.totalQuantity, ); } } for (final item in _manyToOnePackedItems.where( (item) => item.boxNo == boxNo, )) { byItemId[item.boxItemId] = item; } return byItemId.values.toList(growable: false); } Future _submit() async { if (!_canSubmit) return false; final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return false; } final boxNo = int.parse(_boxNoController.text); final quantity = int.parse(_quantityController.text); setState(() => _isSubmitting = true); final editing = _editingBox; final result = editing == null ? await _apiService.saveBoxRecord( baseUrl: baseUrl, zongpaiNo: _zongpaiNo!, boxNo: boxNo, quantity: quantity, ) : await _apiService.updateBoxRecord( baseUrl: baseUrl, boxItemId: editing.boxItemId, boxNo: boxNo, quantity: quantity, ); if (!mounted) return false; setState(() => _isSubmitting = false); if (result.success) { if (editing == null) { _onSubmitSuccess(boxNo, quantity, result.boxItemId); } else { _onUpdateSuccess(editing, boxNo, quantity); } return true; } else if (result.isDuplicate) { _feedbackService.trigger(FeedbackEvent.duplicateBoxNo); _showStatusOverride( '该总排号在箱号 ${result.boxNo ?? ""} 已存在,请重新输入', StatusDotColor.amber, const Duration(seconds: 2), ); return false; } else { final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; _feedbackService.trigger( isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure, ); _showStatusOverride( result.errorMessage ?? '提交失败', isNetwork ? StatusDotColor.yellow : StatusDotColor.red, const Duration(seconds: 2), ); return false; } } void _onSubmitSuccess(int boxNo, int quantity, int? boxItemId) { _feedbackService.trigger(FeedbackEvent.submitSuccess); setState(() { _lastBoxNo = boxNo; if (_mode == BoxingMode.singleCode && boxItemId != null) { _currentZongpaiBoxes = List.from(_currentZongpaiBoxes) ..add( CurrentZongpaiBoxData( boxItemId: boxItemId, boxNo: boxNo, quantity: quantity, ), ); } _addExistingBoxItem(boxNo, quantity, boxItemId); _maxBoxNo = _maxBoxNo > boxNo ? _maxBoxNo : boxNo; }); switch (_mode) { case BoxingMode.singleCode: final remaining = _remainingQuantity; if (remaining <= 0) { _showStatusOverride( '装箱成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { setState(() { _phase = _Phase.scanned; _boxNoController.text = (_maxBoxNo + 1).toString(); _quantityController.text = remaining.toString(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); _isDuplicateBoxNo = false; }); _focusQuantityInput(); _showStatusOverride( '请继续完成剩余数量装箱', StatusDotColor.green, const Duration(milliseconds: 1500), ); } case BoxingMode.multiCode: _lastScannedPaichanNo = _paichanNo; setState(() { if (boxItemId != null) { _manyToOnePackedItems.add( _ManyToOnePackedItem( boxItemId: boxItemId, zongpaiNo: _zongpaiNo!, paichanNo: _paichanNo, workOrderNo: _workOrderNo, boxNo: boxNo, quantity: quantity, totalQuantity: _erpQuantity, ), ); } _phase = _Phase.waiting; _zongpaiNo = null; _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; }); _showStatusOverride( '装箱成功,请继续扫码', StatusDotColor.green, const Duration(milliseconds: 1500), ); } } void _onUpdateSuccess( CurrentZongpaiBoxData editing, int boxNo, int quantity, ) { _feedbackService.trigger(FeedbackEvent.submitSuccess); setState(() { _currentZongpaiBoxes = _currentZongpaiBoxes.map((item) { if (item.boxItemId != editing.boxItemId) return item; return CurrentZongpaiBoxData( boxItemId: item.boxItemId, boxNo: boxNo, quantity: quantity, ); }).toList(); _replaceExistingBoxItem(editing, boxNo, quantity); _editingBox = null; _lastBoxNo = _maxBoxNo; _boxNoController.text = (_maxBoxNo + 1).toString(); final remaining = _remainingQuantity; if (remaining > 0) { _quantityController.text = remaining.toString(); } else { _quantityController.clear(); } _isDuplicateBoxNo = false; }); _showStatusOverride( '修改成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } void _replaceExistingBoxItem( CurrentZongpaiBoxData editing, int boxNo, int quantity, ) { final nextBoxes = []; for (final box in _existingBoxes) { final items = box.items.where((item) { return item.boxItemId != editing.boxItemId; }).toList(); if (items.isNotEmpty) { nextBoxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); } } final targetIndex = nextBoxes.indexWhere((box) => box.boxNo == boxNo); final updatedItem = BoxItemData( boxItemId: editing.boxItemId, zongpaiNo: _zongpaiNo!, workOrderNo: _workOrderNo, quantity: quantity, totalQuantity: _erpQuantity, ); if (targetIndex >= 0) { final target = nextBoxes[targetIndex]; nextBoxes[targetIndex] = BoxDetailData( boxNo: target.boxNo, items: [...target.items, updatedItem], ); } else { nextBoxes.add(BoxDetailData(boxNo: boxNo, items: [updatedItem])); } nextBoxes.sort((a, b) => a.boxNo.compareTo(b.boxNo)); _existingBoxes = nextBoxes; _maxBoxNo = nextBoxes.fold( 0, (max, box) => box.boxNo > max ? box.boxNo : max, ); } void _addExistingBoxItem(int boxNo, int quantity, int? boxItemId) { final item = BoxItemData( boxItemId: boxItemId, zongpaiNo: _zongpaiNo!, workOrderNo: _workOrderNo, quantity: quantity, totalQuantity: _erpQuantity, ); final index = _existingBoxes.indexWhere((box) => box.boxNo == boxNo); if (index < 0) { _existingBoxes = [ ..._existingBoxes, BoxDetailData(boxNo: boxNo, items: [item]), ]..sort((a, b) => a.boxNo.compareTo(b.boxNo)); return; } final next = List.from(_existingBoxes); final box = next[index]; next[index] = BoxDetailData(boxNo: box.boxNo, items: [...box.items, item]); _existingBoxes = next; } void _finishManyToOneBox() { setState(() { _manyToOnePackedItems.clear(); _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; _zongpaiNo = null; _phase = _Phase.waiting; _quantityController.clear(); _boxNoController.text = (_maxBoxNo + 1).toString(); _statusOverrideText = null; _statusOverrideDot = null; }); } void _startEditAssigned(CurrentZongpaiBoxData item) { setState(() { _editingAssignedItemId = item.boxItemId; _editingAssignedQuantity = item.quantity.toString(); _deletingAssignedItemId = null; _statusOverrideText = null; _statusOverrideDot = null; }); } void _cancelEditAssigned() { setState(() { _editingAssignedItemId = null; _editingAssignedQuantity = ''; }); } int _maxAssignedQuantity(CurrentZongpaiBoxData item) { final total = _erpQuantity ?? 0; final packedWithoutItem = _packedQuantity - item.quantity; return total - packedWithoutItem; } void _refreshSingleCodeNewInput({bool focusQuantity = true}) { _lastBoxNo = _maxBoxNo; _boxNoController.text = (_maxBoxNo + 1).toString(); final remaining = _remainingQuantity; if (remaining > 0) { _quantityController.text = remaining.toString(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); if (focusQuantity) { _focusQuantityInput(); } } else { _quantityController.clear(); } _isDuplicateBoxNo = false; } Future _saveAssignedItem(CurrentZongpaiBoxData item) async { final quantity = int.tryParse(_editingAssignedQuantity); if (quantity == null || quantity <= 0) { _showStatusOverride( '请输入有效数量', StatusDotColor.red, const Duration(seconds: 2), ); return; } if (quantity > _maxAssignedQuantity(item)) { _showStatusOverride( '超出可装数量上限', StatusDotColor.red, const Duration(seconds: 2), ); return; } final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } setState(() => _isSubmitting = true); final result = await _apiService.updateBoxRecord( baseUrl: baseUrl, boxItemId: item.boxItemId, boxNo: item.boxNo, quantity: quantity, ); if (!mounted) return; setState(() => _isSubmitting = false); if (result.success) { setState(() { _currentZongpaiBoxes = _currentZongpaiBoxes.map((box) { if (box.boxItemId != item.boxItemId) return box; return CurrentZongpaiBoxData( boxItemId: box.boxItemId, boxNo: box.boxNo, quantity: quantity, ); }).toList(); _replaceExistingBoxItem(item, item.boxNo, quantity); _editingAssignedItemId = null; _editingAssignedQuantity = ''; _refreshSingleCodeNewInput(); }); _showStatusOverride( '修改成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { final dot = result.isDuplicate ? StatusDotColor.amber : StatusDotColor.red; _showStatusOverride( result.errorMessage ?? '修改失败', dot, const Duration(seconds: 2), ); } } Future _deleteAssignedItem(CurrentZongpaiBoxData item) async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } setState(() => _isSubmitting = true); final result = await _apiService.deleteBoxRecord( baseUrl: baseUrl, boxItemId: item.boxItemId, ); if (!mounted) return; setState(() { _isSubmitting = false; if (result.success) { _currentZongpaiBoxes = _currentZongpaiBoxes .where((box) => box.boxItemId != item.boxItemId) .toList(); _removeExistingBoxItem(item.boxItemId); if (_editingAssignedItemId == item.boxItemId) { _editingAssignedItemId = null; _editingAssignedQuantity = ''; } _deletingAssignedItemId = null; _refreshSingleCodeNewInput(); } }); if (result.success) { _showStatusOverride( '删除成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { _showStatusOverride( result.errorMessage ?? '删除失败', StatusDotColor.red, const Duration(seconds: 2), ); } } void _startEditPackedItem(_ManyToOnePackedItem item) { setState(() { _editingPackedItemId = item.boxItemId; _editingPackedQuantity = item.quantity.toString(); _deletingPackedItemId = null; }); } void _cancelEditPackedItem() { setState(() { _editingPackedItemId = null; _editingPackedQuantity = ''; }); } Future _savePackedItem(_ManyToOnePackedItem item) async { final quantity = int.tryParse(_editingPackedQuantity); final boxNo = int.tryParse(_boxNoController.text.trim()); if (quantity == null || quantity <= 0 || boxNo == null || boxNo <= 0) { _showStatusOverride( '请输入有效箱号和数量', StatusDotColor.red, const Duration(seconds: 2), ); return; } final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } setState(() => _isSubmitting = true); final result = await _apiService.updateBoxRecord( baseUrl: baseUrl, boxItemId: item.boxItemId, boxNo: boxNo, quantity: quantity, ); if (!mounted) return; setState(() => _isSubmitting = false); if (result.success) { setState(() { final index = _manyToOnePackedItems.indexWhere( (packed) => packed.boxItemId == item.boxItemId, ); if (index >= 0) { _manyToOnePackedItems[index] = _manyToOnePackedItems[index].copyWith( boxNo: boxNo, quantity: quantity, ); } _replaceExistingPackedItem(item, boxNo, quantity); _editingPackedItemId = null; _editingPackedQuantity = ''; }); _showStatusOverride( '修改成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { final dot = result.isDuplicate ? StatusDotColor.amber : StatusDotColor.red; _showStatusOverride( result.errorMessage ?? '修改失败', dot, const Duration(seconds: 2), ); } } void _replaceExistingPackedItem( _ManyToOnePackedItem item, int boxNo, int quantity, ) { final nextBoxes = []; for (final box in _existingBoxes) { final items = box.items.where((boxItem) { return boxItem.boxItemId != item.boxItemId; }).toList(); if (items.isNotEmpty) { nextBoxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); } } final updatedItem = BoxItemData( boxItemId: item.boxItemId, zongpaiNo: item.zongpaiNo, workOrderNo: item.workOrderNo, quantity: quantity, totalQuantity: item.totalQuantity, ); final targetIndex = nextBoxes.indexWhere((box) => box.boxNo == boxNo); if (targetIndex >= 0) { final target = nextBoxes[targetIndex]; nextBoxes[targetIndex] = BoxDetailData( boxNo: target.boxNo, items: [...target.items, updatedItem], ); } else { nextBoxes.add(BoxDetailData(boxNo: boxNo, items: [updatedItem])); } nextBoxes.sort((a, b) => a.boxNo.compareTo(b.boxNo)); _existingBoxes = nextBoxes; _maxBoxNo = nextBoxes.fold( 0, (max, box) => box.boxNo > max ? box.boxNo : max, ); } Future _deletePackedItem(_ManyToOnePackedItem item) async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } setState(() => _isSubmitting = true); final result = await _apiService.deleteBoxRecord( baseUrl: baseUrl, boxItemId: item.boxItemId, ); if (!mounted) return; setState(() { _isSubmitting = false; if (result.success) { _manyToOnePackedItems.removeWhere( (packed) => packed.boxItemId == item.boxItemId, ); _removeExistingBoxItem(item.boxItemId); if (_editingPackedItemId == item.boxItemId) { _editingPackedItemId = null; _editingPackedQuantity = ''; } _deletingPackedItemId = null; } }); if (result.success) { _showStatusOverride( '删除成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { _showStatusOverride( result.errorMessage ?? '删除失败', StatusDotColor.red, const Duration(seconds: 2), ); } } void _removeExistingBoxItem(int boxItemId) { final nextBoxes = []; for (final box in _existingBoxes) { final items = box.items .where((item) => item.boxItemId != boxItemId) .toList(); if (items.isNotEmpty) { nextBoxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); } } _existingBoxes = nextBoxes; _maxBoxNo = nextBoxes.fold( 0, (max, box) => box.boxNo > max ? box.boxNo : max, ); } // === Status bar management === void _showStatusOverride(String text, StatusDotColor dot, Duration duration) { setState(() { _statusOverrideText = text; _statusOverrideDot = dot; }); Future.delayed(duration, () { if (mounted) { setState(() { _statusOverrideText = null; _statusOverrideDot = null; }); } }); } void _jumpToCompletedBox() { final boxNo = _completedJumpBoxNo; if (boxNo == null) return; setState(() { _boxNoController.text = boxNo.toString(); _completedJumpBoxNo = null; _statusOverrideText = null; _statusOverrideDot = null; }); } // === 导航到详情页 === void _openDetail() { Navigator.push( context, MaterialPageRoute( builder: (_) => BoxingDetailPage( paichanNo: _paichanNo ?? '', existingBoxes: _existingBoxes, ), ), ); } // === 状态文字 === void _updateBaseStatus() { if (_isAutoProcessing) { _statusDot = StatusDotColor.orange; _statusText = '正在同步转运数据...'; return; } if (_isSubmitting) { _statusDot = StatusDotColor.orange; _statusText = '正在提交…'; return; } if (_isDuplicateBoxNo && _phase == _Phase.scanned) { _statusDot = StatusDotColor.amber; _statusText = '箱号 ${_boxNoController.text} 已存在,请重新输入'; return; } if (_quantityTooHigh && _phase == _Phase.scanned) { _statusDot = StatusDotColor.red; _statusText = '超出可装数量上限'; return; } if (_editingAssignedItemId != null) { _statusDot = StatusDotColor.amber; _statusText = '正在编辑已分配记录'; return; } if (_deletingAssignedItemId != null) { _statusDot = StatusDotColor.red; _statusText = '请确认是否删除该箱记录'; return; } if (_paichanSwitchNotice != null) { _statusDot = StatusDotColor.blue; _statusText = '排产号已切换,箱号已重置'; return; } switch (_phase) { case _Phase.waiting: _statusDot = StatusDotColor.blue; if (_mode == BoxingMode.multiCode && _visibleManyToOnePackedItems.isNotEmpty) { _statusText = '请继续扫码或完成本箱'; } else { _statusText = '等待扫码'; } case _Phase.scanned: _statusDot = StatusDotColor.blue; if (_remainingQuantity <= 0) { _statusDot = StatusDotColor.red; _statusText = '该总排号已全部装箱完毕'; return; } _statusText = _mode == BoxingMode.multiCode ? '数量已填入,请确认或修改' : '数量已填入,请确认或修改'; case _Phase.submitted: _statusDot = StatusDotColor.green; switch (_mode) { case BoxingMode.singleCode: _statusText = '装箱成功,可继续扫码'; case BoxingMode.multiCode: _statusText = '请扫描下一个总排号'; } } } // === Build === @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; _updateBaseStatus(); final effectiveDot = _statusOverrideDot ?? _statusDot; final effectiveText = _statusOverrideText ?? _statusText; return KeyboardListener( focusNode: _focusNode, onKeyEvent: _onKeyEvent, child: Scaffold( appBar: AppBar( title: Row( children: [ const Text('装箱编号'), const SizedBox(width: 8), _buildModePill(), ], ), toolbarHeight: 44, titleSpacing: 12, ), body: Column( children: [ ..._buildNoticeBanners(), Expanded( child: _mode == BoxingMode.multiCode ? _buildManyToOneBody(colorScheme) : _buildSingleCodeBody(colorScheme), ), // 底部状态栏 StatusBar(dotColor: effectiveDot, text: effectiveText), ], ), ), ); } List _buildNoticeBanners() { final banners = []; if (_isAutoProcessing) { banners.add( _noticeBanner( icon: SizedBox( width: 12, height: 12, child: CircularProgressIndicator( strokeWidth: 1.5, color: Colors.orange.shade700, ), ), text: '正在同步转运数据...', background: Colors.orange.shade50, foreground: Colors.orange.shade900, border: Colors.orange.shade100, ), ); } if (_autoWarnings.isNotEmpty) { banners.add( _noticeBanner( icon: Icon( Icons.warning_amber, size: 14, color: Colors.amber.shade900, ), text: _autoWarnings.join(';'), background: Colors.amber.shade100, foreground: Colors.amber.shade900, border: Colors.amber.shade200, ), ); } if (_paichanSwitchNotice != null) { banners.add( _noticeBanner( icon: Icon(Icons.info_outline, size: 14, color: Colors.blue.shade900), text: _paichanSwitchNotice!, background: Colors.blue.shade50, foreground: Colors.blue.shade900, border: Colors.blue.shade100, ), ); } if (_isDuplicateBoxNo && _phase == _Phase.scanned) { banners.add( _noticeBanner( icon: Icon( Icons.warning_amber, size: 14, color: Colors.amber.shade900, ), text: '箱号 ${_boxNoController.text} 已存在,请重新输入', background: Colors.amber.shade100, foreground: Colors.amber.shade900, border: Colors.amber.shade200, ), ); } if (_quantityTooHigh && _phase == _Phase.scanned) { banners.add( _noticeBanner( icon: Icon(Icons.close, size: 14, color: Colors.red.shade800), text: '超出可装数量上限(最多可装 $_remainingQuantity 件)', background: Colors.red.shade50, foreground: Colors.red.shade800, border: Colors.red.shade100, ), ); } if (_phase == _Phase.scanned && _zongpaiNo != null && _remainingQuantity <= 0) { final jumpBoxNo = _completedJumpBoxNo; if (jumpBoxNo != null) { banners.add( GestureDetector( onTap: _jumpToCompletedBox, behavior: HitTestBehavior.opaque, child: _noticeBanner( icon: Icon(Icons.warning, size: 14, color: Colors.amber.shade800), text: '${_zongpaiNo!} 已完成装箱。(P4跳转所在箱号)', background: Colors.amber.shade50, foreground: Colors.amber.shade800, border: Colors.amber.shade200, ), ), ); } else { banners.add( _noticeBanner( icon: Icon(Icons.warning, size: 14, color: Colors.red.shade800), text: '${_zongpaiNo!} 已全部装箱完毕,无需操作', background: Colors.red.shade50, foreground: Colors.red.shade800, border: Colors.red.shade100, ), ); } } return banners; } Widget _noticeBanner({ required Widget icon, required String text, required Color background, required Color foreground, required Color border, }) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), decoration: BoxDecoration( color: background, border: Border(bottom: BorderSide(color: border)), ), child: Row( children: [ icon, const SizedBox(width: 6), Expanded( child: Text( text, style: TextStyle(fontSize: 12, color: foreground), overflow: TextOverflow.ellipsis, ), ), ], ), ); } Widget _buildSingleScanBar(bool isWaiting) { if (isWaiting) { return Container( height: 50, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: Colors.grey.shade100, border: Border(bottom: BorderSide(color: Colors.grey.shade300)), ), child: const Row( children: [ Icon(Icons.qr_code_scanner, color: Colors.grey, size: 16), SizedBox(width: 8), Text( '请扫描执行卡二维码', style: TextStyle(color: Colors.grey, fontSize: 13), ), ], ), ); } return Container( height: 50, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: Colors.green.shade50, border: Border(bottom: BorderSide(color: Colors.green.shade200)), ), child: Row( children: [ const Icon(Icons.check_circle, color: Colors.green, size: 18), const SizedBox(width: 8), Expanded( child: Text( _zongpaiNo ?? '', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), Text( '${_workOrderNo ?? "--"} / ${_erpQuantity ?? 0}件 / 已装$_packedQuantity', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w500, color: Colors.black54, ), ), ], ), ); } Widget _buildSingleCodeBody(ColorScheme colorScheme) { final isWaiting = _phase == _Phase.waiting && _zongpaiNo == null; final isFinished = _phase == _Phase.scanned && _zongpaiNo != null && _remainingQuantity <= 0; return Column( children: [ _buildSingleScanBar(isWaiting), _buildSingleInfoBar(isWaiting, colorScheme), _buildSingleAssignedList(isWaiting, isFinished), _buildSingleBottomArea(isWaiting || isFinished), ], ); } Widget _buildSingleInfoBar(bool isWaiting, ColorScheme colorScheme) { final total = _erpQuantity ?? 0; final packed = isWaiting ? 0 : _packedQuantity; final progress = total <= 0 ? 0.0 : (packed / total).clamp(0.0, 1.0); final finished = !isWaiting && total > 0 && packed >= total; final detailEnabled = !isWaiting && _existingBoxes.isNotEmpty; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Colors.grey.shade50, border: Border(bottom: BorderSide(color: Colors.grey.shade300)), ), child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Text( isWaiting ? '排产号 --' : (_paichanNo ?? '--'), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: isWaiting ? Colors.grey.shade400 : Colors.black87, ), overflow: TextOverflow.ellipsis, ), const SizedBox(width: 10), Expanded( child: Text( isWaiting ? '已有 -- 箱 · 最大箱号 --' : '已有 ${_existingBoxes.length} 箱 · 最大箱号 $_maxBoxNo', style: TextStyle( fontSize: 10, color: isWaiting ? Colors.grey.shade400 : Colors.grey.shade600, ), overflow: TextOverflow.ellipsis, textAlign: TextAlign.right, ), ), ], ), const SizedBox(height: 4), ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: progress, minHeight: 3, backgroundColor: Colors.grey.shade300, valueColor: AlwaysStoppedAnimation( finished ? Colors.green.shade700 : colorScheme.primary, ), ), ), ], ), ), const SizedBox(width: 10), _buildDetailButton(detailEnabled, colorScheme), ], ), ), ); } Widget _buildDetailButton(bool enabled, ColorScheme colorScheme) { final foreground = enabled ? colorScheme.primary : Colors.grey.shade400; final background = enabled ? Colors.blue.shade50 : Colors.grey.shade100; final border = enabled ? colorScheme.primary : Colors.grey.shade300; return SizedBox( height: 34, child: OutlinedButton.icon( onPressed: enabled ? _openDetail : null, icon: Icon(Icons.receipt_long, size: 15, color: foreground), label: Text( '详情', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: foreground, ), ), style: OutlinedButton.styleFrom( backgroundColor: background, side: BorderSide(color: border), padding: const EdgeInsets.symmetric(horizontal: 10), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), ), ), ); } Widget _buildSingleAssignedList(bool isWaiting, bool isFinished) { final items = _currentZongpaiBoxes; return Expanded( child: Column( children: [ _buildSingleAssignedHeader(items.length), Expanded( child: items.isEmpty ? Center( child: Text( isFinished ? '该总排号已全部装箱完毕' : (isWaiting ? '扫码后显示已分配箱号记录' : '本次扫码尚未分配箱号'), style: TextStyle( fontSize: 13, color: isFinished ? Colors.green.shade700 : Colors.grey.shade500, fontStyle: FontStyle.italic, ), ), ) : ListView.builder( itemCount: items.length, itemBuilder: (context, index) { return _buildSingleAssignedRow(items[index], index + 1); }, ), ), ], ), ); } Widget _buildSingleAssignedHeader(int count) { return Container( height: 28, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: Colors.grey.shade200, border: Border(bottom: BorderSide(color: Colors.grey.shade400)), ), child: Row( children: [ const Expanded(flex: 22, child: Text('箱号', style: _headerStyle)), const Expanded(flex: 24, child: Text('工令号', style: _headerStyle)), const Expanded( flex: 14, child: Text('数量', textAlign: TextAlign.center, style: _headerStyle), ), const SizedBox( width: 64, child: Text('操作', textAlign: TextAlign.center, style: _headerStyle), ), ], ), ); } Widget _buildSingleAssignedRow(CurrentZongpaiBoxData item, int index) { final editing = _editingAssignedItemId == item.boxItemId; final deleting = _deletingAssignedItemId == item.boxItemId; final barColor = editing ? Colors.amber : (deleting ? Colors.red : Colors.green); final Color bgColor; if (editing) { bgColor = Colors.yellow.shade50; } else if (deleting) { bgColor = Colors.red.shade50; } else { bgColor = Colors.transparent; } final rowStyle = TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: Colors.black87, ); return Column( children: [ Container( constraints: const BoxConstraints(minHeight: 36), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: bgColor, border: Border( left: BorderSide(color: barColor, width: 3), bottom: BorderSide(color: Colors.grey.shade200), ), ), child: Row( children: [ Expanded( flex: 22, child: Text( '${item.boxNo} 号箱', overflow: TextOverflow.ellipsis, style: rowStyle.copyWith(fontWeight: FontWeight.w700), ), ), Expanded( flex: 24, child: Text( _workOrderNo ?? '--', overflow: TextOverflow.ellipsis, style: rowStyle, ), ), Expanded( flex: 14, child: editing ? SizedBox( width: 52, height: 28, child: TextField( keyboardType: TextInputType.none, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], textAlign: TextAlign.center, controller: TextEditingController( text: _editingAssignedQuantity, )..selection = TextSelection.collapsed( offset: _editingAssignedQuantity.length, ), onChanged: (v) => _editingAssignedQuantity = v, onSubmitted: (_) => _saveAssignedItem(item), decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric( horizontal: 4, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(5), borderSide: const BorderSide( color: Colors.amber, width: 1.5, ), ), ), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, ), ), ) : Text( '${item.quantity} 件', textAlign: TextAlign.center, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: deleting ? Colors.red : Colors.black54, ), ), ), SizedBox( width: 64, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (editing) ...[ _iconBtn( Icons.check, color: Colors.green, onTap: _isSubmitting ? null : () => _saveAssignedItem(item), ), _iconBtn( Icons.close, color: Colors.grey, onTap: _isSubmitting ? null : _cancelEditAssigned, ), ] else ...[ _iconBtn( Icons.edit, color: Colors.grey.shade700, onTap: _isSubmitting ? null : () => _startEditAssigned(item), ), _iconBtn( Icons.delete_outline, color: Colors.red, onTap: _isSubmitting ? null : () { setState(() { _deletingAssignedItemId = item.boxItemId; _editingAssignedItemId = null; _editingAssignedQuantity = ''; }); }, ), ], ], ), ), ], ), ), if (deleting) Container( height: 28, padding: const EdgeInsets.only(left: 15, right: 12), decoration: BoxDecoration( color: Colors.red.shade50, border: Border(bottom: BorderSide(color: Colors.red.shade200)), ), child: Row( children: [ Expanded( child: Text( '确认删除 ${item.boxNo} 号箱记录?', style: const TextStyle(fontSize: 12, color: Colors.red), overflow: TextOverflow.ellipsis, ), ), TextButton( onPressed: _isSubmitting ? null : () => _deleteAssignedItem(item), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 2, ), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, backgroundColor: Colors.red, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), ), ), child: const Text( '删除', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700), ), ), const SizedBox(width: 6), TextButton( onPressed: _isSubmitting ? null : () => setState(() => _deletingAssignedItemId = null), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 2, ), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: const Text( '取消', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700), ), ), ], ), ), ], ); } Widget _buildSingleBottomArea(bool disabled) { return Container( decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.grey.shade300)), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ Text( '箱号', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: disabled ? Colors.grey.shade400 : Colors.black54, ), ), const SizedBox(width: 4), SizedBox( width: 52, height: 34, child: TextField( controller: _boxNoController, focusNode: _boxNoFocusNode, enabled: !disabled && !_isSubmitting, keyboardType: TextInputType.none, inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.center, onChanged: (_) => _checkDuplicateBoxNo(), onSubmitted: (_) => _submitFromKeyboard(), decoration: _compactInputDecoration( disabled: disabled || _isSubmitting, borderColor: _isDuplicateBoxNo ? Colors.amber : Colors.blue.shade700, ), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w800, ), ), ), const SizedBox(width: 8), Text( '数量', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: disabled ? Colors.grey.shade400 : Colors.black54, ), ), const SizedBox(width: 4), SizedBox( width: 70, height: 34, child: TextField( controller: _quantityController, focusNode: _quantityFocusNode, enabled: !disabled && !_isSubmitting, keyboardType: TextInputType.none, inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.center, onChanged: (_) => setState(() {}), onSubmitted: (_) => _submitFromKeyboard(), decoration: _compactInputDecoration( disabled: disabled || _isSubmitting, borderColor: _quantityTooHigh ? Colors.red : Colors.blue.shade700, ), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w800, ), ), ), const Spacer(), SizedBox( height: 34, child: ElevatedButton( onPressed: _canSubmit ? _submit : null, style: ElevatedButton.styleFrom( backgroundColor: _canSubmit ? Theme.of(context).colorScheme.primary : Colors.grey.shade300, foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600, padding: const EdgeInsets.symmetric(horizontal: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), ), ), child: _isSubmitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Text( '确认', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, ), ), ), ), ], ), ), ); } InputDecoration _compactInputDecoration({ required bool disabled, required Color borderColor, }) { final disabledBorder = OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: Colors.grey.shade300), ); final activeBorder = OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: borderColor, width: 1.5), ); return InputDecoration( contentPadding: EdgeInsets.zero, border: activeBorder, enabledBorder: activeBorder, focusedBorder: activeBorder, disabledBorder: disabledBorder, filled: disabled, fillColor: Colors.grey.shade100, ); } Widget _buildModePill() { final isSingle = _mode == BoxingMode.singleCode; final bgColor = isSingle ? Colors.blue.shade700 : Colors.orange.shade700; return Material( borderRadius: BorderRadius.circular(8), color: bgColor, child: InkWell( borderRadius: BorderRadius.circular(8), onTap: _isAutoProcessing ? null : _cycleMode, child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 10), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.swap_horiz, color: Colors.white, size: 18), const SizedBox(width: 6), Flexible( child: FittedBox( fit: BoxFit.scaleDown, child: Text( _modeLabel, maxLines: 1, style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w800, ), ), ), ), const SizedBox(width: 8), Text( 'P2', style: TextStyle( color: Colors.white.withValues(alpha: 0.88), fontSize: 12, fontWeight: FontWeight.w700, ), ), ], ), ), ), ); } Widget _buildManyToOneBody(ColorScheme colorScheme) { final hasScan = _zongpaiNo != null && _phase == _Phase.scanned; final visibleItems = _visibleManyToOnePackedItems; return Column( children: [ // Header bar: box number + paichan info merged _buildManyToOneHeaderBar(), // List header (sticky above scroll area) _buildManyToOneListHeader(visibleItems.length), // Scrollable list Expanded( child: visibleItems.isEmpty ? Center( child: Text( '尚未装入任何物料', style: TextStyle( fontSize: 13, color: Colors.grey.shade500, fontStyle: FontStyle.italic, ), ), ) : ListView.builder( itemCount: visibleItems.length, itemBuilder: (context, index) { return _buildManyToOnePackedRow( visibleItems[index], index + 1, ); }, ), ), // Fixed bottom area: scan bar + submit + finish _buildManyToOneBottomArea(hasScan), ], ); } // ── Header bar: box number + paichan info ── Widget _buildManyToOneHeaderBar() { final hasDetail = _existingBoxes.isNotEmpty; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.grey.shade50, border: Border(bottom: BorderSide(color: Colors.grey.shade300)), ), child: Row( children: [ // Box number const Text( '箱号', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black54, ), ), const SizedBox(width: 6), SizedBox( width: 52, height: 32, child: TextField( controller: _boxNoController, focusNode: _boxNoFocusNode, enabled: !_isSubmitting, keyboardType: TextInputType.none, inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.center, onEditingComplete: () {}, onSubmitted: (_) => _submitFromKeyboard(), decoration: InputDecoration( contentPadding: EdgeInsets.zero, border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: Colors.grey.shade400), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: const BorderSide(color: Colors.blue, width: 1.5), ), ), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), ), ), // Divider Container( width: 1, height: 28, margin: const EdgeInsets.symmetric(horizontal: 10), color: Colors.grey.shade300, ), // Paichan info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( _paichanNo ?? '--', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, ), overflow: TextOverflow.ellipsis, ), Text( '已有 ${_existingBoxes.length} 箱 · 最大箱号 $_maxBoxNo', style: TextStyle(fontSize: 11, color: Colors.grey.shade500), overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 10), _buildDetailButton(hasDetail, Theme.of(context).colorScheme), ], ), ); } // ── List header ── Widget _buildManyToOneListHeader(int count) { return Container( height: 28, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: Colors.grey.shade200, border: Border(bottom: BorderSide(color: Colors.grey.shade400)), ), child: Row( children: [ const Expanded(flex: 22, child: Text('总排号', style: _headerStyle)), const Expanded(flex: 24, child: Text('工令号', style: _headerStyle)), const Expanded( flex: 14, child: Text('数量', textAlign: TextAlign.center, style: _headerStyle), ), const SizedBox( width: 64, child: Text('操作', textAlign: TextAlign.center, style: _headerStyle), ), ], ), ); } // ── Packed item row ── Widget _buildManyToOnePackedRow(_ManyToOnePackedItem item, int index) { final editing = _editingPackedItemId == item.boxItemId; final deleting = _deletingPackedItemId == item.boxItemId; final totalText = item.totalQuantity?.toString() ?? '--'; final barColor = editing ? Colors.amber : (deleting ? Colors.red : Colors.green); final Color bgColor; if (editing) { bgColor = Colors.yellow.shade50; } else if (deleting) { bgColor = Colors.red.shade50; } else { bgColor = Colors.transparent; } final rowStyle = TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: Colors.black87, ); return Column( children: [ Container( constraints: const BoxConstraints(minHeight: 36), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: bgColor, border: Border( left: BorderSide(color: barColor, width: 3), bottom: BorderSide(color: Colors.grey.shade200), ), ), child: Row( children: [ Expanded( flex: 22, child: Text( item.zongpaiNo, overflow: TextOverflow.ellipsis, style: rowStyle.copyWith(fontWeight: FontWeight.w700), ), ), Expanded( flex: 24, child: Text( item.workOrderNo ?? '--', overflow: TextOverflow.ellipsis, style: rowStyle, ), ), Expanded( flex: 14, child: editing ? SizedBox( width: 52, height: 26, child: TextField( keyboardType: TextInputType.none, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], textAlign: TextAlign.center, controller: TextEditingController( text: _editingPackedQuantity, )..selection = TextSelection.collapsed( offset: _editingPackedQuantity.length, ), onChanged: (v) => _editingPackedQuantity = v, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric( horizontal: 4, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(4), borderSide: const BorderSide( color: Colors.amber, width: 1.5, ), ), ), style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), ), ) : Text( '${item.quantity} / $totalText', textAlign: TextAlign.center, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: deleting ? Colors.red : Colors.green.shade700, ), ), ), SizedBox( width: 64, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (editing) ...[ _iconBtn( Icons.check, color: Colors.green, onTap: _isSubmitting ? null : () => _savePackedItem(item), ), _iconBtn( Icons.close, color: Colors.grey, onTap: _isSubmitting ? null : _cancelEditPackedItem, ), ] else ...[ _iconBtn( Icons.edit, color: Colors.grey.shade700, onTap: _isSubmitting ? null : () => _startEditPackedItem(item), ), _iconBtn( Icons.delete_outline, color: Colors.red, onTap: _isSubmitting ? null : () { setState(() { _deletingPackedItemId = item.boxItemId; _editingPackedItemId = null; _editingPackedQuantity = ''; }); }, ), ], ], ), ), ], ), ), if (deleting) Container( height: 28, padding: const EdgeInsets.only(left: 15, right: 12), decoration: BoxDecoration( color: Colors.red.shade50, border: Border(bottom: BorderSide(color: Colors.red.shade200)), ), child: Row( children: [ Expanded( child: Text( '确认删除?', style: const TextStyle( fontSize: 12, color: Colors.red, fontWeight: FontWeight.w500, ), ), ), TextButton( onPressed: _isSubmitting ? null : () => _deletePackedItem(item), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, backgroundColor: Colors.red.shade100, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), ), ), child: const Text( '删除', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: Colors.red, ), ), ), const SizedBox(width: 4), TextButton( onPressed: _isSubmitting ? null : () => setState(() => _deletingPackedItemId = null), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: const Text( '取消', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: Colors.grey, ), ), ), ], ), ), ], ); } // ── Compact icon button helper ── Widget _iconBtn(IconData icon, {Color? color, VoidCallback? onTap}) { return SizedBox( width: 28, height: 28, child: IconButton( icon: Icon(icon, size: 16), color: color, onPressed: onTap, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), ); } // ── Fixed bottom area ── Widget _buildManyToOneBottomArea(bool hasScan) { final hasItems = _visibleManyToOnePackedItems.isNotEmpty; return Container( decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.grey.shade300)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildManyToOneScanBar(hasScan), _buildManyToOneSubmitRow(hasScan, hasItems), ], ), ); } Widget _buildManyToOneScanBar(bool hasScan) { if (!hasScan) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), color: Colors.grey.shade50, child: const Row( children: [ Icon(Icons.qr_code_scanner, color: Colors.grey, size: 16), SizedBox(width: 8), Text( '请扫描执行卡二维码', style: TextStyle( color: Colors.grey, fontSize: 13, fontWeight: FontWeight.w400, ), ), ], ), ); } return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), decoration: BoxDecoration( color: Colors.green.shade50, border: Border(bottom: BorderSide(color: Colors.green.shade200)), ), child: Row( children: [ const Icon(Icons.check_circle, color: Colors.green, size: 15), const SizedBox(width: 6), Expanded( child: Text( '${_zongpaiNo ?? ""} · ${_workOrderNo ?? "--"} · ${_erpQuantity ?? "--"}件', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: Colors.green.shade700, ), overflow: TextOverflow.ellipsis, ), ), ], ), ); } Widget _buildManyToOneSubmitRow(bool hasScan, bool hasItems) { return Padding( padding: const EdgeInsets.fromLTRB(12, 7, 12, 8), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // Left half: quantity label + input + confirm Expanded( flex: 1, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( '数量', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: hasScan ? Colors.black54 : Colors.grey.shade400, ), ), const SizedBox(width: 6), Expanded( child: SizedBox( height: 34, child: TextField( controller: _quantityController, focusNode: _quantityFocusNode, enabled: hasScan && !_isSubmitting, keyboardType: TextInputType.none, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (_) => setState(() {}), onEditingComplete: () {}, onSubmitted: (_) => _submitFromKeyboard(), decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide( color: _quantityTooHigh ? Colors.red : Colors.grey.shade300, ), ), disabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: Colors.grey.shade200), ), filled: !hasScan, fillColor: Colors.grey.shade100, ), style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), ), ), const SizedBox(width: 8), SizedBox( height: 34, child: ElevatedButton( onPressed: _canSubmit ? _submit : null, style: ElevatedButton.styleFrom( backgroundColor: _canSubmit ? Theme.of(context).colorScheme.primary : Colors.grey.shade200, foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade400, padding: const EdgeInsets.symmetric(horizontal: 18), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), ), ), child: _isSubmitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Text( '确认', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, ), ), ), ), ], ), ), const SizedBox(width: 8), // Right half: 完成本箱 Expanded( flex: 1, child: SizedBox( height: 34, child: OutlinedButton( onPressed: hasItems ? _finishManyToOneBox : null, style: OutlinedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), ), side: BorderSide( color: hasItems ? Colors.grey.shade600 : Colors.grey.shade300, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '完成本箱', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: hasItems ? Colors.black87 : Colors.grey.shade400, ), ), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: hasItems ? Colors.grey.shade200 : Colors.grey.shade100, borderRadius: BorderRadius.circular(3), ), child: Text( 'P3', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: hasItems ? Colors.black54 : Colors.grey.shade400, ), ), ), ], ), ), ), ), ], ), ); } } const _headerStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);