Files
pad_scanner/lib/pages/boxing_page.dart
Misaka_Company f587194267 refactor: replace boxing list with table layout matching registration design
Change single-code and multi-code boxing lists from card-style layout
(number + color bar + two-line content) to table-style layout
(column headers + flex columns + left border color bar),
matching the registration module design language.

Fields: Box No. / Total Row No. | Work Order | Quantity | Action Button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 12:40:51 +08:00

2723 lines
85 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> 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<BoxingPage> createState() => _BoxingPageState();
}
class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
static const _androidKeyCodeButtonR1 = 103;
static const _androidKeyCodeButtonL2 = 104;
final _scannerService = ScannerService();
final _apiService = ApiService();
final _feedbackService = FeedbackService();
final _focusNode = FocusNode();
StreamSubscription<ScanResult>? _scanSubscription;
// 模式
BoxingMode _mode = BoxingMode.singleCode;
// 阶段
_Phase _phase = _Phase.waiting;
// 当前总排号
String? _zongpaiNo;
// 排产号信息(来自后端查询)
String? _paichanNo;
String? _workOrderNo;
int? _erpQuantity;
List<CurrentZongpaiBoxData> _currentZongpaiBoxes = [];
List<BoxDetailData> _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 = <String>[];
@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();
}
}
}
bool _isModeSwitchKey(KeyEvent event) {
final key = event.logicalKey;
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.gameButtonRight1 ||
_androidKeyCodeFromLogicalKey(key) == _androidKeyCodeButtonR1;
}
bool _isFinishBoxKey(KeyEvent event) {
final key = event.logicalKey;
return key == LogicalKeyboardKey.gameButtonLeft2 ||
_androidKeyCodeFromLogicalKey(key) == _androidKeyCodeButtonL2;
}
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<bool> _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<int>(
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;
_statusOverrideText = null;
if (_mode == BoxingMode.singleCode) {
_paichanSwitchNotice = null;
}
// 根据模式自动填充
_applyAutoFill();
});
return true;
}
Future<void> _processAutoScanCodes(List<String> 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<int>(
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 = <int, _ManyToOnePackedItem>{};
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<bool> _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 = (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 = <BoxDetailData>[];
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<int>(
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<BoxDetailData>.from(_existingBoxes);
final box = next[index];
next[index] = BoxDetailData(boxNo: box.boxNo, items: [...box.items, item]);
_existingBoxes = next;
}
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<void> _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<void> _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<void> _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 = <BoxDetailData>[];
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<int>(
0,
(max, box) => box.boxNo > max ? box.boxNo : max,
);
}
Future<void> _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 = <BoxDetailData>[];
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<int>(
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 (_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<Widget> _buildNoticeBanners() {
final banners = <Widget>[];
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) {
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<Color>(
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);