feat(android): add attachment config dialog and FJ- scan in registration

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-08-12 14:56:28 +08:00
parent b7484915c6
commit 6b05d6c0e3
6 changed files with 503 additions and 1 deletions

View File

@@ -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('确认'),
),
],
);
}
}

View 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('确认'),
),
],
);
}
}

View 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;
}
}
}

View File

@@ -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() {

View File

@@ -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,