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

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

690 lines
22 KiB
Dart

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;
final List<PendingAccessory> pendingAccessories;
final ValueChanged<PendingAccessory> onSubmitAccessory;
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,
required this.pendingAccessories,
required this.onSubmitAccessory,
});
@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 && pendingAccessories.isEmpty
? Center(
child: Text(
'尚未装入任何物料',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade500,
fontStyle: FontStyle.italic,
),
),
)
: ListView.builder(
itemCount: visibleItems.length + pendingAccessories.length,
itemBuilder: (context, index) {
if (index < visibleItems.length) {
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,
);
}
final accIndex = index - visibleItems.length;
final acc = pendingAccessories[accIndex];
return _MultiAccessoryRow(
accessory: acc,
isSubmitting: isSubmitting,
onSubmit: () => onSubmitAccessory(acc),
);
},
),
),
_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,
),
),
),
],
),
),
),
),
],
),
);
}
}
class _MultiAccessoryRow extends StatelessWidget {
final PendingAccessory accessory;
final bool isSubmitting;
final VoidCallback onSubmit;
const _MultiAccessoryRow({
required this.accessory,
required this.isSubmitting,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
Icon(Icons.build, size: 14, color: Colors.orange.shade600),
const SizedBox(width: 6),
Expanded(
flex: 40,
child: Text(
accessory.accessoryType,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 20,
child: Text(
'\u00d7${accessory.quantity}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
),
SizedBox(
height: 28,
child: ElevatedButton(
onPressed: isSubmitting ? null : onSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: isSubmitting
? Colors.grey.shade300
: Colors.orange.shade600,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
child: const Text(
'加入本箱',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
],
),
);
}
}