refactor: split registration page into focused parts

This commit is contained in:
Misaka
2026-05-21 20:30:01 +08:00
parent 305083bf3f
commit 4891fe4dfd
10 changed files with 1659 additions and 1379 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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