Files
pad_scanner/lib/pages/boxing_page.dart
Misaka 9dc714dbeb feat: extend boxing page for accessory boxing support
Add pending accessories section to both single-code and multi-code boxing
modes. Accessories display with orange styling and a "加入本箱" button that
calls saveBoxRecord with item_type=accessory. Extend API service with
PendingAccessory model, itemType fields on data classes, and optional
accessory_id parameter on saveBoxRecord. Show accessories with visual
indicator (wrench icon + orange background) in boxing detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 18:01:01 +08:00

489 lines
16 KiB
Dart

// ignore_for_file: unnecessary_this
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/boxing_api_actions.dart';
import 'package:pad_scanner/pages/boxing/boxing_box_mutations.dart'
as box_mutations;
import 'package:pad_scanner/pages/boxing/boxing_calculations.dart'
as boxing_calculations;
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart';
import 'package:pad_scanner/pages/boxing/dialogs/cross_paichan_dialog.dart';
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
import 'package:pad_scanner/pages/boxing/widgets/boxing_notice_banners.dart';
import 'package:pad_scanner/pages/boxing/widgets/multi_code_body.dart';
import 'package:pad_scanner/pages/boxing/widgets/single_code_body.dart';
import 'package:pad_scanner/pages/boxing_detail_page.dart';
import 'package:pad_scanner/widgets/status_bar.dart';
export 'package:pad_scanner/pages/boxing/boxing_models.dart'
show BoxingMode, BoxingPageArguments;
part 'boxing/parts/boxing_box_mutation_part.dart';
part 'boxing/parts/boxing_multi_ops_part.dart';
part 'boxing/parts/boxing_scan_part.dart';
part 'boxing/parts/boxing_single_ops_part.dart';
part 'boxing/parts/boxing_status_part.dart';
part 'boxing/parts/boxing_submit_part.dart';
// === 主页面 ===
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 {
final _scannerService = ScannerService();
final _apiService = ApiService();
late final BoxingApiActions _apiActions;
final _feedbackService = FeedbackService();
final _focusNode = FocusNode();
StreamSubscription<ScanResult>? _scanSubscription;
// 模式
BoxingMode _mode = BoxingMode.singleCode;
// 阶段
BoxingPhase _phase = BoxingPhase.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 _crossPaichaPending = false;
// 提交中
bool _isSubmitting = false;
CurrentZongpaiBoxData? _editingBox;
// 重复箱号
bool _isDuplicateBoxNo = false;
// Status bar state
StatusDotColor _statusDot = StatusDotColor.blue;
String _statusText = '等待扫码';
String? _statusOverrideText;
StatusDotColor? _statusOverrideDot;
bool _isAutoProcessing = false;
final _autoWarnings = <String>[];
// 多码凑箱:已完成装箱时可跳转的箱号
int? _completedJumpBoxNo;
// 待装箱附件
List<PendingAccessory> _pendingAccessories = [];
@override
void initState() {
super.initState();
_apiActions = BoxingApiActions(apiService: _apiService);
WidgetsBinding.instance.addObserver(this);
final arguments = widget.arguments;
if (arguments != null) {
_mode = arguments.initialMode;
}
_boxNoController.addListener(_onBoxNoTextChanged);
_scanSubscription = _scannerService.scanResults.listen(
(result) => this._onScan(result),
);
if (arguments != null && arguments.autoScanCodes.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
this._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) {
this._finishManyToOneBox();
}
}
if (_completedJumpBoxNo != null && _isJumpToBoxKey(event)) {
this._jumpToCompletedBox();
}
}
bool _isModeSwitchKey(KeyEvent event) {
final key = event.logicalKey;
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.gameButtonRight1;
}
bool _isFinishBoxKey(KeyEvent event) {
return event.logicalKey == LogicalKeyboardKey.gameButtonLeft2;
}
bool _isJumpToBoxKey(KeyEvent event) {
return event.logicalKey == LogicalKeyboardKey.gameButtonRight2;
}
void _onBoxNoTextChanged() {
if (!mounted || _mode != BoxingMode.multiCode) return;
setState(() {
_editingPackedItemId = null;
_editingPackedQuantity = '';
_deletingPackedItemId = null;
});
}
// === 模式切换 ===
String get _modeLabel {
switch (_mode) {
case BoxingMode.singleCode:
return '单码装箱';
case BoxingMode.multiCode:
return '多码凑箱';
}
}
void _cycleMode() {
_feedbackService.trigger(FeedbackEvent.modeSwitch);
setState(() {
switch (_mode) {
case BoxingMode.singleCode:
_mode = BoxingMode.multiCode;
case BoxingMode.multiCode:
_mode = BoxingMode.singleCode;
}
_resetState();
});
}
void _resetState() {
_phase = BoxingPhase.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;
_crossPaichaPending = false;
_isSubmitting = false;
_editingBox = null;
_isDuplicateBoxNo = false;
_statusOverrideText = null;
_statusOverrideDot = null;
_pendingAccessories = [];
}
// === 重复箱号检测 ===
bool _boxNoIsDuplicate() {
return switch (_mode) {
BoxingMode.multiCode => false,
BoxingMode.singleCode => boxing_calculations.singleCodeBoxNoIsDuplicate(
boxNoText: _boxNoController.text,
currentBoxes: _currentZongpaiBoxes,
editingBoxItemId: _editingBox?.boxItemId,
),
};
}
void _checkDuplicateBoxNo() {
setState(() => _isDuplicateBoxNo = _boxNoIsDuplicate());
}
// === 提交 ===
int get _packedQuantity {
return boxing_calculations.packedQuantity(_currentZongpaiBoxes);
}
int get _remainingQuantity {
return boxing_calculations.remainingQuantity(
totalQuantity: _erpQuantity,
currentBoxes: _currentZongpaiBoxes,
);
}
bool get _quantityTooHigh {
return boxing_calculations.quantityTooHigh(
remainingQuantity: _remainingQuantity,
quantityText: _quantityController.text,
);
}
bool get _canSubmit {
return boxing_calculations.canSubmitBoxing(
isSubmitting: _isSubmitting,
phase: _phase,
zongpaiNo: _zongpaiNo,
boxNoText: _boxNoController.text,
quantityText: _quantityController.text,
remainingQuantity: _remainingQuantity,
quantityTooHigh: _quantityTooHigh,
isDuplicateBoxNo: _isDuplicateBoxNo,
);
}
void _submitFromKeyboard() {
if (_canSubmit) {
this._submit();
}
}
void _focusQuantityInput() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_quantityFocusNode.requestFocus();
_quantityController.selection = TextSelection(
baseOffset: 0,
extentOffset: _quantityController.text.length,
);
});
}
int? get _currentManyToOneBoxNo {
return boxing_calculations.currentManyToOneBoxNo(_boxNoController.text);
}
List<ManyToOnePackedItem> get _visibleManyToOnePackedItems {
return boxing_calculations.visibleManyToOnePackedItems(
boxNo: _currentManyToOneBoxNo,
existingBoxes: _existingBoxes,
packedItems: _manyToOnePackedItems,
paichanNo: _paichanNo,
);
}
// === Build ===
@override
Widget build(BuildContext context) {
this._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),
BoxingModePill(
isSingle: _mode == BoxingMode.singleCode,
label: _modeLabel,
enabled: !_isAutoProcessing,
onTap: _cycleMode,
),
],
),
toolbarHeight: 44,
titleSpacing: 12,
),
body: Column(
children: [
BoxingNoticeBanners(
isAutoProcessing: _isAutoProcessing,
autoWarnings: _autoWarnings,
paichanSwitchNotice: _paichanSwitchNotice,
isDuplicateBoxNo: _isDuplicateBoxNo,
quantityTooHigh: _quantityTooHigh,
phase: _phase,
boxNoText: _boxNoController.text,
remainingQuantity: _remainingQuantity,
zongpaiNo: _zongpaiNo,
completedJumpBoxNo: _completedJumpBoxNo,
onJumpToCompletedBox: () => this._jumpToCompletedBox(),
),
Expanded(
child: _mode == BoxingMode.multiCode
? MultiCodeBody(
hasScan:
_zongpaiNo != null && _phase == BoxingPhase.scanned,
zongpaiNo: _zongpaiNo,
paichanNo: _paichanNo,
workOrderNo: _workOrderNo,
erpQuantity: _erpQuantity,
maxBoxNo: _maxBoxNo,
existingBoxes: _existingBoxes,
visibleItems: _visibleManyToOnePackedItems,
boxNoController: _boxNoController,
quantityController: _quantityController,
boxNoFocusNode: _boxNoFocusNode,
quantityFocusNode: _quantityFocusNode,
isSubmitting: _isSubmitting,
quantityTooHigh: _quantityTooHigh,
canSubmit: _canSubmit,
editingPackedItemId: _editingPackedItemId,
editingPackedQuantity: _editingPackedQuantity,
deletingPackedItemId: _deletingPackedItemId,
onOpenDetail: () => this._openDetail(),
onSubmitFromKeyboard: _submitFromKeyboard,
onSubmit: () => this._submit(),
onQuantityChanged: () => setState(() {}),
onFinishBox: () => this._finishManyToOneBox(),
onEditingPackedQuantityChanged: (value) =>
_editingPackedQuantity = value,
onSavePackedItem: (item) => this._savePackedItem(item),
onCancelEditPackedItem: () =>
this._cancelEditPackedItem(),
onStartEditPackedItem: (item) =>
this._startEditPackedItem(item),
onStartDeletePackedItem: (item) {
setState(() {
_deletingPackedItemId = item.boxItemId;
_editingPackedItemId = null;
_editingPackedQuantity = '';
});
},
onDeletePackedItem: (item) =>
this._deletePackedItem(item),
onCancelDeletePackedItem: () =>
setState(() => _deletingPackedItemId = null),
pendingAccessories: _pendingAccessories,
onSubmitAccessory: (accessory) =>
this._submitAccessory(accessory),
)
: SingleCodeBody(
isWaiting:
_phase == BoxingPhase.waiting && _zongpaiNo == null,
isFinished:
_phase == BoxingPhase.scanned &&
_zongpaiNo != null &&
_remainingQuantity <= 0,
zongpaiNo: _zongpaiNo,
paichanNo: _paichanNo,
workOrderNo: _workOrderNo,
erpQuantity: _erpQuantity,
packedQuantity: _packedQuantity,
maxBoxNo: _maxBoxNo,
assignedItems: _currentZongpaiBoxes,
existingBoxes: _existingBoxes,
boxNoController: _boxNoController,
quantityController: _quantityController,
boxNoFocusNode: _boxNoFocusNode,
quantityFocusNode: _quantityFocusNode,
isSubmitting: _isSubmitting,
isDuplicateBoxNo: _isDuplicateBoxNo,
quantityTooHigh: _quantityTooHigh,
canSubmit: _canSubmit,
editingAssignedItemId: _editingAssignedItemId,
editingAssignedQuantity: _editingAssignedQuantity,
deletingAssignedItemId: _deletingAssignedItemId,
onOpenDetail: () => this._openDetail(),
onCheckDuplicateBoxNo: _checkDuplicateBoxNo,
onSubmitFromKeyboard: _submitFromKeyboard,
onSubmit: () => this._submit(),
onQuantityChanged: () => setState(() {}),
onEditingAssignedQuantityChanged: (value) =>
_editingAssignedQuantity = value,
onSaveAssignedItem: (item) =>
this._saveAssignedItem(item),
onCancelEditAssigned: () => this._cancelEditAssigned(),
onStartEditAssigned: (item) =>
this._startEditAssigned(item),
onStartDeleteAssigned: (item) {
setState(() {
_deletingAssignedItemId = item.boxItemId;
_editingAssignedItemId = null;
_editingAssignedQuantity = '';
});
},
onDeleteAssignedItem: (item) =>
this._deleteAssignedItem(item),
onCancelDeleteAssigned: () =>
setState(() => _deletingAssignedItemId = null),
pendingAccessories: _pendingAccessories,
onSubmitAccessory: (accessory) =>
this._submitAccessory(accessory),
),
),
// 底部状态栏
StatusBar(dotColor: effectiveDot, text: effectiveText),
],
),
),
);
}
}