feat(android): add attachment config dialog and FJ- scan in registration
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
/// Show a single-category picker. Returns the selected category, or null.
|
||||
Future<AttachmentCategoryData?> showAttachmentCategoryPicker({
|
||||
required BuildContext context,
|
||||
required List<AttachmentCategoryData> categories,
|
||||
}) {
|
||||
return showDialog<AttachmentCategoryData>(
|
||||
context: context,
|
||||
builder: (ctx) => _CategoryPickerDialog(categories: categories),
|
||||
);
|
||||
}
|
||||
|
||||
class _CategoryPickerDialog extends StatefulWidget {
|
||||
final List<AttachmentCategoryData> categories;
|
||||
const _CategoryPickerDialog({required this.categories});
|
||||
|
||||
@override
|
||||
State<_CategoryPickerDialog> createState() => _CategoryPickerDialogState();
|
||||
}
|
||||
|
||||
class _CategoryPickerDialogState extends State<_CategoryPickerDialog> {
|
||||
int? _selectedId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('选择附件类型'),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: RadioGroup<int>(
|
||||
groupValue: _selectedId,
|
||||
onChanged: (v) => setState(() => _selectedId = v),
|
||||
child: widget.categories.length <= 6
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widget.categories
|
||||
.map(
|
||||
(cat) => RadioListTile<int>(
|
||||
dense: true,
|
||||
title: Text(cat.name),
|
||||
value: cat.id,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widget.categories
|
||||
.map(
|
||||
(cat) => RadioListTile<int>(
|
||||
dense: true,
|
||||
title: Text(cat.name),
|
||||
value: cat.id,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _selectedId != null
|
||||
? () {
|
||||
final cat = widget.categories.firstWhere(
|
||||
(c) => c.id == _selectedId,
|
||||
);
|
||||
Navigator.pop(context, cat);
|
||||
}
|
||||
: null,
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
191
lib/pages/registration/dialogs/attachment_config_dialog.dart
Normal file
191
lib/pages/registration/dialogs/attachment_config_dialog.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class AttachmentConfigDialogResult {
|
||||
final String determination; // 'has' or 'none'
|
||||
final List<AttachmentConfigItemData> items; // empty if 'none'
|
||||
|
||||
const AttachmentConfigDialogResult({
|
||||
required this.determination,
|
||||
required this.items,
|
||||
});
|
||||
}
|
||||
|
||||
/// Show the attachment configuration dialog for an order.
|
||||
///
|
||||
/// [categories] — the full active category list from GET /attachment/categories.
|
||||
/// [prefillItems] — existing attachment status items (for pre-fill when
|
||||
/// determination is already 'has' due to earlier attachment arrival).
|
||||
/// [erpQuantity] — default expected_qty per selected type.
|
||||
Future<AttachmentConfigDialogResult?> showAttachmentConfigDialog({
|
||||
required BuildContext context,
|
||||
required String zongpaiNo,
|
||||
required List<AttachmentCategoryData> categories,
|
||||
required List<AttachmentStatusItemData> prefillItems,
|
||||
required int erpQuantity,
|
||||
}) {
|
||||
return showDialog<AttachmentConfigDialogResult>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _AttachmentConfigDialog(
|
||||
zongpaiNo: zongpaiNo,
|
||||
categories: categories,
|
||||
prefillItems: prefillItems,
|
||||
erpQuantity: erpQuantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AttachmentConfigDialog extends StatefulWidget {
|
||||
final String zongpaiNo;
|
||||
final List<AttachmentCategoryData> categories;
|
||||
final List<AttachmentStatusItemData> prefillItems;
|
||||
final int erpQuantity;
|
||||
|
||||
const _AttachmentConfigDialog({
|
||||
required this.zongpaiNo,
|
||||
required this.categories,
|
||||
required this.prefillItems,
|
||||
required this.erpQuantity,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AttachmentConfigDialog> createState() =>
|
||||
_AttachmentConfigDialogState();
|
||||
}
|
||||
|
||||
class _AttachmentConfigDialogState extends State<_AttachmentConfigDialog> {
|
||||
late Map<int, bool> _selected; // category_id → checked
|
||||
late Map<int, TextEditingController> _controllers; // category_id → qty
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = {};
|
||||
_controllers = {};
|
||||
final prefillSet = <int>{};
|
||||
for (final item in widget.prefillItems) {
|
||||
_selected[item.categoryId] = true;
|
||||
prefillSet.add(item.categoryId);
|
||||
}
|
||||
for (final cat in widget.categories) {
|
||||
if (!_selected.containsKey(cat.id)) {
|
||||
_selected[cat.id] = prefillSet.contains(cat.id);
|
||||
}
|
||||
final ctrl = TextEditingController(
|
||||
text: prefillSet.contains(cat.id)
|
||||
? widget.prefillItems
|
||||
.firstWhere((i) => i.categoryId == cat.id)
|
||||
.expectedQty
|
||||
.toString()
|
||||
: widget.erpQuantity.toString(),
|
||||
);
|
||||
_controllers[cat.id] = ctrl;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final ctrl in _controllers.values) {
|
||||
ctrl.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasAnyChecked = _selected.values.any((v) => v);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('${widget.zongpaiNo} — 附件配置'),
|
||||
content: SizedBox(
|
||||
width: 500,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'请勾选本订单携带的附件类型,并确认每种数量:',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...widget.categories.map(
|
||||
(cat) => CheckboxListTile(
|
||||
dense: true,
|
||||
title: Text(cat.name, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
const Text('应到: ', style: TextStyle(fontSize: 12)),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _controllers[cat.id],
|
||||
keyboardType: TextInputType.number,
|
||||
enabled: _selected[cat.id] == true,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
value: _selected[cat.id] ?? false,
|
||||
onChanged: (v) =>
|
||||
setState(() => _selected[cat.id] = v ?? false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(
|
||||
context,
|
||||
AttachmentConfigDialogResult(determination: 'none', items: []),
|
||||
);
|
||||
},
|
||||
child: const Text('无附件'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: hasAnyChecked
|
||||
? () {
|
||||
final items = <AttachmentConfigItemData>[];
|
||||
for (final cat in widget.categories) {
|
||||
if (_selected[cat.id] == true) {
|
||||
final qty =
|
||||
int.tryParse(_controllers[cat.id]!.text.trim()) ??
|
||||
widget.erpQuantity;
|
||||
items.add(
|
||||
AttachmentConfigItemData(
|
||||
categoryId: cat.id,
|
||||
expectedQty: qty,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Navigator.pop(
|
||||
context,
|
||||
AttachmentConfigDialogResult(
|
||||
determination: 'has',
|
||||
items: items,
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
185
lib/pages/registration/parts/registration_attachment_part.dart
Normal file
185
lib/pages/registration/parts/registration_attachment_part.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationAttachmentPart on _RegistrationPageState {
|
||||
/// Handle FJ- attachment scan: pick category, then register location.
|
||||
Future<void> _handleAttachmentScan(String zongpaiNo) async {
|
||||
if (_crossPaichaPending) {
|
||||
_triggerDoubleVibration();
|
||||
return;
|
||||
}
|
||||
|
||||
final baseUrl = await _baseUrl();
|
||||
if (baseUrl == null || !mounted) return;
|
||||
|
||||
// 1. Fetch categories (cache in the page state)
|
||||
final catResult = await _apiService.fetchAttachmentCategories(
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
if (!catResult.success || !mounted) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
_showStatusOverride(
|
||||
catResult.errorMessage ?? '加载附件类型失败',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final categories = catResult.categories;
|
||||
|
||||
// 2. Pick category (single choice)
|
||||
final picked = await showAttachmentCategoryPicker(
|
||||
context: context,
|
||||
categories: categories,
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
// 3. Now treat this zongpai as "scanned" with the picked category
|
||||
// For the registration flow: append scanned zongpai (for location binding)
|
||||
// and store category context for the submit path
|
||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||
setState(() {
|
||||
if (_mode == RegistrationMode.multiCode) {
|
||||
if (!_zongpaiNos.contains(zongpaiNo)) {
|
||||
_zongpaiNos.add(zongpaiNo);
|
||||
}
|
||||
} else {
|
||||
if (_zongpaiNos.isEmpty) {
|
||||
_zongpaiNos.add(zongpaiNo);
|
||||
} else {
|
||||
_zongpaiNos[0] = zongpaiNo;
|
||||
}
|
||||
}
|
||||
// Store the last-scanned attachment category for submit
|
||||
_pendingAttachmentCategory = picked.id;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
|
||||
// 4. Refresh the overview (the zongpai is now a product scan for display,
|
||||
// attachment status will be shown via a separate banner/tag)
|
||||
final shouldRefresh = _overviewZongpaiNo != zongpaiNo;
|
||||
if (shouldRefresh) {
|
||||
_loadOverview(zongpaiNo);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a product scan with attachment-config integration.
|
||||
/// Called from the existing _handleZongpaiScan when a product code is scanned.
|
||||
Future<void> _checkAndConfigureAttachment(String zongpaiNo) async {
|
||||
final baseUrl = await _baseUrl();
|
||||
if (baseUrl == null || !mounted) return;
|
||||
|
||||
// Fetch attachment status for this zongpai
|
||||
final status = await _apiService.fetchAttachmentStatus(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (!status.success) {
|
||||
// Network error or other — defer to product scan flow without blocking
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.determination == 'none') {
|
||||
// Already determined none — nothing to configure
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.determination == 'undetermined' ||
|
||||
status.determination == 'has') {
|
||||
// Fetch categories for the dialog
|
||||
final catResult = await _apiService.fetchAttachmentCategories(
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
if (!catResult.success || !mounted) return;
|
||||
|
||||
// Fetch ERP quantity for default expected_qty
|
||||
final overview = _overview;
|
||||
final erpQty = overview?.success == true
|
||||
? overview!.items
|
||||
.where((i) => i.zongpaiNo == zongpaiNo)
|
||||
.fold<int>(0, (sum, i) => sum + i.quantity)
|
||||
: 0;
|
||||
|
||||
final result = await showAttachmentConfigDialog(
|
||||
context: context,
|
||||
zongpaiNo: zongpaiNo,
|
||||
categories: catResult.categories,
|
||||
prefillItems: status.items,
|
||||
erpQuantity: erpQty > 0 ? erpQty : 1,
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
// Submit config
|
||||
final configResult = await _apiService.putAttachmentConfig(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
determination: result.determination,
|
||||
items: result.items,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (!configResult.success) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
configResult.errorMessage ?? '配置失败',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit attachment location for the given zongpai.
|
||||
Future<bool> _submitAttachmentLocation(
|
||||
String baseUrl,
|
||||
String zongpaiNo,
|
||||
int categoryId,
|
||||
) async {
|
||||
final result = await _apiService.registerAttachmentLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
categoryId: categoryId,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
if (result.success) {
|
||||
return true;
|
||||
} else if (result.isDuplicate) {
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
if (result.conflictInfo != null) {
|
||||
final msg =
|
||||
'附件 $zongpaiNo 已上架至 ${result.conflictInfo!['location_code']}';
|
||||
_showStatusOverride(
|
||||
msg,
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} else if (result.isAlreadyOffShelf) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '该附件已下架至转运区域,不可重新上架',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return false;
|
||||
} else {
|
||||
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||
_feedbackService.trigger(
|
||||
isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure,
|
||||
);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '提交失败',
|
||||
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,9 @@ extension _RegistrationScanPart on _RegistrationPageState {
|
||||
final parsed = CodeParser.parse(result.barcode);
|
||||
switch (parsed.type) {
|
||||
case CodeType.zongpaiNo:
|
||||
case CodeType.attachmentCode:
|
||||
_handleZongpaiScan(parsed.value);
|
||||
case CodeType.attachmentCode:
|
||||
_handleAttachmentScan(parsed.value);
|
||||
case CodeType.locationNormal:
|
||||
case CodeType.locationTransit:
|
||||
case CodeType.locationTempStorage:
|
||||
@@ -116,6 +117,8 @@ extension _RegistrationScanPart on _RegistrationPageState {
|
||||
if (shouldRefresh) {
|
||||
_loadOverview(zongpaiNo);
|
||||
}
|
||||
// Trigger attachment config check in background (modal dialog if needed)
|
||||
unawaited(_checkAndConfigureAttachment(zongpaiNo));
|
||||
}
|
||||
|
||||
void _triggerDoubleVibration() {
|
||||
|
||||
@@ -110,6 +110,38 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
|
||||
}
|
||||
|
||||
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
||||
// Attachment scan: submit via POST /attachment/location instead
|
||||
final pendingCat = _pendingAttachmentCategory;
|
||||
if (pendingCat != null) {
|
||||
final ok = await _submitAttachmentLocation(
|
||||
baseUrl,
|
||||
zongpaiNo,
|
||||
pendingCat,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
_pendingAttachmentCategory = null;
|
||||
});
|
||||
if (ok) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
if (_mode == RegistrationMode.singleCode && !_isTempStorageTarget) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
}
|
||||
});
|
||||
_showStatusOverride(
|
||||
'附件上架成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
await _refreshCurrentOverview();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _apiService.registerLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
|
||||
@@ -14,11 +14,14 @@ import 'package:pad_scanner/widgets/status_bar.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/pages/registration/registration_calculations.dart'
|
||||
as registration_calculations;
|
||||
import 'package:pad_scanner/pages/registration/dialogs/attachment_config_dialog.dart';
|
||||
import 'package:pad_scanner/pages/registration/dialogs/attachment_category_picker.dart';
|
||||
|
||||
part 'registration/parts/registration_scan_part.dart';
|
||||
part 'registration/parts/registration_status_part.dart';
|
||||
part 'registration/parts/registration_overview_part.dart';
|
||||
part 'registration/parts/registration_submit_part.dart';
|
||||
part 'registration/parts/registration_attachment_part.dart';
|
||||
part 'registration/parts/registration_mode_part.dart';
|
||||
part 'registration/dialogs/registration_dialogs_part.dart';
|
||||
part 'registration/widgets/registration_widgets_part.dart';
|
||||
@@ -63,6 +66,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
/// Whether a cross-paicha warning dialog is currently showing.
|
||||
bool _crossPaichaPending = false;
|
||||
|
||||
/// Pending attachment category ID set by the FJ- scan branch.
|
||||
/// Consumed by the submit path to call POST /attachment/location
|
||||
/// instead of POST /location.
|
||||
int? _pendingAttachmentCategory;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
Reference in New Issue
Block a user