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 { static const _androidKeyCodeButtonR1 = 103; 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 = []; @override void initState() { super.initState(); 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() { _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(); } } void _onKeyEvent(KeyEvent event) { if (event is! KeyDownEvent || _isAutoProcessing) return; if (_isModeSwitchKey(event)) { _cycleMode(); } } bool _isModeSwitchKey(KeyEvent event) { final key = event.logicalKey; return key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonRight1 || _androidKeyCodeFromLogicalKey(key) == _androidKeyCodeButtonR1; } int? _androidKeyCodeFromLogicalKey(LogicalKeyboardKey key) { final keyId = key.keyId; const androidPlane = LogicalKeyboardKey.androidPlane; if (keyId >= androidPlane && keyId < androidPlane + 0x100000000) { return keyId - androidPlane; } return null; } 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; } _feedbackService.trigger(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; _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(); _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 { 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 (_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), ); Future.delayed(const Duration(milliseconds: 1500), () { if (mounted) setState(() => _resetState()); }); } else { setState(() { _phase = _Phase.scanned; _boxNoController.text = (boxNo + 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 _onGoBack() { setState(() => _resetState()); } void _finishManyToOneBox() { final currentBoxNo = int.tryParse(_boxNoController.text.trim()); setState(() { _manyToOnePackedItems.clear(); _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; _zongpaiNo = null; _phase = _Phase.waiting; _quantityController.clear(); if (currentBoxNo != null && currentBoxNo > 0) { _boxNoController.text = (currentBoxNo + 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 _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 (_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; final isWaiting = _phase == _Phase.waiting && _zongpaiNo == null; final showActionButtons = _phase == _Phase.scanned && _mode == BoxingMode.singleCode; _updateBaseStatus(); final effectiveDot = _statusOverrideDot ?? _statusDot; final effectiveText = _statusOverrideText ?? _statusText; return KeyboardListener( focusNode: _focusNode, onKeyEvent: _onKeyEvent, child: Scaffold( appBar: AppBar( title: const Text('装箱编号'), titleSpacing: 12, actions: [ _buildModePill(), const SizedBox(width: 8), ], ), body: Column( children: [ if (_isAutoProcessing) Container( width: double.infinity, padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 12, ), color: Colors.orange.shade50, child: Row( children: [ SizedBox( width: 12, height: 12, child: CircularProgressIndicator( strokeWidth: 1.5, color: Colors.orange.shade700, ), ), const SizedBox(width: 6), Text( '正在同步转运数据...', style: TextStyle( color: Colors.orange.shade900, fontSize: 12, ), ), ], ), ), if (_autoWarnings.isNotEmpty) Container( width: double.infinity, padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 12, ), color: Colors.amber.shade100, child: Text( _autoWarnings.join(';'), style: TextStyle(color: Colors.amber.shade900, fontSize: 12), ), ), if (_paichanSwitchNotice != null) Container( width: double.infinity, padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 12, ), color: Colors.blue.shade50, child: Text( _paichanSwitchNotice!, style: TextStyle(color: Colors.blue.shade900, fontSize: 12), ), ), if (_isDuplicateBoxNo && _phase == _Phase.scanned) Container( width: double.infinity, padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 12, ), color: Colors.amber.shade100, child: Text( '箱号 ${_boxNoController.text} 已存在,请重新输入', style: TextStyle(color: Colors.amber.shade900, fontSize: 12), ), ), Expanded( child: _mode == BoxingMode.multiCode ? _buildManyToOneBody(colorScheme) : SingleChildScrollView( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildScanArea(isWaiting), const SizedBox(height: 12), _buildInfoArea(isWaiting, colorScheme), const Divider(height: 24), if (_mode == BoxingMode.singleCode && !isWaiting && _currentZongpaiBoxes.isNotEmpty) ...[ _buildAssignedBoxes(), const Divider(height: 24), ], _buildInputArea(isWaiting), ], ), ), ), // 操作按钮(单码装箱 / 多码凑箱) if (showActionButtons) Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 4, ), child: Row( children: [ Expanded( child: OutlinedButton( onPressed: _onGoBack, style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(40), ), child: const Text('返回'), ), ), if (_mode == BoxingMode.singleCode && _editingBox == null) ...[ const SizedBox(width: 12), const Expanded(child: SizedBox.shrink()), ], if (_mode == BoxingMode.singleCode && _editingBox != null) ...[ const SizedBox(width: 12), Expanded( child: ElevatedButton( onPressed: _cancelEditAssigned, style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(40), ), child: const Text('取消'), ), ), ], ], ), ), // 底部状态栏 StatusBar(dotColor: effectiveDot, text: effectiveText), ], ), ), ); } Widget _buildModePill() { final isSingle = _mode == BoxingMode.singleCode; final bgColor = isSingle ? Colors.blue.shade700 : Colors.orange.shade700; return GestureDetector( onTap: _isAutoProcessing ? null : _cycleMode, child: Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.swap_horiz, color: Colors.white, size: 14), const SizedBox(width: 4), Text( _modeLabel, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700, ), ), const SizedBox(width: 4), Container( padding: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.22), borderRadius: BorderRadius.circular(4), ), child: const Text( 'P2', style: TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600, ), ), ), ], ), ), ); } Widget _buildAssignedBoxes() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '已分配', style: TextStyle(fontSize: 13, color: Colors.black54), ), const SizedBox(height: 6), ..._currentZongpaiBoxes.map(_buildAssignedBoxRow), ], ); } Widget _buildAssignedBoxRow(CurrentZongpaiBoxData item) { final editing = _editingAssignedItemId == item.boxItemId; final deleting = _deletingAssignedItemId == item.boxItemId; return Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: editing ? Colors.orange.shade50 : Colors.grey.shade50, border: Border.all( color: editing ? Colors.orange : Colors.grey.shade300, ), borderRadius: BorderRadius.circular(6), ), child: Column( children: [ Row( children: [ Expanded( child: Text( '${item.boxNo}号箱', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ), ), if (editing) SizedBox( width: 56, height: 32, child: TextField( keyboardType: TextInputType.none, inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.center, controller: TextEditingController(text: _editingAssignedQuantity) ..selection = TextSelection.collapsed( offset: _editingAssignedQuantity.length, ), onChanged: (value) => _editingAssignedQuantity = value, onEditingComplete: () {}, onSubmitted: (_) => _saveAssignedItem(item), decoration: const InputDecoration( contentPadding: EdgeInsets.symmetric(horizontal: 6), border: OutlineInputBorder(), ), ), ) else Text( '数量 ${item.quantity}', style: const TextStyle(fontSize: 13), ), const SizedBox(width: 4), if (editing) ...[ IconButton( tooltip: '保存', visualDensity: VisualDensity.compact, icon: const Icon(Icons.check, size: 18), onPressed: _isSubmitting ? null : () => _saveAssignedItem(item), ), IconButton( tooltip: '取消', visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, size: 18), onPressed: _isSubmitting ? null : _cancelEditAssigned, ), ] else ...[ IconButton( tooltip: '修改', visualDensity: VisualDensity.compact, icon: const Icon(Icons.edit, size: 18), onPressed: _isSubmitting ? null : () => _startEditAssigned(item), ), IconButton( tooltip: '删除', visualDensity: VisualDensity.compact, icon: const Icon(Icons.delete_outline, size: 18), onPressed: _isSubmitting ? null : () { setState(() { _deletingAssignedItemId = item.boxItemId; _editingAssignedItemId = null; _editingAssignedQuantity = ''; }); }, ), ], ], ), if (deleting) Padding( padding: const EdgeInsets.only(top: 4), child: Row( children: [ const Expanded( child: Text('确认删除此条记录?', style: TextStyle(fontSize: 13)), ), TextButton( onPressed: _isSubmitting ? null : () => _deleteAssignedItem(item), child: const Text('是'), ), TextButton( onPressed: _isSubmitting ? null : () => setState(() => _deletingAssignedItemId = null), child: const Text('否'), ), ], ), ), ], ), ); } 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, ), ], ), ), // Detail link TextButton( onPressed: hasDetail ? _openDetail : null, style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 4), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Text( '详情', style: TextStyle( fontSize: 11, color: hasDetail ? Theme.of(context).colorScheme.primary : Colors.grey.shade400, ), ), ), ], ), ); } // ── List header ── Widget _buildManyToOneListHeader(int count) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), decoration: BoxDecoration( color: Colors.white, border: Border(bottom: BorderSide(color: Colors.grey.shade300)), ), child: Row( children: [ const Expanded( child: Text( '已装入本箱', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: Colors.black54, letterSpacing: 0.5, ), ), ), Text( '$count 项', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.primary, ), ), ], ), ); } // ── 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); return Column( children: [ Container( height: 40, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: editing ? Colors.yellow.shade50 : (deleting ? Colors.red.shade50 : null), border: Border( bottom: BorderSide(color: Colors.grey.shade200), ), ), child: Row( children: [ // Index SizedBox( width: 14, child: Text( '$index', textAlign: TextAlign.right, style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, color: Colors.grey.shade500, ), ), ), const SizedBox(width: 6), // Color bar Container( width: 3, height: 26, decoration: BoxDecoration( color: barColor, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 6), // Main info Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.zongpaiNo, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, ), overflow: TextOverflow.ellipsis, ), Text( item.workOrderNo ?? '--', style: TextStyle( fontSize: 11, color: Colors.grey.shade500, ), overflow: TextOverflow.ellipsis, ), ], ), ), // Quantity / edit input if (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: (value) => _editingPackedQuantity = value, 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, ), ), ) else Text( '${item.quantity} / $totalText', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: deleting ? Colors.red : Colors.green.shade700, ), ), // Action buttons 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.orange.shade700, onTap: _isSubmitting ? null : () => _startEditPackedItem(item), ), _iconBtn( Icons.delete_outline, color: Colors.red, onTap: _isSubmitting ? null : () { setState(() { _deletingPackedItemId = item.boxItemId; _editingPackedItemId = null; _editingPackedQuantity = ''; }); }, ), ], ], ), ), // Inline delete confirmation if (deleting) Container( padding: const EdgeInsets.only( left: 34, right: 12, top: 2, bottom: 5, ), decoration: BoxDecoration( color: Colors.red.shade50, border: Border( bottom: BorderSide(color: Colors.red.shade200), ), ), child: Row( children: [ const Expanded( child: Text( '确认删除?', style: 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), Padding( padding: const EdgeInsets.fromLTRB(12, 6, 12, 8), child: SizedBox( width: double.infinity, height: 36, child: OutlinedButton( onPressed: hasItems ? _finishManyToOneBox : null, style: OutlinedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), side: BorderSide( color: hasItems ? Colors.grey.shade600 : Colors.grey.shade300, ), ), child: Text( '完成本箱', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: hasItems ? Colors.black87 : Colors.grey.shade400, ), ), ), ), ), ], ), ); } 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) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), 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, ), ), ), ), ], ), ); } // === 扫码区 Widget === Widget _buildScanArea(bool isWaiting) { if (isWaiting) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8), ), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.qr_code_scanner, color: Colors.grey, size: 20), SizedBox(width: 8), Text( '请扫描执行卡二维码', style: TextStyle(color: Colors.grey, fontSize: 14), ), ], ), ); } return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( border: Border.all(color: Colors.green, width: 2), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ const Icon(Icons.check_circle, color: Colors.green, size: 18), const SizedBox(width: 8), Text( _zongpaiNo ?? '', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87, ), ), const Spacer(), const Text( '已识别', style: TextStyle(fontSize: 12, color: Colors.green), ), ], ), ); } // === 信息区 Widget === Widget _buildInfoArea(bool isWaiting, ColorScheme colorScheme) { final grey = isWaiting; final paichanText = grey ? '--' : (_paichanNo ?? '--'); final workOrderText = grey ? '--' : (_workOrderNo ?? '--'); final quantityText = grey ? '--' : (_erpQuantity?.toString() ?? '--'); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( flex: 5, child: Text( paichanText, style: TextStyle( fontSize: 20, fontWeight: FontWeight.w700, color: grey ? Colors.grey : Colors.black87, ), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), Expanded( flex: 4, child: Text( workOrderText, textAlign: TextAlign.center, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: grey ? Colors.grey : Colors.black87, ), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), Expanded( flex: 3, child: Text( quantityText, textAlign: TextAlign.right, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: grey ? Colors.grey : Colors.black87, ), overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 10), Row( children: [ Expanded( child: Row( children: [ Text( '已有箱数:', style: TextStyle( fontSize: 13, color: grey ? Colors.grey : Colors.black54, ), ), Text( grey ? '--' : '${_existingBoxes.length}箱', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: grey ? Colors.grey : Colors.black87, ), ), const SizedBox(width: 16), Text( '最大箱号:', style: TextStyle( fontSize: 13, color: grey ? Colors.grey : Colors.black54, ), ), Text( grey ? '--' : '$_maxBoxNo', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: _maxBoxNo > 0 ? Colors.amber.shade800 : Colors.grey, ), ), ], ), ), TextButton( onPressed: grey || _existingBoxes.isEmpty ? null : _openDetail, style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Text( '详情 →', style: TextStyle( fontSize: 13, color: grey || _existingBoxes.isEmpty ? Colors.grey.shade400 : colorScheme.primary, ), ), ), ], ), ], ); } // === 输入区 Widget === Widget _buildInputArea(bool isWaiting) { final enabled = !isWaiting && _phase == _Phase.scanned; return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ // 箱号输入 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _editingBox == null ? '箱号' : '修改箱号', style: TextStyle( fontSize: 13, color: enabled ? Colors.black54 : Colors.grey, ), ), const SizedBox(height: 4), SizedBox( height: 40, child: TextField( controller: _boxNoController, focusNode: _boxNoFocusNode, enabled: enabled, keyboardType: TextInputType.none, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (_) => _checkDuplicateBoxNo(), onEditingComplete: () {}, onSubmitted: (_) => _submitFromKeyboard(), decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 10), border: const OutlineInputBorder(), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: _isDuplicateBoxNo ? Colors.amber : Colors.grey.shade400, ), ), ), style: const TextStyle(fontSize: 16), ), ), ], ), ), const SizedBox(width: 8), // 数量输入 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _editingBox == null ? '数量' : '修改数量', style: TextStyle( fontSize: 13, color: enabled ? Colors.black54 : Colors.grey, ), ), const SizedBox(height: 4), SizedBox( height: 40, child: TextField( controller: _quantityController, focusNode: _quantityFocusNode, enabled: enabled, keyboardType: TextInputType.none, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (_) => setState(() {}), onEditingComplete: () {}, onSubmitted: (_) => _submitFromKeyboard(), decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 10), border: const OutlineInputBorder(), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: _quantityTooHigh ? Colors.red : Colors.grey.shade400, ), ), ), style: const TextStyle(fontSize: 16), ), ), ], ), ), const SizedBox(width: 8), // 确认按钮 SizedBox( height: 40, 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, ), child: _isSubmitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : Text( _editingBox == null ? '确认' : '保存', style: const TextStyle(fontSize: 15), ), ), ), ], ); } }