Compare commits

...

6 Commits

Author SHA1 Message Date
Misaka_Company
950effc164 refactor: redesign single-code boxing info bar layout
- Remove progress bar from paichanNo info section
- Increase paichanNo font size to 22, decrease zongpaiNo to 15
- Swap paichanNo and zongpaiNo row positions (paichanNo on top)
- Split boxing detail info into two lines (box count, max box number)
- Reduce zongpaiNo row height from 50 to 35

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 16:24:50 +08:00
Misaka_Company
e87d29e696 refactor: split boxing page into part files for scan, submit, ops and status
Extract large methods from _BoxingPageState into 6 part files:
- boxing_scan_part.dart: scan handling and auto-scan processing
- boxing_submit_part.dart: submit/update/delete API calls
- boxing_box_mutation_part.dart: box item mutations
- boxing_single_ops_part.dart: single-code boxing operations
- boxing_multi_ops_part.dart: multi-code boxing operations
- boxing_status_part.dart: status bar management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 10:44:22 +08:00
Misaka_Company
b9f17d5d49 refactor: extract API actions into BoxingApiActions with error handling
Centralize all API call logic (fetchBoxInfo, saveBoxRecord,
updateBoxRecord, deleteBoxRecord) into BoxingApiActions class with
unified error categorization (network, duplicate, missingApiUrl).
Add helper methods to reduce repetitive error handling in boxing_page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 09:54:16 +08:00
Misaka_Company
9eb1bde645 docs: add Flutter test proxy workaround to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 09:06:25 +08:00
Misaka_Company
c2d9f5fd56 refactor: extract boxing logic into dedicated modules with tests
- boxing_models.dart: add BoxingMode, BoxingPhase, BoxingPageArguments
- boxing_box_mutations.dart: box item add/replace/remove operations
- boxing_calculations.dart: pure calculation helpers (quantities, validation)
- boxing_status_presenter.dart: status bar text/dot determination
- dialogs/cross_paichan_dialog.dart: cross-paichan confirmation dialog
- widgets/boxing_notice_banners.dart: notice banner widgets
- Add unit tests for mutations, calculations, and status presenter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 09:02:56 +08:00
Misaka_Company
f2b2f41929 refactor: extract boxing page into modular components
Split the monolithic boxing_page.dart into separate files:
- boxing_models.dart: data models (_ManyToOnePackedItem, _Phase)
- widgets/boxing_shared_widgets.dart: shared UI components
- widgets/single_code_body.dart: single-code boxing UI
- widgets/multi_code_body.dart: multi-code boxing UI
- widgets/multi_packed_row.dart: multi-code packed row widget
- widgets/single_assigned_row.dart: single-code assigned row widget

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 08:35:45 +08:00
24 changed files with 4123 additions and 2608 deletions

View File

@@ -21,3 +21,16 @@ 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

@@ -0,0 +1,150 @@
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

@@ -0,0 +1,135 @@
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

@@ -0,0 +1,101 @@
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

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,97 @@
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

@@ -0,0 +1,126 @@
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

@@ -0,0 +1,59 @@
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

@@ -0,0 +1,112 @@
// 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

@@ -0,0 +1,271 @@
// 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

@@ -0,0 +1,133 @@
// 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

@@ -0,0 +1,86 @@
// 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

@@ -0,0 +1,161 @@
// 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

@@ -0,0 +1,178 @@
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

@@ -0,0 +1,161 @@
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

@@ -0,0 +1,606 @@
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

@@ -0,0 +1,239 @@
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

@@ -0,0 +1,231 @@
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

@@ -0,0 +1,578 @@
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

@@ -0,0 +1,159 @@
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

@@ -0,0 +1,88 @@
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

@@ -0,0 +1,123 @@
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

@@ -0,0 +1,81 @@
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,
'请扫描下一个总排号',
);
});
});
}