Compare commits

..

2 Commits

Author SHA1 Message Date
Misaka_Company
928e7296b1 feat: 多码凑箱已完成装箱时支持跳转箱号查看 & 统一按键识别
- 多码凑箱模式下,扫描已完成装箱的总排号时显示可点击警告横幅,
  点击或按P4键可跳转到该总排号所在箱号查看装箱信息
- 统一所有页面按键处理为 LogicalKeyboardKey 命名常量形式,
  移除 _androidKeyCodeFromLogicalKey 回退路径

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 14:21:38 +08:00
Misaka_Company
7d22c23dfe refactor: 装箱编号列表改为表格式布局,对齐上架登记设计语言
单码装箱和多码凑箱的列表从卡片式(序号+色条+双行内容)改为表格式
(列头+flex列布局+左侧border色条),与上架登记模块保持一致。

字段:箱号/总排号 | 工令号 | 数量 | 操作按钮

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 12:40:51 +08:00
38 changed files with 3726 additions and 5963 deletions

View File

@@ -6,28 +6,6 @@
- All new feature development must be done on a `dev` branch, created from `master`
- After verification, merge `dev` back into `master`
## Pre-Commit Checks
Before committing Flutter app changes, run these checks:
```bash
dart format --set-exit-if-changed .
flutter analyze
flutter test
```
If `flutter test` fails with a localhost WebSocket/proxy error, follow the proxy cleanup steps in the Flutter Test Rules section below and run it again.
### Handling Check Failures
- If a check fails because of the current change, fix the issue before committing.
- If `flutter analyze` reports any issue in files changed by the current task, fix it before committing.
- If `flutter analyze` reports only unrelated pre-existing issues, do not fix them in the current commit. Report the file, line, and lint/error name, then handle them in a separate cleanup commit or task.
- If a check fails because of unrelated pre-existing issues, do not include unrelated fixes in the same commit. Report the failing command and the existing issues, then handle them in a separate cleanup commit or task.
- If formatting fails, format only files changed by the current task. Do not run a broad formatting cleanup unless that is the explicit task.
- Treat `flutter test` failures as blocking unless the failure is clearly caused by the proxy issue described below and passes after rerunning with proxy variables cleared.
- When committing or reporting completion, mention which checks were run and whether any remaining failures are unrelated pre-existing issues.
## App Installation Rules
**Always use `adb install -r` to install the app. Never use `flutter install`.**
@@ -43,16 +21,3 @@ flutter build apk --release
# 2. Install (in-place upgrade, preserves config)
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
## Flutter Test Rules
Before running `flutter test`, temporarily remove proxy environment variables for the current shell. The Flutter tester uses a localhost WebSocket, and proxy settings can break that connection with `Invalid WebSocket upgrade request`.
### PowerShell
```powershell
$env:HTTP_PROXY=''
$env:HTTPS_PROXY=''
$env:NO_PROXY='localhost,127.0.0.1,::1'
flutter test
```

View File

@@ -1,150 +0,0 @@
import 'package:pad_scanner/services/api_service.dart';
import 'package:pad_scanner/services/app_config_service.dart';
enum BoxingActionErrorKind { missingApiUrl, network, duplicate, backend }
class BoxingActionResult<T> {
final bool success;
final T? data;
final BoxingActionErrorKind? errorKind;
final String? errorMessage;
final int? boxNo;
const BoxingActionResult._({
required this.success,
this.data,
this.errorKind,
this.errorMessage,
this.boxNo,
});
const BoxingActionResult.ok(T data) : this._(success: true, data: data);
const BoxingActionResult.error({
required BoxingActionErrorKind kind,
required String message,
int? boxNo,
}) : this._(
success: false,
errorKind: kind,
errorMessage: message,
boxNo: boxNo,
);
}
typedef ApiUrlLoader = Future<String?> Function();
class BoxingApiActions {
static const networkErrorMessage = '网络异常,请检查网络连接';
static const missingApiUrlMessage = '未配置 API 地址,请前往设置';
final ApiService _apiService;
final ApiUrlLoader _loadApiUrl;
BoxingApiActions({required ApiService apiService, ApiUrlLoader? loadApiUrl})
: _apiService = apiService,
_loadApiUrl =
loadApiUrl ?? (() => AppConfigService().getString('api_url'));
Future<BoxingActionResult<BoxInfoResult>> fetchBoxInfo(String zongpai) async {
final baseUrl = await _requireBaseUrl();
if (baseUrl == null) return _missingApiUrl();
final result = await _apiService.fetchBoxInfo(
baseUrl: baseUrl,
zongpaiNo: zongpai,
);
if (result.success) return BoxingActionResult.ok(result);
return BoxingActionResult.error(
kind: _kindForMessage(result.errorMessage),
message: result.errorMessage ?? '查询失败',
);
}
Future<BoxingActionResult<BoxSaveResult>> saveBoxRecord({
required String zongpaiNo,
required int boxNo,
required int quantity,
}) async {
final baseUrl = await _requireBaseUrl();
if (baseUrl == null) return _missingApiUrl();
final result = await _apiService.saveBoxRecord(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
boxNo: boxNo,
quantity: quantity,
);
return _saveResult(result, fallbackMessage: '提交失败');
}
Future<BoxingActionResult<BoxSaveResult>> updateBoxRecord({
required int boxItemId,
required int boxNo,
required int quantity,
}) async {
final baseUrl = await _requireBaseUrl();
if (baseUrl == null) return _missingApiUrl();
final result = await _apiService.updateBoxRecord(
baseUrl: baseUrl,
boxItemId: boxItemId,
boxNo: boxNo,
quantity: quantity,
);
return _saveResult(result, fallbackMessage: '修改失败');
}
Future<BoxingActionResult<BoxDeleteResult>> deleteBoxRecord({
required int boxItemId,
}) async {
final baseUrl = await _requireBaseUrl();
if (baseUrl == null) return _missingApiUrl();
final result = await _apiService.deleteBoxRecord(
baseUrl: baseUrl,
boxItemId: boxItemId,
);
if (result.success) return BoxingActionResult.ok(result);
return BoxingActionResult.error(
kind: _kindForMessage(result.errorMessage),
message: result.errorMessage ?? '删除失败',
);
}
Future<String?> _requireBaseUrl() async {
final baseUrl = await _loadApiUrl() ?? '';
if (baseUrl.isEmpty) return null;
return baseUrl;
}
BoxingActionResult<T> _missingApiUrl<T>() {
return const BoxingActionResult.error(
kind: BoxingActionErrorKind.missingApiUrl,
message: missingApiUrlMessage,
);
}
BoxingActionResult<BoxSaveResult> _saveResult(
BoxSaveResult result, {
required String fallbackMessage,
}) {
if (result.success) return BoxingActionResult.ok(result);
if (result.isDuplicate) {
return BoxingActionResult.error(
kind: BoxingActionErrorKind.duplicate,
message: result.errorMessage ?? fallbackMessage,
boxNo: result.boxNo,
);
}
return BoxingActionResult.error(
kind: _kindForMessage(result.errorMessage),
message: result.errorMessage ?? fallbackMessage,
);
}
BoxingActionErrorKind _kindForMessage(String? message) {
if (message == networkErrorMessage) return BoxingActionErrorKind.network;
return BoxingActionErrorKind.backend;
}
}

View File

@@ -1,135 +0,0 @@
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/services/api_service.dart';
class BoxMutationResult {
final List<BoxDetailData> boxes;
final int maxBoxNo;
const BoxMutationResult({required this.boxes, required this.maxBoxNo});
}
int maxBoxNoFor(List<BoxDetailData> boxes) {
return boxes.fold<int>(0, (max, box) => box.boxNo > max ? box.boxNo : max);
}
BoxMutationResult addExistingBoxItem({
required List<BoxDetailData> existingBoxes,
required int boxNo,
required int quantity,
required int? boxItemId,
required String zongpaiNo,
required String? workOrderNo,
required int? totalQuantity,
}) {
final item = BoxItemData(
boxItemId: boxItemId,
zongpaiNo: zongpaiNo,
workOrderNo: workOrderNo,
quantity: quantity,
totalQuantity: totalQuantity,
);
final index = existingBoxes.indexWhere((box) => box.boxNo == boxNo);
if (index < 0) {
final boxes = [
...existingBoxes,
BoxDetailData(boxNo: boxNo, items: [item]),
]..sort((a, b) => a.boxNo.compareTo(b.boxNo));
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
}
final boxes = List<BoxDetailData>.from(existingBoxes);
final box = boxes[index];
boxes[index] = BoxDetailData(boxNo: box.boxNo, items: [...box.items, item]);
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
}
BoxMutationResult replaceExistingBoxItem({
required List<BoxDetailData> existingBoxes,
required CurrentZongpaiBoxData editing,
required int boxNo,
required int quantity,
required String zongpaiNo,
required String? workOrderNo,
required int? totalQuantity,
}) {
final updatedItem = BoxItemData(
boxItemId: editing.boxItemId,
zongpaiNo: zongpaiNo,
workOrderNo: workOrderNo,
quantity: quantity,
totalQuantity: totalQuantity,
);
return _replaceItemById(
existingBoxes: existingBoxes,
boxItemId: editing.boxItemId,
targetBoxNo: boxNo,
updatedItem: updatedItem,
);
}
BoxMutationResult replaceExistingPackedItem({
required List<BoxDetailData> existingBoxes,
required ManyToOnePackedItem item,
required int boxNo,
required int quantity,
}) {
final updatedItem = BoxItemData(
boxItemId: item.boxItemId,
zongpaiNo: item.zongpaiNo,
workOrderNo: item.workOrderNo,
quantity: quantity,
totalQuantity: item.totalQuantity,
);
return _replaceItemById(
existingBoxes: existingBoxes,
boxItemId: item.boxItemId,
targetBoxNo: boxNo,
updatedItem: updatedItem,
);
}
BoxMutationResult removeExistingBoxItem({
required List<BoxDetailData> existingBoxes,
required int boxItemId,
}) {
final boxes = <BoxDetailData>[];
for (final box in existingBoxes) {
final items = box.items
.where((item) => item.boxItemId != boxItemId)
.toList();
if (items.isNotEmpty) {
boxes.add(BoxDetailData(boxNo: box.boxNo, items: items));
}
}
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
}
BoxMutationResult _replaceItemById({
required List<BoxDetailData> existingBoxes,
required int boxItemId,
required int targetBoxNo,
required BoxItemData updatedItem,
}) {
final boxes = <BoxDetailData>[];
for (final box in existingBoxes) {
final items = box.items.where((item) {
return item.boxItemId != boxItemId;
}).toList();
if (items.isNotEmpty) {
boxes.add(BoxDetailData(boxNo: box.boxNo, items: items));
}
}
final targetIndex = boxes.indexWhere((box) => box.boxNo == targetBoxNo);
if (targetIndex >= 0) {
final target = boxes[targetIndex];
boxes[targetIndex] = BoxDetailData(
boxNo: target.boxNo,
items: [...target.items, updatedItem],
);
} else {
boxes.add(BoxDetailData(boxNo: targetBoxNo, items: [updatedItem]));
}
boxes.sort((a, b) => a.boxNo.compareTo(b.boxNo));
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
}

View File

@@ -1,101 +0,0 @@
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/services/api_service.dart';
int packedQuantity(List<CurrentZongpaiBoxData> items) {
return items.fold<int>(0, (sum, item) => sum + item.quantity);
}
int remainingQuantity({
required int? totalQuantity,
required List<CurrentZongpaiBoxData> currentBoxes,
}) {
return (totalQuantity ?? 0) - packedQuantity(currentBoxes);
}
bool quantityTooHigh({
required int remainingQuantity,
required String quantityText,
}) {
if (remainingQuantity <= 0) return false;
final quantity = int.tryParse(quantityText);
return quantity != null && quantity > remainingQuantity;
}
bool canSubmitBoxing({
required bool isSubmitting,
required BoxingPhase phase,
required String? zongpaiNo,
required String boxNoText,
required String quantityText,
required int remainingQuantity,
required bool quantityTooHigh,
required bool isDuplicateBoxNo,
}) {
if (isSubmitting || phase != BoxingPhase.scanned) return false;
if (zongpaiNo == null) return false;
final boxNo = int.tryParse(boxNoText);
final quantity = int.tryParse(quantityText);
if (boxNo == null || boxNo <= 0 || quantity == null || quantity <= 0) {
return false;
}
if (remainingQuantity <= 0) return false;
if (quantityTooHigh) return false;
if (isDuplicateBoxNo) return false;
return true;
}
int? currentManyToOneBoxNo(String boxNoText) {
final boxNo = int.tryParse(boxNoText.trim());
if (boxNo == null || boxNo <= 0) return null;
return boxNo;
}
bool singleCodeBoxNoIsDuplicate({
required String boxNoText,
required List<CurrentZongpaiBoxData> currentBoxes,
required int? editingBoxItemId,
}) {
final boxNo = int.tryParse(boxNoText);
if (boxNo == null) return false;
return currentBoxes.any((box) {
if (box.boxNo != boxNo) return false;
return editingBoxItemId == null || box.boxItemId != editingBoxItemId;
});
}
List<ManyToOnePackedItem> visibleManyToOnePackedItems({
required int? boxNo,
required List<BoxDetailData> existingBoxes,
required List<ManyToOnePackedItem> packedItems,
required String? paichanNo,
}) {
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 packedItems.where((item) => item.boxNo == boxNo)) {
byItemId[item.boxItemId] = item;
}
return byItemId.values.toList(growable: false);
}
int maxAssignedQuantity({
required int? totalQuantity,
required int packedQuantity,
required int itemQuantity,
}) {
return (totalQuantity ?? 0) - (packedQuantity - itemQuantity);
}

View File

@@ -1,56 +0,0 @@
enum BoxingMode {
singleCode, // 单码装箱
multiCode, // 多码凑箱
}
enum BoxingPhase {
waiting, // 等待扫码
scanned, // 已扫码,显示信息
submitted, // 已提交成功
}
class BoxingPageArguments {
final BoxingMode initialMode;
final List<String> autoScanCodes;
const BoxingPageArguments({
required this.initialMode,
required this.autoScanCodes,
});
}
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,
);
}
}

View File

@@ -1,97 +0,0 @@
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/widgets/status_bar.dart';
class BoxingStatusPresentation {
final StatusDotColor dot;
final String text;
const BoxingStatusPresentation({required this.dot, required this.text});
}
BoxingStatusPresentation boxingStatusFor({
required bool isAutoProcessing,
required bool isSubmitting,
required bool isDuplicateBoxNo,
required bool quantityTooHigh,
required bool isEditingAssigned,
required bool isDeletingAssigned,
required bool hasPaichanSwitchNotice,
required BoxingPhase phase,
required bool isMultiCode,
required bool hasVisibleManyToOneItems,
required int remainingQuantity,
required String boxNoText,
}) {
if (isAutoProcessing) {
return const BoxingStatusPresentation(
dot: StatusDotColor.orange,
text: '正在同步转运数据...',
);
}
if (isSubmitting) {
return const BoxingStatusPresentation(
dot: StatusDotColor.orange,
text: '正在提交…',
);
}
if (isDuplicateBoxNo && phase == BoxingPhase.scanned) {
return BoxingStatusPresentation(
dot: StatusDotColor.amber,
text: '箱号 $boxNoText 已存在,请重新输入',
);
}
if (quantityTooHigh && phase == BoxingPhase.scanned) {
return const BoxingStatusPresentation(
dot: StatusDotColor.red,
text: '超出可装数量上限',
);
}
if (isEditingAssigned) {
return const BoxingStatusPresentation(
dot: StatusDotColor.amber,
text: '正在编辑已分配记录',
);
}
if (isDeletingAssigned) {
return const BoxingStatusPresentation(
dot: StatusDotColor.red,
text: '请确认是否删除该箱记录',
);
}
if (hasPaichanSwitchNotice) {
return const BoxingStatusPresentation(
dot: StatusDotColor.blue,
text: '排产号已切换,箱号已重置',
);
}
switch (phase) {
case BoxingPhase.waiting:
if (isMultiCode && hasVisibleManyToOneItems) {
return const BoxingStatusPresentation(
dot: StatusDotColor.blue,
text: '请继续扫码或完成本箱',
);
}
return const BoxingStatusPresentation(
dot: StatusDotColor.blue,
text: '等待扫码',
);
case BoxingPhase.scanned:
if (remainingQuantity <= 0) {
return const BoxingStatusPresentation(
dot: StatusDotColor.red,
text: '该总排号已全部装箱完毕',
);
}
return const BoxingStatusPresentation(
dot: StatusDotColor.blue,
text: '数量已填入,请确认或修改',
);
case BoxingPhase.submitted:
return BoxingStatusPresentation(
dot: StatusDotColor.green,
text: isMultiCode ? '请扫描下一个总排号' : '装箱成功,可继续扫码',
);
}
}

View File

@@ -1,126 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
Future<bool> showCrossPaichanDialog({
required BuildContext context,
required String currentPaicha,
required String newPaicha,
}) async {
final dialogFocus = FocusNode();
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
final dialogFuture = showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) {
void dismissAsNo() => Navigator.of(ctx).pop(false);
void confirmSwitch() => Navigator.of(ctx).pop(true);
return KeyboardListener(
focusNode: dialogFocus,
onKeyEvent: (event) {
if (event is! KeyDownEvent) return;
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
setDialogState(() => selectedIndex = 0);
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
setDialogState(() => selectedIndex = 1);
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
selectedIndex == 0 ? dismissAsNo() : confirmSwitch();
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
dismissAsNo();
}
},
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) dismissAsNo();
},
child: AlertDialog(
backgroundColor: Colors.orange.shade50,
title: Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange.shade800),
const SizedBox(width: 8),
Text(
'跨排产号扫描',
style: TextStyle(color: Colors.orange.shade900),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'当前排产号:$currentPaicha\n'
'新排产号:$newPaicha\n\n'
'是否切换到新的排产号?\n\n'
'选择「是」将放弃当前已扫描的所有数据。',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
_DialogOptionButton(
label: '',
selected: selectedIndex == 0,
onTap: dismissAsNo,
),
const SizedBox(height: 8),
_DialogOptionButton(
label: '是,切换排产号',
selected: selectedIndex == 1,
onTap: confirmSwitch,
),
],
),
),
),
);
},
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
dialogFocus.requestFocus();
});
final result = await dialogFuture;
dialogFocus.dispose();
return result ?? false;
}
class _DialogOptionButton extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _DialogOptionButton({
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 40,
child: ElevatedButton(
onPressed: onTap,
style: ElevatedButton.styleFrom(
backgroundColor: selected
? Colors.blue.shade700
: Colors.grey.shade200,
foregroundColor: selected ? Colors.white : Colors.black87,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Text(
label,
style: TextStyle(
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
),
),
),
);
}
}

View File

@@ -1,59 +0,0 @@
part of '../../boxing_page.dart';
extension _BoxingBoxMutationPart on _BoxingPageState {
void _replaceExistingBoxItem(
CurrentZongpaiBoxData editing,
int boxNo,
int quantity,
) {
final result = box_mutations.replaceExistingBoxItem(
existingBoxes: _existingBoxes,
editing: editing,
boxNo: boxNo,
quantity: quantity,
zongpaiNo: _zongpaiNo!,
workOrderNo: _workOrderNo,
totalQuantity: _erpQuantity,
);
_existingBoxes = result.boxes;
_maxBoxNo = result.maxBoxNo;
}
void _addExistingBoxItem(int boxNo, int quantity, int? boxItemId) {
final result = box_mutations.addExistingBoxItem(
existingBoxes: _existingBoxes,
boxNo: boxNo,
quantity: quantity,
boxItemId: boxItemId,
zongpaiNo: _zongpaiNo!,
workOrderNo: _workOrderNo,
totalQuantity: _erpQuantity,
);
_existingBoxes = result.boxes;
_maxBoxNo = result.maxBoxNo;
}
void _replaceExistingPackedItem(
ManyToOnePackedItem item,
int boxNo,
int quantity,
) {
final result = box_mutations.replaceExistingPackedItem(
existingBoxes: _existingBoxes,
item: item,
boxNo: boxNo,
quantity: quantity,
);
_existingBoxes = result.boxes;
_maxBoxNo = result.maxBoxNo;
}
void _removeExistingBoxItem(int boxItemId) {
final result = box_mutations.removeExistingBoxItem(
existingBoxes: _existingBoxes,
boxItemId: boxItemId,
);
_existingBoxes = result.boxes;
_maxBoxNo = result.maxBoxNo;
}
}

View File

@@ -1,112 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
part of '../../boxing_page.dart';
extension _BoxingMultiOpsPart on _BoxingPageState {
void _finishManyToOneBox() {
setState(() {
_manyToOnePackedItems.clear();
_editingPackedItemId = null;
_editingPackedQuantity = '';
_deletingPackedItemId = null;
_zongpaiNo = null;
_phase = BoxingPhase.waiting;
_quantityController.clear();
_boxNoController.text = (_maxBoxNo + 1).toString();
_statusOverrideText = null;
_statusOverrideDot = null;
});
}
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) {
this._showStatusOverride(
'请输入有效箱号和数量',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
setState(() => _isSubmitting = true);
final action = await _apiActions.updateBoxRecord(
boxItemId: item.boxItemId,
boxNo: boxNo,
quantity: quantity,
);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (action.success) {
setState(() {
final index = _manyToOnePackedItems.indexWhere(
(packed) => packed.boxItemId == item.boxItemId,
);
if (index >= 0) {
_manyToOnePackedItems[index] = _manyToOnePackedItems[index].copyWith(
boxNo: boxNo,
quantity: quantity,
);
}
this._replaceExistingPackedItem(item, boxNo, quantity);
_editingPackedItemId = null;
_editingPackedQuantity = '';
});
this._showStatusOverride(
'修改成功',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
} else {
this._showActionError(action, fallback: '修改失败');
}
}
Future<void> _deletePackedItem(ManyToOnePackedItem item) async {
setState(() => _isSubmitting = true);
final action = await _apiActions.deleteBoxRecord(boxItemId: item.boxItemId);
if (!mounted) return;
setState(() {
_isSubmitting = false;
if (action.success) {
_manyToOnePackedItems.removeWhere(
(packed) => packed.boxItemId == item.boxItemId,
);
this._removeExistingBoxItem(item.boxItemId);
if (_editingPackedItemId == item.boxItemId) {
_editingPackedItemId = null;
_editingPackedQuantity = '';
}
_deletingPackedItemId = null;
}
});
if (action.success) {
this._showStatusOverride(
'删除成功',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
} else {
this._showActionError(action, fallback: '删除失败');
}
}
}

View File

@@ -1,271 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
part of '../../boxing_page.dart';
extension _BoxingScanPart on _BoxingPageState {
void _onScan(ScanResult result) {
if (_isAutoProcessing) {
return;
}
if (_crossPaichaPending) {
_feedbackService.trigger(FeedbackEvent.alreadyCompleted);
return;
}
final parsed = CodeParser.parse(result.barcode);
if (parsed.type != CodeType.zongpaiNo) {
_feedbackService.trigger(FeedbackEvent.scanInvalid);
this._showStatusOverride(
'无效码,请重新扫描',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
final zongpai = parsed.value;
// 三种模式都允许后扫入的总排号覆盖当前扫码区;未确认数据不会入库。
this._queryBoxInfo(zongpai);
}
Future<bool> _queryBoxInfo(String zongpai) async {
final action = await _apiActions.fetchBoxInfo(zongpai);
if (!mounted) return false;
if (!action.success) {
_feedbackService.trigger(switch (action.errorKind) {
BoxingActionErrorKind.network => FeedbackEvent.networkError,
BoxingActionErrorKind.missingApiUrl => FeedbackEvent.submitFailure,
_ => FeedbackEvent.scanInvalid,
});
this._showStatusOverride(
action.errorMessage ?? '查询失败',
this._dotForActionError(action.errorKind),
const Duration(seconds: 2),
);
return false;
}
final result = action.data!;
// 判断该总牌号是否已全部装箱完毕
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 = BoxingPhase.scanned;
_isDuplicateBoxNo = false;
_editingBox = null;
_editingAssignedItemId = null;
_editingAssignedQuantity = '';
_deletingAssignedItemId = null;
_completedJumpBoxNo = null;
_statusOverrideText = null;
if (_mode == BoxingMode.singleCode) {
_paichanSwitchNotice = null;
}
// 根据模式自动填充
this._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 this._queryBoxInfo(code);
if (!ok && mounted) {
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
}
} else {
for (final code in codes) {
if (!mounted) return;
final queried = await this._queryBoxInfo(code);
if (!queried) {
if (mounted) {
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
}
continue;
}
if (!_canSubmit) {
if (mounted) {
setState(() => _autoWarnings.add('总排号 $code 暂不能装箱,已忽略'));
}
continue;
}
final saved = await this._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;
}
_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) {
_crossPaichaPending = true;
_feedbackService.trigger(FeedbackEvent.alreadyCompleted);
this._showCrossPaichaDialog();
return;
} else {
_paichanSwitchNotice = null;
}
final boxNoToApply = decision.boxNoToApply;
if (boxNoToApply != null) {
_boxNoController.text = boxNoToApply.toString();
}
_lastScannedPaichanNo = _paichanNo;
final remaining = _remainingQuantity;
if (remaining <= 0) {
_quantityController.clear();
if (_manyToOnePackedItems.isEmpty &&
_currentZongpaiBoxes.isNotEmpty) {
_completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo;
}
_statusOverrideText = '该总排号已全部装箱完毕';
_statusOverrideDot = StatusDotColor.red;
return;
}
_quantityController.text = remaining.toString();
_quantityController.selection = TextSelection(
baseOffset: 0,
extentOffset: _quantityController.text.length,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _mode == BoxingMode.multiCode) {
_quantityFocusNode.requestFocus();
}
});
}
_isDuplicateBoxNo = _boxNoIsDuplicate();
}
Future<void> _showCrossPaichaDialog() async {
final currentPaicha = _lastScannedPaichanNo ?? '--';
final newPaicha = _paichanNo ?? '--';
final confirmed = await showCrossPaichanDialog(
context: context,
currentPaicha: currentPaicha,
newPaicha: newPaicha,
);
if (!mounted) return;
confirmed ? this._confirmPaichanSwitch() : this._cancelPaichanSwitch();
}
void _confirmPaichanSwitch() {
setState(() {
_crossPaichaPending = false;
_manyToOnePackedItems.clear();
_editingPackedItemId = null;
_editingPackedQuantity = '';
_deletingPackedItemId = null;
_paichanSwitchNotice = '排产号已切换:${_paichanNo ?? "--"}';
_boxNoController.text = (_maxBoxNo + 1).toString();
_lastScannedPaichanNo = _paichanNo;
});
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
// Continue with remaining quantity flow
final remaining = _remainingQuantity;
if (remaining <= 0) {
_quantityController.clear();
if (_manyToOnePackedItems.isEmpty && _currentZongpaiBoxes.isNotEmpty) {
_completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo;
}
setState(() {
_statusOverrideText = '该总排号已全部装箱完毕';
_statusOverrideDot = StatusDotColor.red;
});
return;
}
setState(() {
_quantityController.text = remaining.toString();
_quantityController.selection = TextSelection(
baseOffset: 0,
extentOffset: _quantityController.text.length,
);
_isDuplicateBoxNo = _boxNoIsDuplicate();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _mode == BoxingMode.multiCode) {
_quantityFocusNode.requestFocus();
}
});
}
void _cancelPaichanSwitch() {
setState(() {
_crossPaichaPending = false;
_zongpaiNo = null;
_paichanNo = _lastScannedPaichanNo;
_phase = BoxingPhase.waiting;
});
}
}

View File

@@ -1,133 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
part of '../../boxing_page.dart';
extension _BoxingSingleOpsPart on _BoxingPageState {
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) {
return boxing_calculations.maxAssignedQuantity(
totalQuantity: _erpQuantity,
packedQuantity: _packedQuantity,
itemQuantity: item.quantity,
);
}
void _refreshSingleCodeNewInput({bool focusQuantity = true}) {
_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) {
this._showStatusOverride(
'请输入有效数量',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
if (quantity > this._maxAssignedQuantity(item)) {
this._showStatusOverride(
'超出可装数量上限',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
setState(() => _isSubmitting = true);
final action = await _apiActions.updateBoxRecord(
boxItemId: item.boxItemId,
boxNo: item.boxNo,
quantity: quantity,
);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (action.success) {
setState(() {
_currentZongpaiBoxes = _currentZongpaiBoxes.map((box) {
if (box.boxItemId != item.boxItemId) return box;
return CurrentZongpaiBoxData(
boxItemId: box.boxItemId,
boxNo: box.boxNo,
quantity: quantity,
);
}).toList();
this._replaceExistingBoxItem(item, item.boxNo, quantity);
_editingAssignedItemId = null;
_editingAssignedQuantity = '';
this._refreshSingleCodeNewInput();
});
this._showStatusOverride(
'修改成功',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
} else {
this._showActionError(action, fallback: '修改失败');
}
}
Future<void> _deleteAssignedItem(CurrentZongpaiBoxData item) async {
setState(() => _isSubmitting = true);
final action = await _apiActions.deleteBoxRecord(boxItemId: item.boxItemId);
if (!mounted) return;
setState(() {
_isSubmitting = false;
if (action.success) {
_currentZongpaiBoxes = _currentZongpaiBoxes
.where((box) => box.boxItemId != item.boxItemId)
.toList();
this._removeExistingBoxItem(item.boxItemId);
if (_editingAssignedItemId == item.boxItemId) {
_editingAssignedItemId = null;
_editingAssignedQuantity = '';
}
_deletingAssignedItemId = null;
this._refreshSingleCodeNewInput();
}
});
if (action.success) {
this._showStatusOverride(
'删除成功',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
} else {
this._showActionError(action, fallback: '删除失败');
}
}
}

View File

@@ -1,86 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
part of '../../boxing_page.dart';
extension _BoxingStatusPart on _BoxingPageState {
StatusDotColor _dotForActionError(BoxingActionErrorKind? kind) {
return switch (kind) {
BoxingActionErrorKind.network => StatusDotColor.yellow,
BoxingActionErrorKind.duplicate => StatusDotColor.amber,
_ => StatusDotColor.red,
};
}
void _showActionError<T>(
BoxingActionResult<T> action, {
required String fallback,
}) {
_feedbackService.trigger(switch (action.errorKind) {
BoxingActionErrorKind.network => FeedbackEvent.networkError,
BoxingActionErrorKind.duplicate => FeedbackEvent.duplicateBoxNo,
_ => FeedbackEvent.submitFailure,
});
this._showStatusOverride(
action.errorMessage ?? fallback,
this._dotForActionError(action.errorKind),
const Duration(seconds: 2),
);
}
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
setState(() {
_statusOverrideText = text;
_statusOverrideDot = dot;
});
Future.delayed(duration, () {
if (mounted) {
setState(() {
_statusOverrideText = null;
_statusOverrideDot = null;
});
}
});
}
void _jumpToCompletedBox() {
final boxNo = _completedJumpBoxNo;
if (boxNo == null) return;
setState(() {
_boxNoController.text = boxNo.toString();
_completedJumpBoxNo = null;
_statusOverrideText = null;
_statusOverrideDot = null;
});
}
void _openDetail() {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BoxingDetailPage(
paichanNo: _paichanNo ?? '',
existingBoxes: _existingBoxes,
),
),
);
}
void _updateBaseStatus() {
final status = boxingStatusFor(
isAutoProcessing: _isAutoProcessing,
isSubmitting: _isSubmitting,
isDuplicateBoxNo: _isDuplicateBoxNo,
quantityTooHigh: _quantityTooHigh,
isEditingAssigned: _editingAssignedItemId != null,
isDeletingAssigned: _deletingAssignedItemId != null,
hasPaichanSwitchNotice: _paichanSwitchNotice != null,
phase: _phase,
isMultiCode: _mode == BoxingMode.multiCode,
hasVisibleManyToOneItems: _visibleManyToOnePackedItems.isNotEmpty,
remainingQuantity: _remainingQuantity,
boxNoText: _boxNoController.text,
);
_statusDot = status.dot;
_statusText = status.text;
}
}

View File

@@ -1,161 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
part of '../../boxing_page.dart';
extension _BoxingSubmitPart on _BoxingPageState {
Future<bool> _submit() async {
if (!_canSubmit) return false;
final boxNo = int.parse(_boxNoController.text);
final quantity = int.parse(_quantityController.text);
setState(() => _isSubmitting = true);
final editing = _editingBox;
final action = editing == null
? await _apiActions.saveBoxRecord(
zongpaiNo: _zongpaiNo!,
boxNo: boxNo,
quantity: quantity,
)
: await _apiActions.updateBoxRecord(
boxItemId: editing.boxItemId,
boxNo: boxNo,
quantity: quantity,
);
if (!mounted) return false;
setState(() => _isSubmitting = false);
if (action.success) {
if (editing == null) {
this._onSubmitSuccess(boxNo, quantity, action.data!.boxItemId);
} else {
this._onUpdateSuccess(editing, boxNo, quantity);
}
return true;
} else if (action.errorKind == BoxingActionErrorKind.duplicate) {
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
this._showStatusOverride(
'该总排号在箱号 ${action.boxNo ?? ""} 已存在,请重新输入',
StatusDotColor.amber,
const Duration(seconds: 2),
);
return false;
} else {
this._showActionError(action, fallback: '提交失败');
return false;
}
}
void _onSubmitSuccess(int boxNo, int quantity, int? boxItemId) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
setState(() {
if (_mode == BoxingMode.singleCode && boxItemId != null) {
_currentZongpaiBoxes = List.from(_currentZongpaiBoxes)
..add(
CurrentZongpaiBoxData(
boxItemId: boxItemId,
boxNo: boxNo,
quantity: quantity,
),
);
}
this._addExistingBoxItem(boxNo, quantity, boxItemId);
_maxBoxNo = _maxBoxNo > boxNo ? _maxBoxNo : boxNo;
});
switch (_mode) {
case BoxingMode.singleCode:
final remaining = _remainingQuantity;
if (remaining <= 0) {
this._showStatusOverride(
'装箱成功',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
} else {
setState(() {
_phase = BoxingPhase.scanned;
_boxNoController.text = (_maxBoxNo + 1).toString();
_quantityController.text = remaining.toString();
_quantityController.selection = TextSelection(
baseOffset: 0,
extentOffset: _quantityController.text.length,
);
_isDuplicateBoxNo = false;
});
_focusQuantityInput();
this._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 = BoxingPhase.waiting;
_zongpaiNo = null;
_editingPackedItemId = null;
_editingPackedQuantity = '';
_deletingPackedItemId = null;
});
this._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();
this._replaceExistingBoxItem(editing, boxNo, quantity);
_editingBox = null;
_boxNoController.text = (_maxBoxNo + 1).toString();
final remaining = _remainingQuantity;
if (remaining > 0) {
_quantityController.text = remaining.toString();
} else {
_quantityController.clear();
}
_isDuplicateBoxNo = false;
});
this._showStatusOverride(
'修改成功',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
}
}

View File

@@ -1,178 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
class BoxingNoticeBanners extends StatelessWidget {
final bool isAutoProcessing;
final List<String> autoWarnings;
final String? paichanSwitchNotice;
final bool isDuplicateBoxNo;
final bool quantityTooHigh;
final BoxingPhase phase;
final String boxNoText;
final int remainingQuantity;
final String? zongpaiNo;
final int? completedJumpBoxNo;
final VoidCallback onJumpToCompletedBox;
const BoxingNoticeBanners({
super.key,
required this.isAutoProcessing,
required this.autoWarnings,
required this.paichanSwitchNotice,
required this.isDuplicateBoxNo,
required this.quantityTooHigh,
required this.phase,
required this.boxNoText,
required this.remainingQuantity,
required this.zongpaiNo,
required this.completedJumpBoxNo,
required this.onJumpToCompletedBox,
});
@override
Widget build(BuildContext context) {
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 == BoxingPhase.scanned) {
banners.add(
_NoticeBanner(
icon: Icon(
Icons.warning_amber,
size: 14,
color: Colors.amber.shade900,
),
text: '箱号 $boxNoText 已存在,请重新输入',
background: Colors.amber.shade100,
foreground: Colors.amber.shade900,
border: Colors.amber.shade200,
),
);
}
if (quantityTooHigh && phase == BoxingPhase.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 == BoxingPhase.scanned &&
zongpaiNo != null &&
remainingQuantity <= 0) {
final jumpBoxNo = completedJumpBoxNo;
if (jumpBoxNo != null) {
banners.add(
GestureDetector(
onTap: onJumpToCompletedBox,
behavior: HitTestBehavior.opaque,
child: _NoticeBanner(
icon: Icon(Icons.warning, size: 14, color: Colors.amber.shade800),
text: '$zongpaiNo 已完成装箱。(P4跳转所在箱号)',
background: Colors.amber.shade50,
foreground: Colors.amber.shade800,
border: Colors.amber.shade200,
),
),
);
} else {
banners.add(
_NoticeBanner(
icon: Icon(Icons.warning, size: 14, color: Colors.red.shade800),
text: '$zongpaiNo 已全部装箱完毕,无需操作',
background: Colors.red.shade50,
foreground: Colors.red.shade800,
border: Colors.red.shade100,
),
);
}
}
return Column(mainAxisSize: MainAxisSize.min, children: banners);
}
}
class _NoticeBanner extends StatelessWidget {
final Widget icon;
final String text;
final Color background;
final Color foreground;
final Color border;
const _NoticeBanner({
required this.icon,
required this.text,
required this.background,
required this.foreground,
required this.border,
});
@override
Widget build(BuildContext context) {
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,
),
),
],
),
);
}
}

View File

@@ -1,161 +0,0 @@
import 'package:flutter/material.dart';
const boxingHeaderStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);
class BoxingDetailButton extends StatelessWidget {
final bool enabled;
final ColorScheme colorScheme;
final VoidCallback onPressed;
const BoxingDetailButton({
super.key,
required this.enabled,
required this.colorScheme,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
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 ? onPressed : 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)),
),
),
);
}
}
class BoxingModePill extends StatelessWidget {
final bool isSingle;
final String label;
final bool enabled;
final VoidCallback onTap;
const BoxingModePill({
super.key,
required this.isSingle,
required this.label,
required this.enabled,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final bgColor = isSingle ? Colors.blue.shade700 : Colors.orange.shade700;
return Material(
borderRadius: BorderRadius.circular(8),
color: bgColor,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: enabled ? onTap : null,
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(
label,
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,
),
),
],
),
),
),
);
}
}
class CompactIconButton extends StatelessWidget {
final IconData icon;
final Color? color;
final VoidCallback? onTap;
const CompactIconButton({
super.key,
required this.icon,
this.color,
this.onTap,
});
@override
Widget build(BuildContext context) {
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),
),
);
}
}
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,
);
}

View File

@@ -1,606 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
import 'package:pad_scanner/pages/boxing/widgets/multi_packed_row.dart';
import 'package:pad_scanner/services/api_service.dart';
class MultiCodeBody extends StatelessWidget {
final bool hasScan;
final String? zongpaiNo;
final String? paichanNo;
final String? workOrderNo;
final int? erpQuantity;
final int maxBoxNo;
final List<BoxDetailData> existingBoxes;
final List<ManyToOnePackedItem> visibleItems;
final TextEditingController boxNoController;
final TextEditingController quantityController;
final FocusNode boxNoFocusNode;
final FocusNode quantityFocusNode;
final bool isSubmitting;
final bool quantityTooHigh;
final bool canSubmit;
final int? editingPackedItemId;
final String editingPackedQuantity;
final int? deletingPackedItemId;
final VoidCallback onOpenDetail;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onSubmit;
final VoidCallback onQuantityChanged;
final VoidCallback onFinishBox;
final ValueChanged<String> onEditingPackedQuantityChanged;
final ValueChanged<ManyToOnePackedItem> onSavePackedItem;
final VoidCallback onCancelEditPackedItem;
final ValueChanged<ManyToOnePackedItem> onStartEditPackedItem;
final ValueChanged<ManyToOnePackedItem> onStartDeletePackedItem;
final ValueChanged<ManyToOnePackedItem> onDeletePackedItem;
final VoidCallback onCancelDeletePackedItem;
const MultiCodeBody({
super.key,
required this.hasScan,
required this.zongpaiNo,
required this.paichanNo,
required this.workOrderNo,
required this.erpQuantity,
required this.maxBoxNo,
required this.existingBoxes,
required this.visibleItems,
required this.boxNoController,
required this.quantityController,
required this.boxNoFocusNode,
required this.quantityFocusNode,
required this.isSubmitting,
required this.quantityTooHigh,
required this.canSubmit,
required this.editingPackedItemId,
required this.editingPackedQuantity,
required this.deletingPackedItemId,
required this.onOpenDetail,
required this.onSubmitFromKeyboard,
required this.onSubmit,
required this.onQuantityChanged,
required this.onFinishBox,
required this.onEditingPackedQuantityChanged,
required this.onSavePackedItem,
required this.onCancelEditPackedItem,
required this.onStartEditPackedItem,
required this.onStartDeletePackedItem,
required this.onDeletePackedItem,
required this.onCancelDeletePackedItem,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
_MultiHeaderBar(
paichanNo: paichanNo,
maxBoxNo: maxBoxNo,
existingBoxes: existingBoxes,
boxNoController: boxNoController,
boxNoFocusNode: boxNoFocusNode,
isSubmitting: isSubmitting,
onSubmitFromKeyboard: onSubmitFromKeyboard,
onOpenDetail: onOpenDetail,
),
const _MultiListHeader(),
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) {
final item = visibleItems[index];
return MultiPackedRow(
item: item,
editing: editingPackedItemId == item.boxItemId,
deleting: deletingPackedItemId == item.boxItemId,
isSubmitting: isSubmitting,
editingQuantity: editingPackedQuantity,
onEditingQuantityChanged: onEditingPackedQuantityChanged,
onSave: () => onSavePackedItem(item),
onCancelEdit: onCancelEditPackedItem,
onStartEdit: () => onStartEditPackedItem(item),
onStartDelete: () => onStartDeletePackedItem(item),
onDelete: () => onDeletePackedItem(item),
onCancelDelete: onCancelDeletePackedItem,
);
},
),
),
_MultiBottomArea(
hasScan: hasScan,
hasItems: visibleItems.isNotEmpty,
zongpaiNo: zongpaiNo,
workOrderNo: workOrderNo,
erpQuantity: erpQuantity,
quantityController: quantityController,
quantityFocusNode: quantityFocusNode,
isSubmitting: isSubmitting,
quantityTooHigh: quantityTooHigh,
canSubmit: canSubmit,
onSubmitFromKeyboard: onSubmitFromKeyboard,
onSubmit: onSubmit,
onQuantityChanged: onQuantityChanged,
onFinishBox: onFinishBox,
),
],
);
}
}
class _MultiHeaderBar extends StatelessWidget {
final String? paichanNo;
final int maxBoxNo;
final List<BoxDetailData> existingBoxes;
final TextEditingController boxNoController;
final FocusNode boxNoFocusNode;
final bool isSubmitting;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onOpenDetail;
const _MultiHeaderBar({
required this.paichanNo,
required this.maxBoxNo,
required this.existingBoxes,
required this.boxNoController,
required this.boxNoFocusNode,
required this.isSubmitting,
required this.onSubmitFromKeyboard,
required this.onOpenDetail,
});
@override
Widget build(BuildContext context) {
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: [
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: (_) => onSubmitFromKeyboard(),
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),
),
),
Container(
width: 1,
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: Colors.grey.shade300,
),
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),
BoxingDetailButton(
enabled: hasDetail,
colorScheme: Theme.of(context).colorScheme,
onPressed: onOpenDetail,
),
],
),
);
}
}
class _MultiListHeader extends StatelessWidget {
const _MultiListHeader();
@override
Widget build(BuildContext context) {
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: const Row(
children: [
Expanded(
flex: 26,
child: Text(
'总排号',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
Expanded(
flex: 30,
child: Text(
'工令号',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
Expanded(
flex: 23,
child: Text(
'数量',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
SizedBox(
width: 54,
child: Text(
'操作',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
],
),
);
}
}
class _MultiBottomArea extends StatelessWidget {
final bool hasScan;
final bool hasItems;
final String? zongpaiNo;
final String? workOrderNo;
final int? erpQuantity;
final TextEditingController quantityController;
final FocusNode quantityFocusNode;
final bool isSubmitting;
final bool quantityTooHigh;
final bool canSubmit;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onSubmit;
final VoidCallback onQuantityChanged;
final VoidCallback onFinishBox;
const _MultiBottomArea({
required this.hasScan,
required this.hasItems,
required this.zongpaiNo,
required this.workOrderNo,
required this.erpQuantity,
required this.quantityController,
required this.quantityFocusNode,
required this.isSubmitting,
required this.quantityTooHigh,
required this.canSubmit,
required this.onSubmitFromKeyboard,
required this.onSubmit,
required this.onQuantityChanged,
required this.onFinishBox,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_MultiScanBar(
hasScan: hasScan,
zongpaiNo: zongpaiNo,
workOrderNo: workOrderNo,
erpQuantity: erpQuantity,
),
_MultiSubmitRow(
hasScan: hasScan,
hasItems: hasItems,
quantityController: quantityController,
quantityFocusNode: quantityFocusNode,
isSubmitting: isSubmitting,
quantityTooHigh: quantityTooHigh,
canSubmit: canSubmit,
onSubmitFromKeyboard: onSubmitFromKeyboard,
onSubmit: onSubmit,
onQuantityChanged: onQuantityChanged,
onFinishBox: onFinishBox,
),
],
),
);
}
}
class _MultiScanBar extends StatelessWidget {
final bool hasScan;
final String? zongpaiNo;
final String? workOrderNo;
final int? erpQuantity;
const _MultiScanBar({
required this.hasScan,
required this.zongpaiNo,
required this.workOrderNo,
required this.erpQuantity,
});
@override
Widget build(BuildContext context) {
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,
),
),
],
),
);
}
}
class _MultiSubmitRow extends StatelessWidget {
final bool hasScan;
final bool hasItems;
final TextEditingController quantityController;
final FocusNode quantityFocusNode;
final bool isSubmitting;
final bool quantityTooHigh;
final bool canSubmit;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onSubmit;
final VoidCallback onQuantityChanged;
final VoidCallback onFinishBox;
const _MultiSubmitRow({
required this.hasScan,
required this.hasItems,
required this.quantityController,
required this.quantityFocusNode,
required this.isSubmitting,
required this.quantityTooHigh,
required this.canSubmit,
required this.onSubmitFromKeyboard,
required this.onSubmit,
required this.onQuantityChanged,
required this.onFinishBox,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 7, 12, 8),
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),
SizedBox(
width: 52,
height: 34,
child: TextField(
controller: quantityController,
focusNode: quantityFocusNode,
enabled: hasScan && !isSubmitting,
keyboardType: TextInputType.none,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
onChanged: (_) => onQuantityChanged(),
onEditingComplete: () {},
onSubmitted: (_) => onSubmitFromKeyboard(),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
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 ? onSubmit : 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),
Expanded(
child: SizedBox(
height: 34,
child: OutlinedButton(
onPressed: hasItems ? onFinishBox : 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,
),
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -1,239 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
class MultiPackedRow extends StatelessWidget {
final ManyToOnePackedItem item;
final bool editing;
final bool deleting;
final bool isSubmitting;
final String editingQuantity;
final ValueChanged<String> onEditingQuantityChanged;
final VoidCallback onSave;
final VoidCallback onCancelEdit;
final VoidCallback onStartEdit;
final VoidCallback onStartDelete;
final VoidCallback onDelete;
final VoidCallback onCancelDelete;
const MultiPackedRow({
super.key,
required this.item,
required this.editing,
required this.deleting,
required this.isSubmitting,
required this.editingQuantity,
required this.onEditingQuantityChanged,
required this.onSave,
required this.onCancelEdit,
required this.onStartEdit,
required this.onStartDelete,
required this.onDelete,
required this.onCancelDelete,
});
@override
Widget build(BuildContext context) {
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: 15,
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: 26,
child: Text(
item.zongpaiNo,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle.copyWith(fontWeight: FontWeight.w700),
),
),
Expanded(
flex: 30,
child: Text(
item.workOrderNo ?? '--',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 23,
child: editing
? SizedBox(
width: 52,
height: 26,
child: TextField(
keyboardType: TextInputType.none,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
textAlign: TextAlign.center,
controller:
TextEditingController(text: editingQuantity)
..selection = TextSelection.collapsed(
offset: editingQuantity.length,
),
onChanged: onEditingQuantityChanged,
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: 15,
fontWeight: FontWeight.w600,
),
),
)
: Text(
'${item.quantity} / $totalText',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: deleting ? Colors.red : Colors.green.shade700,
),
),
),
SizedBox(
width: 54,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (editing) ...[
CompactIconButton(
icon: Icons.check,
color: Colors.green,
onTap: isSubmitting ? null : onSave,
),
CompactIconButton(
icon: Icons.close,
color: Colors.grey,
onTap: isSubmitting ? null : onCancelEdit,
),
] else ...[
CompactIconButton(
icon: Icons.edit,
color: Colors.grey.shade700,
onTap: isSubmitting ? null : onStartEdit,
),
CompactIconButton(
icon: Icons.delete_outline,
color: Colors.red,
onTap: isSubmitting ? null : onStartDelete,
),
],
],
),
),
],
),
),
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 : onDelete,
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 : onCancelDelete,
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,
),
),
),
],
),
),
],
);
}
}

View File

@@ -1,231 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
import 'package:pad_scanner/services/api_service.dart';
class SingleAssignedRow extends StatelessWidget {
final CurrentZongpaiBoxData item;
final String? workOrderNo;
final bool editing;
final bool deleting;
final bool isSubmitting;
final String editingQuantity;
final ValueChanged<String> onEditingQuantityChanged;
final VoidCallback onSave;
final VoidCallback onCancelEdit;
final VoidCallback onStartEdit;
final VoidCallback onStartDelete;
final VoidCallback onDelete;
final VoidCallback onCancelDelete;
const SingleAssignedRow({
super.key,
required this.item,
required this.workOrderNo,
required this.editing,
required this.deleting,
required this.isSubmitting,
required this.editingQuantity,
required this.onEditingQuantityChanged,
required this.onSave,
required this.onCancelEdit,
required this.onStartEdit,
required this.onStartDelete,
required this.onDelete,
required this.onCancelDelete,
});
@override
Widget build(BuildContext context) {
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: 15,
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} 号箱',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle.copyWith(fontWeight: FontWeight.w700),
),
),
Expanded(
flex: 30,
child: Text(
workOrderNo ?? '--',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 23,
child: editing
? SizedBox(
width: 52,
height: 28,
child: TextField(
keyboardType: TextInputType.none,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
textAlign: TextAlign.center,
controller:
TextEditingController(text: editingQuantity)
..selection = TextSelection.collapsed(
offset: editingQuantity.length,
),
onChanged: onEditingQuantityChanged,
onSubmitted: (_) => onSave(),
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: 15,
fontWeight: FontWeight.w700,
),
),
)
: Text(
'${item.quantity}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: deleting ? Colors.red : Colors.black54,
),
),
),
SizedBox(
width: 54,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (editing) ...[
CompactIconButton(
icon: Icons.check,
color: Colors.green,
onTap: isSubmitting ? null : onSave,
),
CompactIconButton(
icon: Icons.close,
color: Colors.grey,
onTap: isSubmitting ? null : onCancelEdit,
),
] else ...[
CompactIconButton(
icon: Icons.edit,
color: Colors.grey.shade700,
onTap: isSubmitting ? null : onStartEdit,
),
CompactIconButton(
icon: Icons.delete_outline,
color: Colors.red,
onTap: isSubmitting ? null : onStartDelete,
),
],
],
),
),
],
),
),
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 : onDelete,
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 : onCancelDelete,
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),
),
),
],
),
),
],
);
}
}

View File

@@ -1,578 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
import 'package:pad_scanner/pages/boxing/widgets/single_assigned_row.dart';
import 'package:pad_scanner/services/api_service.dart';
class SingleCodeBody extends StatelessWidget {
final bool isWaiting;
final bool isFinished;
final String? zongpaiNo;
final String? paichanNo;
final String? workOrderNo;
final int? erpQuantity;
final int packedQuantity;
final int maxBoxNo;
final List<CurrentZongpaiBoxData> assignedItems;
final List<BoxDetailData> existingBoxes;
final TextEditingController boxNoController;
final TextEditingController quantityController;
final FocusNode boxNoFocusNode;
final FocusNode quantityFocusNode;
final bool isSubmitting;
final bool isDuplicateBoxNo;
final bool quantityTooHigh;
final bool canSubmit;
final int? editingAssignedItemId;
final String editingAssignedQuantity;
final int? deletingAssignedItemId;
final VoidCallback onOpenDetail;
final VoidCallback onCheckDuplicateBoxNo;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onSubmit;
final VoidCallback onQuantityChanged;
final ValueChanged<String> onEditingAssignedQuantityChanged;
final ValueChanged<CurrentZongpaiBoxData> onSaveAssignedItem;
final VoidCallback onCancelEditAssigned;
final ValueChanged<CurrentZongpaiBoxData> onStartEditAssigned;
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
final VoidCallback onCancelDeleteAssigned;
const SingleCodeBody({
super.key,
required this.isWaiting,
required this.isFinished,
required this.zongpaiNo,
required this.paichanNo,
required this.workOrderNo,
required this.erpQuantity,
required this.packedQuantity,
required this.maxBoxNo,
required this.assignedItems,
required this.existingBoxes,
required this.boxNoController,
required this.quantityController,
required this.boxNoFocusNode,
required this.quantityFocusNode,
required this.isSubmitting,
required this.isDuplicateBoxNo,
required this.quantityTooHigh,
required this.canSubmit,
required this.editingAssignedItemId,
required this.editingAssignedQuantity,
required this.deletingAssignedItemId,
required this.onOpenDetail,
required this.onCheckDuplicateBoxNo,
required this.onSubmitFromKeyboard,
required this.onSubmit,
required this.onQuantityChanged,
required this.onEditingAssignedQuantityChanged,
required this.onSaveAssignedItem,
required this.onCancelEditAssigned,
required this.onStartEditAssigned,
required this.onStartDeleteAssigned,
required this.onDeleteAssignedItem,
required this.onCancelDeleteAssigned,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
_SingleInfoBar(
isWaiting: isWaiting,
paichanNo: paichanNo,
existingBoxes: existingBoxes,
maxBoxNo: maxBoxNo,
colorScheme: colorScheme,
onOpenDetail: onOpenDetail,
),
_SingleScanBar(
isWaiting: isWaiting,
zongpaiNo: zongpaiNo,
workOrderNo: workOrderNo,
erpQuantity: erpQuantity,
packedQuantity: packedQuantity,
),
_SingleAssignedList(
isWaiting: isWaiting,
isFinished: isFinished,
items: assignedItems,
workOrderNo: workOrderNo,
isSubmitting: isSubmitting,
editingAssignedItemId: editingAssignedItemId,
editingAssignedQuantity: editingAssignedQuantity,
deletingAssignedItemId: deletingAssignedItemId,
onEditingAssignedQuantityChanged: onEditingAssignedQuantityChanged,
onSaveAssignedItem: onSaveAssignedItem,
onCancelEditAssigned: onCancelEditAssigned,
onStartEditAssigned: onStartEditAssigned,
onStartDeleteAssigned: onStartDeleteAssigned,
onDeleteAssignedItem: onDeleteAssignedItem,
onCancelDeleteAssigned: onCancelDeleteAssigned,
),
_SingleBottomArea(
disabled: isWaiting || isFinished,
boxNoController: boxNoController,
quantityController: quantityController,
boxNoFocusNode: boxNoFocusNode,
quantityFocusNode: quantityFocusNode,
isSubmitting: isSubmitting,
isDuplicateBoxNo: isDuplicateBoxNo,
quantityTooHigh: quantityTooHigh,
canSubmit: canSubmit,
onCheckDuplicateBoxNo: onCheckDuplicateBoxNo,
onSubmitFromKeyboard: onSubmitFromKeyboard,
onSubmit: onSubmit,
onQuantityChanged: onQuantityChanged,
),
],
);
}
}
class _SingleScanBar extends StatelessWidget {
final bool isWaiting;
final String? zongpaiNo;
final String? workOrderNo;
final int? erpQuantity;
final int packedQuantity;
const _SingleScanBar({
required this.isWaiting,
required this.zongpaiNo,
required this.workOrderNo,
required this.erpQuantity,
required this.packedQuantity,
});
@override
Widget build(BuildContext context) {
if (isWaiting) {
return Container(
height: 35,
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: 35,
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: 15, 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,
),
),
],
),
);
}
}
class _SingleInfoBar extends StatelessWidget {
final bool isWaiting;
final String? paichanNo;
final List<BoxDetailData> existingBoxes;
final int maxBoxNo;
final ColorScheme colorScheme;
final VoidCallback onOpenDetail;
const _SingleInfoBar({
required this.isWaiting,
required this.paichanNo,
required this.existingBoxes,
required this.maxBoxNo,
required this.colorScheme,
required this.onOpenDetail,
});
@override
Widget build(BuildContext context) {
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: Row(
children: [
Text(
isWaiting ? '排产号 --' : (paichanNo ?? '--'),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: isWaiting
? Colors.grey.shade400
: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
isWaiting
? '已有 -- 箱'
: '已有 ${existingBoxes.length}',
style: TextStyle(
fontSize: 12,
color: isWaiting
? Colors.grey.shade400
: Colors.grey.shade600,
),
),
Text(
isWaiting
? '最大箱号 --'
: '最大箱号 $maxBoxNo',
style: TextStyle(
fontSize: 12,
color: isWaiting
? Colors.grey.shade400
: Colors.grey.shade600,
),
),
],
),
),
const SizedBox(width: 10),
BoxingDetailButton(
enabled: detailEnabled,
colorScheme: colorScheme,
onPressed: onOpenDetail,
),
],
),
);
}
}
class _SingleAssignedList extends StatelessWidget {
final bool isWaiting;
final bool isFinished;
final List<CurrentZongpaiBoxData> items;
final String? workOrderNo;
final bool isSubmitting;
final int? editingAssignedItemId;
final String editingAssignedQuantity;
final int? deletingAssignedItemId;
final ValueChanged<String> onEditingAssignedQuantityChanged;
final ValueChanged<CurrentZongpaiBoxData> onSaveAssignedItem;
final VoidCallback onCancelEditAssigned;
final ValueChanged<CurrentZongpaiBoxData> onStartEditAssigned;
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
final VoidCallback onCancelDeleteAssigned;
const _SingleAssignedList({
required this.isWaiting,
required this.isFinished,
required this.items,
required this.workOrderNo,
required this.isSubmitting,
required this.editingAssignedItemId,
required this.editingAssignedQuantity,
required this.deletingAssignedItemId,
required this.onEditingAssignedQuantityChanged,
required this.onSaveAssignedItem,
required this.onCancelEditAssigned,
required this.onStartEditAssigned,
required this.onStartDeleteAssigned,
required this.onDeleteAssignedItem,
required this.onCancelDeleteAssigned,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
children: [
const _SingleAssignedHeader(),
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) {
final item = items[index];
return SingleAssignedRow(
item: item,
workOrderNo: workOrderNo,
editing: editingAssignedItemId == item.boxItemId,
deleting: deletingAssignedItemId == item.boxItemId,
isSubmitting: isSubmitting,
editingQuantity: editingAssignedQuantity,
onEditingQuantityChanged:
onEditingAssignedQuantityChanged,
onSave: () => onSaveAssignedItem(item),
onCancelEdit: onCancelEditAssigned,
onStartEdit: () => onStartEditAssigned(item),
onStartDelete: () => onStartDeleteAssigned(item),
onDelete: () => onDeleteAssignedItem(item),
onCancelDelete: onCancelDeleteAssigned,
);
},
),
),
],
),
);
}
}
class _SingleAssignedHeader extends StatelessWidget {
const _SingleAssignedHeader();
@override
Widget build(BuildContext context) {
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: const Row(
children: [
Expanded(
flex: 22,
child: Text(
'箱号',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
Expanded(
flex: 30,
child: Text(
'工令号',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
Expanded(
flex: 23,
child: Text(
'数量',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
SizedBox(
width: 54,
child: Text(
'操作',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
],
),
);
}
}
class _SingleBottomArea extends StatelessWidget {
final bool disabled;
final TextEditingController boxNoController;
final TextEditingController quantityController;
final FocusNode boxNoFocusNode;
final FocusNode quantityFocusNode;
final bool isSubmitting;
final bool isDuplicateBoxNo;
final bool quantityTooHigh;
final bool canSubmit;
final VoidCallback onCheckDuplicateBoxNo;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onSubmit;
final VoidCallback onQuantityChanged;
const _SingleBottomArea({
required this.disabled,
required this.boxNoController,
required this.quantityController,
required this.boxNoFocusNode,
required this.quantityFocusNode,
required this.isSubmitting,
required this.isDuplicateBoxNo,
required this.quantityTooHigh,
required this.canSubmit,
required this.onCheckDuplicateBoxNo,
required this.onSubmitFromKeyboard,
required this.onSubmit,
required this.onQuantityChanged,
});
@override
Widget build(BuildContext context) {
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: (_) => onCheckDuplicateBoxNo(),
onSubmitted: (_) => onSubmitFromKeyboard(),
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: 52,
height: 34,
child: TextField(
controller: quantityController,
focusNode: quantityFocusNode,
enabled: !disabled && !isSubmitting,
keyboardType: TextInputType.none,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
onChanged: (_) => onQuantityChanged(),
onSubmitted: (_) => onSubmitFromKeyboard(),
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 ? onSubmit : 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,
),
),
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,357 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationDialogsPart on _RegistrationPageState {
void _showLocationSwitchDialog(
String newLocationCode,
CodeType newLocationType,
) {
final count = _zongpaiNos.length;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.orange.shade50,
title: Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange.shade800),
const SizedBox(width: 8),
Text('切换货架号', style: TextStyle(color: Colors.orange.shade900)),
],
),
content: Text(
'当前已有 $count 条待上架数据,切换货架号不会清除已扫描的总排号。',
style: const TextStyle(fontSize: 14),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(ctx);
},
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
_locationCode = newLocationCode;
_locationType = newLocationType;
_clearStatusOverride();
});
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
),
child: const Text('确认切换'),
),
],
),
);
}
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.red.shade50,
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('重复上架', style: TextStyle(color: Colors.red)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('总排号:$zongpaiNo'),
const SizedBox(height: 4),
Text('已登记货位:${info?["location_code"] ?? "未知"}'),
const SizedBox(height: 4),
Text('登记时间:${info?["registered_at"] ?? "未知"}'),
const SizedBox(height: 12),
const Text(
'请核查实物,确认是否操作错误。',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
actions: [
TextButton(
onPressed: () {
_feedbackService.stopAlert();
Navigator.pop(ctx);
},
child: const Text('关闭'),
),
],
),
);
}
void _showLocationConflictDialog({
required List<PaichaOverviewItem> onShelfItems,
required List<PaichaOverviewItem> transferredItems,
List<PaichaOverviewItem> tempStoredItems = const [],
}) {
final contentParts = <Widget>[];
if (onShelfItems.isNotEmpty) {
contentParts.add(
Text(
'重复上架',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red.shade700,
),
),
);
for (final item in onShelfItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('已登记货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
if (transferredItems.isNotEmpty) {
if (onShelfItems.isNotEmpty) {
contentParts.add(const Divider());
contentParts.add(const SizedBox(height: 4));
}
contentParts.add(
Text(
'货物已转运',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange.shade700,
),
),
);
for (final item in transferredItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('转运货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
if (tempStoredItems.isNotEmpty) {
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
contentParts.add(const Divider());
contentParts.add(const SizedBox(height: 4));
}
contentParts.add(
Text(
'暂存中',
style: TextStyle(
fontWeight: FontWeight.bold,
color: const Color(0xFF7B1FA2),
),
),
);
for (final item in tempStoredItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('暂存货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
contentParts.add(const SizedBox(height: 4));
contentParts.add(
const Text(
'请核查实物,确认是否操作错误。',
style: TextStyle(fontWeight: FontWeight.bold),
),
);
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.red.shade50,
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('操作冲突', style: TextStyle(color: Colors.red)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: contentParts,
),
actions: [
TextButton(
onPressed: () {
_feedbackService.stopAlert();
Navigator.pop(ctx);
},
child: const Text('关闭'),
),
],
),
);
}
Future<void> _showCrossPaichaDialog(String newZongpaiNo) async {
final currentPaicha = _overview?.paichaNo ?? '--';
final shouldSwitch = await _confirmCrossPaichaSwitch(
newZongpaiNo: newZongpaiNo,
currentPaicha: currentPaicha,
);
if (!mounted) return;
if (shouldSwitch) {
_switchToNewPaicha(newZongpaiNo);
return;
}
setState(() {
_crossPaichaPending = false;
});
}
Future<bool> _confirmCrossPaichaSwitch({
required String newZongpaiNo,
required String currentPaicha,
}) async {
final dialogFocus = FocusNode();
var dismissed = false;
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
void dismissAsNo() {
if (dismissed) return;
dismissed = true;
Navigator.of(context).pop(false);
}
void confirmSwitch() {
if (dismissed) return;
dismissed = true;
Navigator.of(context).pop(true);
}
final dialogFuture = showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) {
return KeyboardListener(
focusNode: dialogFocus,
onKeyEvent: (event) {
if (event is! KeyDownEvent) return;
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
setDialogState(() => selectedIndex = 0);
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
setDialogState(() => selectedIndex = 1);
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
selectedIndex == 0 ? dismissAsNo() : confirmSwitch();
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
dismissAsNo();
}
},
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) dismissAsNo();
},
child: AlertDialog(
backgroundColor: Colors.orange.shade50,
title: Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange.shade800),
const SizedBox(width: 8),
Text(
'跨排产号扫描',
style: TextStyle(color: Colors.orange.shade900),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'总排号 $newZongpaiNo 不属于当前排产号($currentPaicha'
'是否切换到新的排产号?\n\n'
'选择「是」将放弃当前已扫描的所有数据。',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
_dialogOptionBtn(
label: '',
selected: selectedIndex == 0,
onTap: dismissAsNo,
),
const SizedBox(height: 8),
_dialogOptionBtn(
label: '是,切换排产号',
selected: selectedIndex == 1,
onTap: confirmSwitch,
),
],
),
),
),
);
},
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
dialogFocus.requestFocus();
});
final shouldSwitch = await dialogFuture;
return shouldSwitch ?? false;
}
Widget _dialogOptionBtn({
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return SizedBox(
width: double.infinity,
height: 40,
child: ElevatedButton(
onPressed: onTap,
style: ElevatedButton.styleFrom(
backgroundColor: selected
? Colors.blue.shade700
: Colors.grey.shade200,
foregroundColor: selected ? Colors.white : Colors.black87,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Text(
label,
style: TextStyle(
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
),
),
),
);
}
void _switchToNewPaicha(String newZongpaiNo) {
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
setState(() {
_crossPaichaPending = false;
_zongpaiNos.clear();
_zongpaiNos.add(newZongpaiNo);
_overview = null;
_overviewNotFound = false;
_overviewError = null;
_clearStatusOverride();
});
_loadOverview(newZongpaiNo);
}
}

View File

@@ -1,21 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationModePart on _RegistrationPageState {
void _cycleMode() {
_feedbackService.trigger(FeedbackEvent.modeSwitch);
setState(() {
switch (_mode) {
case RegistrationMode.singleCode:
_mode = RegistrationMode.multiCode;
case RegistrationMode.multiCode:
_mode = RegistrationMode.singleCode;
// 切换到单码时,只保留最后一个总排号
if (_zongpaiNos.length > 1) {
_zongpaiNos.removeRange(0, _zongpaiNos.length - 1);
}
}
});
}
}

View File

@@ -1,88 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationOverviewPart on _RegistrationPageState {
/// Find all scanned items that are already on shelf.
List<PaichaOverviewItem> _findOnShelfItemsInScanned() {
return registration_calculations.findOnShelfItemsInScanned(
overview: _overview,
zongpaiNos: _zongpaiNos,
);
}
/// Find all scanned items that are already transferred.
List<PaichaOverviewItem> _findTransferredItemsInScanned() {
return registration_calculations.findTransferredItemsInScanned(
overview: _overview,
zongpaiNos: _zongpaiNos,
);
}
/// Find all scanned items that are in temp storage.
List<PaichaOverviewItem> _findTempStoredItemsInScanned() {
return registration_calculations.findTempStoredItemsInScanned(
overview: _overview,
zongpaiNos: _zongpaiNos,
);
}
Future<String?> _baseUrl() async {
final configService = AppConfigService();
final baseUrl = await configService.getString('api_url') ?? '';
if (baseUrl.isEmpty) {
return null;
}
return baseUrl;
}
Future<void> _loadOverview(String zongpaiNo, {bool force = false}) async {
if (!force && _overviewZongpaiNo == zongpaiNo && _overview != null) {
return;
}
final requestId = ++_overviewRequestId;
setState(() {
_overviewZongpaiNo = zongpaiNo;
_overviewLoading = true;
_overviewNotFound = false;
_overviewError = null;
});
final baseUrl = await _baseUrl();
if (!mounted || requestId != _overviewRequestId) return;
if (baseUrl == null) {
setState(() {
_overviewLoading = false;
_overviewError = '未配置 API 地址,请前往设置';
});
return;
}
final result = await _apiService.fetchPaichaOverview(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
);
if (!mounted || requestId != _overviewRequestId) return;
setState(() {
_overviewLoading = false;
if (result.success) {
_overview = result;
_overviewNotFound = false;
_overviewError = null;
} else if (result.notFound) {
_overview = null;
_overviewNotFound = true;
_overviewError = null;
} else {
_overviewError = result.errorMessage ?? '加载失败,点击重试';
}
});
}
Future<void> _refreshCurrentOverview() async {
final zongpaiNo = _overviewZongpaiNo;
if (zongpaiNo == null) return;
await _loadOverview(zongpaiNo, force: true);
}
}

View File

@@ -1,129 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationScanPart on _RegistrationPageState {
void _startScanListening() {
_scanSubscription ??= _scannerService.scanResults.listen(_onScan);
}
Future<void> _stopScanListening() async {
final subscription = _scanSubscription;
_scanSubscription = null;
await subscription?.cancel();
}
void _restartScanListening() {
_scanSubscription?.cancel();
_scanSubscription = null;
_startScanListening();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_focusNode.requestFocus();
}
});
}
void _onKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return;
if (_isLockToggleKey(event)) {
_cycleMode();
return;
}
if (event.logicalKey == LogicalKeyboardKey.enter) {
_submit();
}
}
bool _isLockToggleKey(KeyEvent event) {
final key = event.logicalKey;
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.gameButtonRight1;
}
void _onScan(ScanResult result) {
final parsed = CodeParser.parse(result.barcode);
switch (parsed.type) {
case CodeType.zongpaiNo:
_handleZongpaiScan(parsed.value);
case CodeType.locationNormal:
case CodeType.locationTransit:
case CodeType.locationTempStorage:
_handleLocationScan(parsed.value, parsed.type);
case CodeType.invalid:
_feedbackService.trigger(FeedbackEvent.scanInvalid);
_showStatusOverride(
'无效码:${result.barcode}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
void _handleLocationScan(String locationCode, CodeType locationType) {
// In singleCode mode or no existing data or no previous shelf, switch directly
if (_mode == RegistrationMode.singleCode ||
_zongpaiNos.isEmpty ||
_locationCode == null) {
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
_locationCode = locationCode;
_locationType = locationType;
_clearStatusOverride();
});
return;
}
// In multiCode mode with existing data, confirm before switching
_showLocationSwitchDialog(locationCode, locationType);
}
void _handleZongpaiScan(String zongpaiNo) {
// If cross-paicha dialog is showing, just vibrate and discard
if (_crossPaichaPending) {
_triggerDoubleVibration();
return;
}
// In multiCode mode with existing data, check for cross-paicha scan
if (_mode == RegistrationMode.multiCode &&
_zongpaiNos.isNotEmpty &&
_overview?.success == true &&
!_overview!.items.any((item) => item.zongpaiNo == zongpaiNo)) {
_crossPaichaPending = true;
_triggerDoubleVibration();
_showCrossPaichaDialog(zongpaiNo);
return;
}
final shouldRefresh = _overviewZongpaiNo != zongpaiNo;
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
if (_mode == RegistrationMode.multiCode) {
if (!_zongpaiNos.contains(zongpaiNo)) {
_zongpaiNos.add(zongpaiNo);
}
} else {
if (_zongpaiNos.isEmpty) {
_zongpaiNos.add(zongpaiNo);
} else {
_zongpaiNos[0] = zongpaiNo;
}
}
_clearStatusOverride();
});
if (shouldRefresh) {
_loadOverview(zongpaiNo);
}
}
void _triggerDoubleVibration() {
Vibration.vibrate(pattern: [0, 200, 100, 200]);
}
void _removeZongpai(String zongpaiNo) {
setState(() {
_zongpaiNos.remove(zongpaiNo);
});
}
}

View File

@@ -1,58 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationStatusPart on _RegistrationPageState {
void _clearStatusOverride() {
_statusOverrideText = null;
_statusOverrideDot = null;
}
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
setState(() {
_statusOverrideText = text;
_statusOverrideDot = dot;
});
Future.delayed(duration, () {
if (mounted) setState(() => _clearStatusOverride());
});
}
void _updateBaseStatus() {
if (_isSubmitting) {
_statusDot = StatusDotColor.orange;
_statusText = '正在提交…';
return;
}
if (_mode == RegistrationMode.multiCode &&
_locationCode != null &&
_zongpaiNos.isEmpty) {
_statusDot = StatusDotColor.blue;
_statusText = '多码上架模式,请扫描下一张执行卡';
return;
}
final hasZongpai = _zongpaiNos.isNotEmpty;
final hasLocation = _locationCode != null;
if (hasZongpai && hasLocation) {
_statusDot = StatusDotColor.blue;
_statusText = '请确认信息并提交';
} else if (hasZongpai) {
_statusDot = StatusDotColor.blue;
_statusText = '请扫描目标货位号';
} else if (hasLocation) {
_statusDot = StatusDotColor.blue;
_statusText = '请扫描执行卡';
} else {
_statusDot = StatusDotColor.blue;
_statusText = '等待扫描总排号或货位号…';
}
}
bool get _canSubmit =>
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
bool get _isTempStorageTarget =>
isTempStorageTarget(_locationType, _locationCode);
}

View File

@@ -1,336 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationSubmitPart on _RegistrationPageState {
Future<void> _submit() async {
if (!_canSubmit) return;
final baseUrl = await _baseUrl();
if (baseUrl == null) {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'未配置 API 地址,请前往设置',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
setState(() => _isSubmitting = true);
// For transit target: block if any scanned item is already transferred
// (on_shelf items are allowed — transit registration acts as off-shelf)
if (_isTransitTarget) {
final transferredItems = _findTransferredItemsInScanned();
if (transferredItems.isNotEmpty) {
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showLocationConflictDialog(
onShelfItems: [],
transferredItems: transferredItems,
);
return;
}
}
// For temp storage target: block if any scanned item is transferred or temp_stored
// (on_shelf items are allowed — temp storage acts as shelf change)
if (_isTempStorageTarget) {
final transferredItems = _findTransferredItemsInScanned();
final tempStoredItems = _findTempStoredItemsInScanned();
if (transferredItems.isNotEmpty || tempStoredItems.isNotEmpty) {
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showLocationConflictDialog(
onShelfItems: [],
transferredItems: transferredItems,
tempStoredItems: tempStoredItems,
);
return;
}
}
if (_mode == RegistrationMode.multiCode && _zongpaiNos.length > 1) {
if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) {
if (!mounted) return;
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
batchPaichaMismatchMessage,
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
// Pre-check: block entire batch if any item already has a location binding
if (!_isTransitTarget && !_isTempStorageTarget) {
final onShelfItems = _findOnShelfItemsInScanned();
final transferredItems = _findTransferredItemsInScanned();
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showLocationConflictDialog(
onShelfItems: onShelfItems,
transferredItems: transferredItems,
);
return;
}
}
await _submitBatch(baseUrl);
} else {
await _submitOne(baseUrl, _zongpaiNos.first);
}
}
Future<bool> _ensureBatchSamePaicha(String baseUrl) async {
final paichaByCode = <String, String>{};
final cachedOverview = _overview;
if (cachedOverview?.success == true && cachedOverview!.paichaNo != null) {
for (final item in cachedOverview.items) {
paichaByCode[item.zongpaiNo] = cachedOverview.paichaNo!;
}
}
for (final zongpaiNo in _zongpaiNos) {
if (paichaByCode.containsKey(zongpaiNo)) continue;
final result = await _apiService.fetchPaichaOverview(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
);
if (!result.success || result.paichaNo == null) {
return false;
}
for (final item in result.items) {
paichaByCode[item.zongpaiNo] = result.paichaNo!;
}
}
return allSamePaicha(_zongpaiNos.map((code) => paichaByCode[code]));
}
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
final result = await _apiService.registerLocation(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
locationCode: _locationCode!,
);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (result.success) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
if (_isTransitTarget) {
setState(() => _isSubmitting = false);
await _navigateToBoxing(
mode: BoxingMode.singleCode,
codes: [zongpaiNo],
codesToClear: [zongpaiNo],
clearLocation: _mode == RegistrationMode.singleCode,
);
return;
}
setState(() {
_zongpaiNos.remove(zongpaiNo);
if (_mode == RegistrationMode.singleCode && !_isTempStorageTarget) {
_locationCode = null;
_locationType = null;
}
});
final msg = result.isOffShelfSuccess
? '下架成功'
: _isTempStorageTarget
? '变更货架成功'
: (_mode == RegistrationMode.multiCode ? '多码上架模式,请扫描下一张执行卡' : '上架成功');
_showStatusOverride(
msg,
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
await _refreshCurrentOverview();
} else if (result.isDuplicate) {
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
} else if (result.isAlreadyOffShelf) {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
StatusDotColor.red,
const Duration(seconds: 2),
);
} else {
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
_feedbackService.trigger(
isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure,
);
_showStatusOverride(
result.errorMessage ?? '提交失败',
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
Future<void> _submitBatch(String baseUrl) async {
int successCount = 0;
int offShelfCount = 0;
final failed = <String>[];
final toRemove = <String>[];
final transitCodes = <String>[];
for (final zongpaiNo in List.of(_zongpaiNos)) {
final result = await _apiService.registerLocation(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
locationCode: _locationCode!,
);
if (result.success) {
successCount++;
if (result.isOffShelfSuccess) {
offShelfCount++;
}
toRemove.add(zongpaiNo);
if (_isTransitTarget) {
transitCodes.add(zongpaiNo);
}
} else {
failed.add(zongpaiNo);
if (result.isDuplicate) {
if (_isTransitTarget && transitCodes.isNotEmpty) {
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
await _navigateToBoxing(
mode: BoxingMode.multiCode,
codes: transitCodes,
codesToClear: toRemove,
clearLocation: false,
returnMessage: '成功 $successCount 条,失败 ${failed.length}',
);
return;
}
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
return;
}
if (result.isAlreadyOffShelf) {
if (_isTransitTarget && transitCodes.isNotEmpty) {
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
await _navigateToBoxing(
mode: BoxingMode.multiCode,
codes: transitCodes,
codesToClear: toRemove,
clearLocation: false,
returnMessage: '成功 $successCount 条,失败 ${failed.length}',
);
return;
}
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
}
}
if (!mounted) return;
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
if (_isTransitTarget && transitCodes.isNotEmpty) {
await _navigateToBoxing(
mode: BoxingMode.multiCode,
codes: transitCodes,
codesToClear: toRemove,
clearLocation: failed.isEmpty,
returnMessage: failed.isEmpty
? null
: '成功 $successCount 条,失败 ${failed.length}',
);
return;
}
if (failed.isEmpty) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
final msg = offShelfCount == successCount
? '批量下架成功($successCount 条)'
: '批量上架成功($successCount 条)';
_showStatusOverride(
msg,
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
await _refreshCurrentOverview();
} else {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'成功 $successCount 条,失败 ${failed.length}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
Future<void> _navigateToBoxing({
required BoxingMode mode,
required List<String> codes,
required List<String> codesToClear,
required bool clearLocation,
String? returnMessage,
}) async {
await _refreshCurrentOverview();
if (!mounted) return;
final navigator = Navigator.of(context);
await _stopScanListening();
await navigator.push(
MaterialPageRoute(
builder: (_) => BoxingPage(
arguments: BoxingPageArguments(
initialMode: mode,
autoScanCodes: codes,
),
),
),
);
if (!mounted) return;
await Future<void>.delayed(const Duration(milliseconds: 120));
if (!mounted) return;
_restartScanListening();
setState(() {
_zongpaiNos.removeWhere((item) => codesToClear.contains(item));
if (clearLocation) {
_locationCode = null;
_locationType = null;
}
});
await _refreshCurrentOverview();
if (!mounted) return;
if (returnMessage != null) {
_showStatusOverride(
returnMessage,
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
}

View File

@@ -1,193 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/services/api_service.dart';
class RegistrationOverviewStats {
final int totalCount;
final int shelvedCount;
final int transferredCount;
final int tempStoredCount;
const RegistrationOverviewStats({
required this.totalCount,
required this.shelvedCount,
required this.transferredCount,
required this.tempStoredCount,
});
}
class RegistrationOverviewSections {
final List<PaichaOverviewItem> scanned;
final List<PaichaOverviewItem> unscanned;
const RegistrationOverviewSections({
required this.scanned,
required this.unscanned,
});
bool get hasDivider => scanned.isNotEmpty && unscanned.isNotEmpty;
}
RegistrationOverviewStats overviewStats(PaichaOverviewResult? overview) {
if (overview?.success != true) {
return const RegistrationOverviewStats(
totalCount: 0,
shelvedCount: 0,
transferredCount: 0,
tempStoredCount: 0,
);
}
var shelvedCount = 0;
var transferredCount = 0;
var tempStoredCount = 0;
for (final item in overview!.items) {
switch (item.status) {
case 'on_shelf':
shelvedCount++;
case 'transferred':
transferredCount++;
case 'temp_stored':
tempStoredCount++;
}
}
return RegistrationOverviewStats(
totalCount: overview.totalCount,
shelvedCount: shelvedCount,
transferredCount: transferredCount,
tempStoredCount: tempStoredCount,
);
}
RegistrationOverviewSections splitOverviewItems({
required PaichaOverviewResult overview,
required Iterable<String> zongpaiNos,
}) {
final scannedSet = zongpaiNos.toSet();
final scanned = <PaichaOverviewItem>[];
final unscanned = <PaichaOverviewItem>[];
for (final item in overview.items) {
if (scannedSet.contains(item.zongpaiNo)) {
scanned.add(item);
} else {
unscanned.add(item);
}
}
return RegistrationOverviewSections(scanned: scanned, unscanned: unscanned);
}
List<PaichaOverviewItem> findOnShelfItemsInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
return _findScannedItemsByStatus(
overview: overview,
zongpaiNos: zongpaiNos,
status: 'on_shelf',
);
}
List<PaichaOverviewItem> findTransferredItemsInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
return _findScannedItemsByStatus(
overview: overview,
zongpaiNos: zongpaiNos,
status: 'transferred',
);
}
List<PaichaOverviewItem> findTempStoredItemsInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
return _findScannedItemsByStatus(
overview: overview,
zongpaiNos: zongpaiNos,
status: 'temp_stored',
);
}
bool hasAnyLocatedInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
if (overview?.success != true) return false;
final scannedSet = zongpaiNos.toSet();
return overview!.items.any(
(item) =>
scannedSet.contains(item.zongpaiNo) &&
(item.status == 'on_shelf' ||
item.status == 'transferred' ||
item.status == 'temp_stored'),
);
}
Color registrationBarColor({
required String status,
required bool isScanned,
required bool isTransitTarget,
bool isTempStorageTarget = false,
}) {
if (isScanned) {
final isConflict = isTransitTarget
? status == 'transferred'
: isTempStorageTarget
? (status == 'transferred' || status == 'temp_stored')
: (status == 'on_shelf' || status == 'transferred');
return isConflict ? Colors.red : const Color(0xFF43A047);
}
switch (status) {
case 'on_shelf':
return const Color(0xFF2196F3);
case 'temp_stored':
return const Color(0xFF7B1FA2);
case 'transferred':
return const Color(0xFFFF9800);
default:
return Colors.grey.shade400;
}
}
Color registrationRowBgColor({
required String status,
required bool isScanned,
required bool isTransitTarget,
bool isTempStorageTarget = false,
}) {
if (isScanned) {
final isConflict = isTransitTarget
? status == 'transferred'
: isTempStorageTarget
? (status == 'transferred' || status == 'temp_stored')
: (status == 'on_shelf' || status == 'transferred');
return isConflict ? Colors.red.shade50 : const Color(0xFFF1F8E9);
}
switch (status) {
case 'on_shelf':
return const Color(0xFFE3F2FD);
case 'temp_stored':
return const Color(0xFFF3E5F5);
case 'transferred':
return const Color(0xFFFFF3E0);
default:
return const Color(0xFFF5F5F5);
}
}
List<PaichaOverviewItem> _findScannedItemsByStatus({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
required String status,
}) {
if (overview?.success != true) return [];
final scannedSet = zongpaiNos.toSet();
return overview!.items
.where(
(item) => scannedSet.contains(item.zongpaiNo) && item.status == status,
)
.toList();
}

View File

@@ -1,463 +0,0 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationWidgetsPart on _RegistrationPageState {
String _locationLabel(CodeType? type) {
if (type == CodeType.locationTransit) return '转运区域';
if (type == CodeType.locationTempStorage) return '暂存货架';
return '普通货架';
}
Color _locationLabelColor(CodeType? type) {
if (type == CodeType.locationTransit) return Colors.orange;
if (type == CodeType.locationTempStorage) return const Color(0xFF7B1FA2);
return Colors.blue;
}
Widget _buildModePill() {
return BoxingModePill(
isSingle: _mode == RegistrationMode.singleCode,
label: _mode == RegistrationMode.singleCode ? '单码上架' : '多码上架',
enabled: !_isSubmitting,
onTap: _cycleMode,
);
}
Widget _buildLocationChip() {
final color = _locationLabelColor(_locationType);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_locationLabel(_locationType),
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.bold,
),
),
);
}
// === Paicha Header ===
Widget _buildPaichaHeader() {
final overview = _overview;
final hasData = overview?.success == true;
final hasLoc = _locationCode != null;
final stats = registration_calculations.overviewStats(overview);
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
hasData ? (overview!.paichaNo ?? '--') : '--',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: hasData ? Colors.black87 : Colors.grey.shade400,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (hasLoc)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_locationCode!,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
const SizedBox(width: 5),
_buildLocationChip(),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatTag(
'${stats.totalCount}',
const Color(0xFFE3F2FD),
const Color(0xFF1565C0),
hasData,
),
_buildStatTag(
'上架 ${stats.shelvedCount}',
const Color(0xFFE8F5E9),
const Color(0xFF2E7D32),
hasData,
),
_buildStatTag(
'转运 ${stats.transferredCount}',
const Color(0xFFFFF3E0),
const Color(0xFFE65100),
hasData,
),
_buildStatTag(
'暂存 ${stats.tempStoredCount}',
const Color(0xFFF3E5F5),
const Color(0xFF7B1FA2),
hasData,
),
],
),
],
),
),
],
),
);
}
Widget _buildStatTag(String text, Color bg, Color fg, bool hasData) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
margin: const EdgeInsets.only(left: 5),
decoration: BoxDecoration(
color: hasData ? bg : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: hasData ? fg : Colors.grey.shade400,
),
),
);
}
// === Table Head ===
Widget _buildTableHead() {
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: const Row(
children: [
Expanded(
flex: 22,
child: Text(
'总排号',
textAlign: TextAlign.center,
style: _headerStyle,
),
),
Expanded(
flex: 18,
child: Text(
'工令号',
textAlign: TextAlign.center,
style: _headerStyle,
),
),
Expanded(
flex: 12,
child: Text('数量', textAlign: TextAlign.center, style: _headerStyle),
),
Expanded(
flex: 28,
child: Text(
'货位号',
textAlign: TextAlign.center,
style: _headerStyle,
),
),
],
),
);
}
// === List Body ===
Widget _buildListBody() {
if (_overviewZongpaiNo == null) {
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
}
if (_overviewLoading && _overview == null) {
return _buildListMessage('正在加载排产号上架情况…');
}
if (_overviewNotFound) {
return _buildListMessage('暂无排产信息');
}
if (_overviewError != null && _overview == null) {
return _buildListError(_overviewError!);
}
final overview = _overview;
if (overview == null || !overview.success) {
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
}
final sections = registration_calculations.splitOverviewItems(
overview: overview,
zongpaiNos: _zongpaiNos,
);
final scanned = sections.scanned;
final unscanned = sections.unscanned;
return Column(
children: [
if (_overviewError != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
color: Colors.red.shade50,
child: Row(
children: [
Expanded(
child: Text(
_overviewError!,
style: const TextStyle(fontSize: 12, color: Colors.red),
),
),
TextButton(
onPressed: _overviewZongpaiNo == null
? null
: () => _loadOverview(_overviewZongpaiNo!, force: true),
child: const Text('重试'),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount:
scanned.length +
(sections.hasDivider ? 1 : 0) +
unscanned.length,
itemBuilder: (context, index) {
if (index < scanned.length) {
return _buildListRow(scanned[index], isScanned: true);
}
if (index == scanned.length && sections.hasDivider) {
return _buildSectionDivider(unscanned.length);
}
final unscannedIdx =
index - scanned.length - (sections.hasDivider ? 1 : 0);
return _buildListRow(unscanned[unscannedIdx], isScanned: false);
},
),
),
],
);
}
Widget _buildSectionDivider(int count) {
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
const Expanded(child: Divider(height: 0)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'以下 $count 项未操作',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.grey.shade500,
),
),
),
const Expanded(child: Divider(height: 0)),
],
),
);
}
Widget _buildListRow(PaichaOverviewItem item, {required bool isScanned}) {
final rowStyle = TextStyle(
fontSize: 15,
fontWeight: isScanned ? FontWeight.w700 : FontWeight.w500,
color: Colors.black87,
);
final barColor = registration_calculations.registrationBarColor(
status: item.status,
isScanned: isScanned,
isTransitTarget: _isTransitTarget,
isTempStorageTarget: _isTempStorageTarget,
);
final bgColor = registration_calculations.registrationRowBgColor(
status: item.status,
isScanned: isScanned,
isTransitTarget: _isTransitTarget,
isTempStorageTarget: _isTempStorageTarget,
);
return 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,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 18,
child: Text(
item.workOrderNo ?? '--',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 12,
child: Text(
item.quantity.toString(),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 28,
child: Text(
item.locationCode ?? '\u2014',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
if (isScanned && _mode == RegistrationMode.multiCode) ...[
const SizedBox(width: 6),
SizedBox(
width: 28,
height: 28,
child: IconButton(
icon: Icon(Icons.delete_outline, size: 16),
color: Colors.red,
onPressed: () => _removeZongpai(item.zongpaiNo),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
),
),
],
],
),
);
}
Widget _buildListMessage(String text) {
return Center(
child: Text(
text,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
);
}
Widget _buildListError(String text) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: Colors.red),
),
const SizedBox(height: 6),
TextButton(
onPressed: _overviewZongpaiNo == null
? null
: () => _loadOverview(_overviewZongpaiNo!, force: true),
child: const Text('重试'),
),
],
),
);
}
// === Bottom Bar ===
Widget _buildBottomBar(ColorScheme colorScheme) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SizedBox(
width: double.infinity,
height: 34,
child: ElevatedButton(
onPressed: _canSubmit ? _submit : null,
style: ElevatedButton.styleFrom(
backgroundColor: registrationSubmitColor(
canSubmit: _canSubmit,
isTransitTarget: _isTransitTarget,
primaryColor: colorScheme.primary,
),
foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
registrationSubmitLabel(
isTransitTarget: _isTransitTarget,
isMultiCode: _mode == RegistrationMode.multiCode,
zongpaiCount: _zongpaiNos.length,
),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
}
const _headerStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);

File diff suppressed because it is too large Load Diff

View File

@@ -67,31 +67,16 @@ class _SettingsPageState extends State<SettingsPage> {
final configService = AppConfigService();
final config = await configService.loadConfig();
config['api_url'] = url;
if (_successPath != null) {
config['sound_success'] = _successPath;
} else {
config.remove('sound_success');
}
if (_failurePath != null) {
config['sound_failure'] = _failurePath;
} else {
config.remove('sound_failure');
}
if (_beepPath != null) {
config['sound_beep'] = _beepPath;
} else {
config.remove('sound_beep');
}
if (_errorPath != null) {
config['sound_error'] = _errorPath;
} else {
config.remove('sound_error');
}
if (_alertPath != null) {
config['sound_alert'] = _alertPath;
} else {
config.remove('sound_alert');
}
if (_successPath != null) config['sound_success'] = _successPath;
else config.remove('sound_success');
if (_failurePath != null) config['sound_failure'] = _failurePath;
else config.remove('sound_failure');
if (_beepPath != null) config['sound_beep'] = _beepPath;
else config.remove('sound_beep');
if (_errorPath != null) config['sound_error'] = _errorPath;
else config.remove('sound_error');
if (_alertPath != null) config['sound_alert'] = _alertPath;
else config.remove('sound_alert');
await configService.saveConfig(config);
setState(() => _saving = false);
if (mounted) {

View File

@@ -1,8 +1,14 @@
// lib/services/code_parser.dart
class CodeParser {
static final _zongpaiRegex = RegExp(r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$');
static final _locationRegex = RegExp(r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$');
static final _transitRegex = RegExp(r'^TRANS-');
static final _zongpaiRegex = RegExp(
r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$',
);
static final _locationRegex = RegExp(
r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$',
);
static final _transitRegex = RegExp(
r'^TRANS-',
);
static ParseResult parse(String code) {
final trimmed = code.trim();
@@ -17,30 +23,16 @@ class CodeParser {
return ParseResult(type: CodeType.zongpaiNo, value: normalized);
}
if (_locationRegex.hasMatch(normalized)) {
if (normalized.startsWith('B')) {
return ParseResult(
type: CodeType.locationTempStorage,
value: normalized,
);
}
return ParseResult(type: CodeType.locationNormal, value: normalized);
}
return ParseResult(type: CodeType.invalid, value: code);
}
static bool isLocation(CodeType type) =>
type == CodeType.locationNormal ||
type == CodeType.locationTransit ||
type == CodeType.locationTempStorage;
type == CodeType.locationNormal || type == CodeType.locationTransit;
}
enum CodeType {
zongpaiNo,
locationNormal,
locationTransit,
locationTempStorage,
invalid,
}
enum CodeType { zongpaiNo, locationNormal, locationTransit, invalid }
class ParseResult {
final CodeType type;

View File

@@ -8,22 +8,15 @@ bool isTransitTarget(CodeType? locationType, String? locationCode) {
(locationCode?.startsWith('TRANS-') ?? false);
}
bool isTempStorageTarget(CodeType? locationType, String? locationCode) {
return locationType == CodeType.locationTempStorage ||
(locationCode?.startsWith('B') ?? false);
}
String registrationSubmitLabel({
required bool isTransitTarget,
required bool isMultiCode,
required bool isLocked,
required int zongpaiCount,
}) {
if (!isTransitTarget) {
return isMultiCode && zongpaiCount > 1
? '批量上架($zongpaiCount 条)'
: '确 认 上 架';
return isLocked && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
}
return isMultiCode && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
return isLocked && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
}
Color registrationSubmitColor({

View File

@@ -1,159 +0,0 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:pad_scanner/pages/boxing/boxing_api_actions.dart';
import 'package:pad_scanner/services/api_service.dart';
void main() {
group('BoxingApiActions', () {
test('returns missingApiUrl when API URL is empty', () async {
final actions = BoxingApiActions(
apiService: ApiService(
client: _MockClient((_) async {
fail('ApiService should not be called without a base URL');
}),
),
loadApiUrl: () async => '',
);
final result = await actions.fetchBoxInfo('26B1');
expect(result.success, isFalse);
expect(result.errorKind, BoxingActionErrorKind.missingApiUrl);
expect(result.errorMessage, BoxingApiActions.missingApiUrlMessage);
});
test('fetchBoxInfo passes through successful data', () async {
final actions = BoxingApiActions(
apiService: ApiService(
client: _MockClient((request) async {
expect(request.url.path, '/CargoTrace/box/info');
expect(request.url.queryParameters['zongpai_no'], '26B1');
return http.Response(
jsonEncode({
'zongpai_no': '26B1',
'paichan_no': 'W00009',
'work_order_no': 'WO001',
'quantity': 8,
'current_zongpai_boxes': [
{'box_item_id': 1, 'box_no': 2, 'quantity': 3},
],
'existing_boxes': [],
'max_box_no': 2,
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
),
loadApiUrl: () async => 'http://localhost',
);
final result = await actions.fetchBoxInfo('26B1');
expect(result.success, isTrue);
expect(result.data?.paichanNo, 'W00009');
expect(result.data?.currentZongpaiBoxes.single.boxNo, 2);
});
test('saveBoxRecord maps duplicate and preserves box number', () async {
final actions = BoxingApiActions(
apiService: ApiService(
client: _MockClient((_) async {
return http.Response(
jsonEncode({
'error_code': 'DUPLICATE_BOX_ITEM',
'message': '重复装箱',
'box_no': 7,
}),
409,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
),
loadApiUrl: () async => 'http://localhost',
);
final result = await actions.saveBoxRecord(
zongpaiNo: '26B1',
boxNo: 7,
quantity: 2,
);
expect(result.success, isFalse);
expect(result.errorKind, BoxingActionErrorKind.duplicate);
expect(result.boxNo, 7);
});
test('updateBoxRecord maps network errors', () async {
final actions = BoxingApiActions(
apiService: ApiService(
client: _MockClient((_) async {
throw Exception('Connection refused');
}),
),
loadApiUrl: () async => 'http://localhost',
);
final result = await actions.updateBoxRecord(
boxItemId: 1,
boxNo: 2,
quantity: 3,
);
expect(result.success, isFalse);
expect(result.errorKind, BoxingActionErrorKind.network);
expect(result.errorMessage, BoxingApiActions.networkErrorMessage);
});
test('deleteBoxRecord passes through success and backend errors', () async {
var calls = 0;
final actions = BoxingApiActions(
apiService: ApiService(
client: _MockClient((_) async {
calls += 1;
if (calls == 1) {
return http.Response(
jsonEncode({'box_item_id': 1, 'deleted': true}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}
return http.Response(
jsonEncode({'message': '指定装箱明细不存在'}),
404,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
),
loadApiUrl: () async => 'http://localhost',
);
final ok = await actions.deleteBoxRecord(boxItemId: 1);
final missing = await actions.deleteBoxRecord(boxItemId: 999);
expect(ok.success, isTrue);
expect(missing.success, isFalse);
expect(missing.errorKind, BoxingActionErrorKind.backend);
expect(missing.errorMessage, '指定装箱明细不存在');
});
});
}
class _MockClient extends http.BaseClient {
final Future<http.Response> Function(http.BaseRequest) _handler;
_MockClient(this._handler);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final response = await _handler(request);
return http.StreamedResponse(
http.ByteStream.fromBytes(response.bodyBytes),
response.statusCode,
headers: response.headers,
reasonPhrase: response.reasonPhrase,
);
}
}

View File

@@ -1,88 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/pages/boxing/boxing_box_mutations.dart';
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/services/api_service.dart';
void main() {
group('boxing box mutations', () {
test('adds item to a new sorted box', () {
final result = addExistingBoxItem(
existingBoxes: [BoxDetailData(boxNo: 3, items: const [])],
boxNo: 2,
quantity: 5,
boxItemId: 22,
zongpaiNo: 'ZP022',
workOrderNo: 'WO022',
totalQuantity: 5,
);
expect(result.boxes.map((box) => box.boxNo), [2, 3]);
expect(result.maxBoxNo, 3);
expect(result.boxes.first.items.single.zongpaiNo, 'ZP022');
});
test('replaces single code item and removes empty source box', () {
final result = replaceExistingBoxItem(
existingBoxes: [
BoxDetailData(
boxNo: 1,
items: [BoxItemData(boxItemId: 1, zongpaiNo: 'ZP001', quantity: 2)],
),
],
editing: CurrentZongpaiBoxData(boxItemId: 1, boxNo: 1, quantity: 2),
boxNo: 4,
quantity: 3,
zongpaiNo: 'ZP001',
workOrderNo: 'WO001',
totalQuantity: 5,
);
expect(result.boxes.map((box) => box.boxNo), [4]);
expect(result.boxes.single.items.single.quantity, 3);
expect(result.maxBoxNo, 4);
});
test('moves packed item to a different box', () {
final result = replaceExistingPackedItem(
existingBoxes: [
BoxDetailData(
boxNo: 2,
items: [BoxItemData(boxItemId: 9, zongpaiNo: 'ZP009', quantity: 1)],
),
],
item: const ManyToOnePackedItem(
boxItemId: 9,
zongpaiNo: 'ZP009',
workOrderNo: 'WO009',
boxNo: 2,
quantity: 1,
totalQuantity: 3,
),
boxNo: 5,
quantity: 2,
);
expect(result.boxes.map((box) => box.boxNo), [5]);
expect(result.boxes.single.items.single.quantity, 2);
});
test('removes item and drops empty box', () {
final result = removeExistingBoxItem(
existingBoxes: [
BoxDetailData(
boxNo: 1,
items: [BoxItemData(boxItemId: 1, zongpaiNo: 'ZP001', quantity: 2)],
),
BoxDetailData(
boxNo: 3,
items: [BoxItemData(boxItemId: 2, zongpaiNo: 'ZP002', quantity: 1)],
),
],
boxItemId: 1,
);
expect(result.boxes.map((box) => box.boxNo), [3]);
expect(result.maxBoxNo, 3);
});
});
}

View File

@@ -1,123 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/pages/boxing/boxing_calculations.dart';
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/services/api_service.dart';
void main() {
group('boxing calculations', () {
final currentBoxes = [
CurrentZongpaiBoxData(boxItemId: 1, boxNo: 1, quantity: 4),
CurrentZongpaiBoxData(boxItemId: 2, boxNo: 2, quantity: 3),
];
test('calculates packed and remaining quantity', () {
expect(packedQuantity(currentBoxes), 7);
expect(
remainingQuantity(totalQuantity: 10, currentBoxes: currentBoxes),
3,
);
});
test('detects quantity too high only when remaining is positive', () {
expect(quantityTooHigh(remainingQuantity: 3, quantityText: '4'), isTrue);
expect(quantityTooHigh(remainingQuantity: 3, quantityText: '3'), isFalse);
expect(quantityTooHigh(remainingQuantity: 0, quantityText: '1'), isFalse);
});
test('validates submit conditions', () {
expect(
canSubmitBoxing(
isSubmitting: false,
phase: BoxingPhase.scanned,
zongpaiNo: 'ZP001',
boxNoText: '1',
quantityText: '2',
remainingQuantity: 3,
quantityTooHigh: false,
isDuplicateBoxNo: false,
),
isTrue,
);
expect(
canSubmitBoxing(
isSubmitting: true,
phase: BoxingPhase.scanned,
zongpaiNo: 'ZP001',
boxNoText: '1',
quantityText: '2',
remainingQuantity: 3,
quantityTooHigh: false,
isDuplicateBoxNo: false,
),
isFalse,
);
});
test('detects duplicate box number in single code mode', () {
expect(
singleCodeBoxNoIsDuplicate(
boxNoText: '1',
currentBoxes: currentBoxes,
editingBoxItemId: null,
),
isTrue,
);
expect(
singleCodeBoxNoIsDuplicate(
boxNoText: '1',
currentBoxes: currentBoxes,
editingBoxItemId: 1,
),
isFalse,
);
});
test('merges visible many-to-one items by box item id', () {
final existingBoxes = [
BoxDetailData(
boxNo: 8,
items: [
BoxItemData(
boxItemId: 11,
zongpaiNo: 'ZP011',
workOrderNo: 'WO011',
quantity: 2,
totalQuantity: 6,
),
],
),
];
final packedItems = [
const ManyToOnePackedItem(
boxItemId: 11,
zongpaiNo: 'ZP011',
paichanNo: 'PC001',
workOrderNo: 'WO011',
boxNo: 8,
quantity: 3,
totalQuantity: 6,
),
const ManyToOnePackedItem(
boxItemId: 12,
zongpaiNo: 'ZP012',
paichanNo: 'PC001',
workOrderNo: 'WO012',
boxNo: 8,
quantity: 1,
totalQuantity: 4,
),
];
final visible = visibleManyToOnePackedItems(
boxNo: 8,
existingBoxes: existingBoxes,
packedItems: packedItems,
paichanNo: 'PC001',
);
expect(visible.map((item) => item.boxItemId), [11, 12]);
expect(visible.first.quantity, 3);
});
});
}

View File

@@ -1,81 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart';
import 'package:pad_scanner/widgets/status_bar.dart';
void main() {
BoxingStatusPresentation status({
bool isAutoProcessing = false,
bool isSubmitting = false,
bool isDuplicateBoxNo = false,
bool quantityTooHigh = false,
bool isEditingAssigned = false,
bool isDeletingAssigned = false,
bool hasPaichanSwitchNotice = false,
BoxingPhase phase = BoxingPhase.waiting,
bool isMultiCode = false,
bool hasVisibleManyToOneItems = false,
int remainingQuantity = 1,
String boxNoText = '1',
}) {
return boxingStatusFor(
isAutoProcessing: isAutoProcessing,
isSubmitting: isSubmitting,
isDuplicateBoxNo: isDuplicateBoxNo,
quantityTooHigh: quantityTooHigh,
isEditingAssigned: isEditingAssigned,
isDeletingAssigned: isDeletingAssigned,
hasPaichanSwitchNotice: hasPaichanSwitchNotice,
phase: phase,
isMultiCode: isMultiCode,
hasVisibleManyToOneItems: hasVisibleManyToOneItems,
remainingQuantity: remainingQuantity,
boxNoText: boxNoText,
);
}
group('boxingStatusFor', () {
test('reports waiting and multi-code continuation states', () {
expect(status().text, '等待扫码');
final multi = status(isMultiCode: true, hasVisibleManyToOneItems: true);
expect(multi.text, '请继续扫码或完成本箱');
});
test('prioritizes transient and validation states', () {
expect(status(isSubmitting: true).text, '正在提交…');
expect(
status(isDuplicateBoxNo: true, phase: BoxingPhase.scanned).dot,
StatusDotColor.amber,
);
expect(
status(quantityTooHigh: true, phase: BoxingPhase.scanned).text,
'超出可装数量上限',
);
});
test('reports edit delete and switched paichan states', () {
expect(status(isEditingAssigned: true).text, '正在编辑已分配记录');
expect(status(isDeletingAssigned: true).dot, StatusDotColor.red);
expect(status(hasPaichanSwitchNotice: true).text, '排产号已切换,箱号已重置');
});
test('reports scanned completed and submitted states', () {
expect(
status(phase: BoxingPhase.scanned, remainingQuantity: 0).text,
'该总排号已全部装箱完毕',
);
expect(
status(phase: BoxingPhase.scanned, remainingQuantity: 2).text,
'数量已填入,请确认或修改',
);
expect(
status(phase: BoxingPhase.submitted, isMultiCode: false).text,
'装箱成功,可继续扫码',
);
expect(
status(phase: BoxingPhase.submitted, isMultiCode: true).text,
'请扫描下一个总排号',
);
});
});
}

View File

@@ -1,95 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/pages/registration/registration_calculations.dart';
import 'package:pad_scanner/services/api_service.dart';
void main() {
group('registration calculations', () {
test('calculates overview stats by item status', () {
final stats = overviewStats(
_overview([
_item('R1', status: 'not_shelved'),
_item('R2', status: 'on_shelf'),
_item('R3', status: 'transferred'),
]),
);
expect(stats.totalCount, 3);
expect(stats.shelvedCount, 1);
expect(stats.transferredCount, 1);
});
test('splits overview items into scanned and unscanned groups', () {
final sections = splitOverviewItems(
overview: _overview([_item('R1'), _item('R2'), _item('R3')]),
zongpaiNos: ['R1', 'R3'],
);
expect(sections.scanned.map((item) => item.zongpaiNo), ['R1', 'R3']);
expect(sections.unscanned.map((item) => item.zongpaiNo), ['R2']);
expect(sections.hasDivider, isTrue);
});
test('finds located scanned items', () {
final overview = _overview([
_item('R1', status: 'on_shelf'),
_item('R2', status: 'transferred'),
_item('R3', status: 'on_shelf'),
]);
expect(
findOnShelfItemsInScanned(
overview: overview,
zongpaiNos: ['R1', 'R2'],
).map((item) => item.zongpaiNo),
['R1'],
);
expect(
findTransferredItemsInScanned(
overview: overview,
zongpaiNos: ['R1', 'R2'],
).map((item) => item.zongpaiNo),
['R2'],
);
expect(
hasAnyLocatedInScanned(overview: overview, zongpaiNos: ['R1']),
isTrue,
);
expect(
hasAnyLocatedInScanned(overview: overview, zongpaiNos: ['R4']),
isFalse,
);
});
test('maps row colors for scanned and unscanned states', () {
expect(
registrationBarColor(
status: 'on_shelf',
isScanned: true,
isTransitTarget: false,
),
Colors.red,
);
expect(
registrationRowBgColor(
status: 'transferred',
isScanned: false,
isTransitTarget: false,
),
const Color(0xFFFFF3E0),
);
});
});
}
PaichaOverviewResult _overview(List<PaichaOverviewItem> items) {
return PaichaOverviewResult(
success: true,
totalCount: items.length,
items: items,
);
}
PaichaOverviewItem _item(String zongpaiNo, {String status = 'not_shelved'}) {
return PaichaOverviewItem(zongpaiNo: zongpaiNo, quantity: 1, status: status);
}

View File

@@ -16,7 +16,7 @@ void main() {
expect(
registrationSubmitLabel(
isTransitTarget: false,
isMultiCode: false,
isLocked: false,
zongpaiCount: 1,
),
'确 认 上 架',
@@ -24,7 +24,7 @@ void main() {
expect(
registrationSubmitLabel(
isTransitTarget: false,
isMultiCode: true,
isLocked: true,
zongpaiCount: 3,
),
'批量上架3 条)',
@@ -32,7 +32,7 @@ void main() {
expect(
registrationSubmitLabel(
isTransitTarget: true,
isMultiCode: false,
isLocked: false,
zongpaiCount: 1,
),
'转运并装箱',
@@ -40,7 +40,7 @@ void main() {
expect(
registrationSubmitLabel(
isTransitTarget: true,
isMultiCode: true,
isLocked: true,
zongpaiCount: 3,
),
'批量转运并凑箱3 条)',