diff --git a/lib/pages/registration/dialogs/registration_dialogs_part.dart b/lib/pages/registration/dialogs/registration_dialogs_part.dart new file mode 100644 index 0000000..fc780e7 --- /dev/null +++ b/lib/pages/registration/dialogs/registration_dialogs_part.dart @@ -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? 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 onShelfItems, + required List transferredItems, + }) { + final contentParts = []; + + 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 _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 _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( + 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); + } +} diff --git a/lib/pages/registration/parts/registration_mode_part.dart b/lib/pages/registration/parts/registration_mode_part.dart new file mode 100644 index 0000000..60bdedb --- /dev/null +++ b/lib/pages/registration/parts/registration_mode_part.dart @@ -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); + } + } + }); + } +} diff --git a/lib/pages/registration/parts/registration_overview_part.dart b/lib/pages/registration/parts/registration_overview_part.dart new file mode 100644 index 0000000..648df01 --- /dev/null +++ b/lib/pages/registration/parts/registration_overview_part.dart @@ -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 _findOnShelfItemsInScanned() { + return registration_calculations.findOnShelfItemsInScanned( + overview: _overview, + zongpaiNos: _zongpaiNos, + ); + } + + /// Find all scanned items that are already transferred. + List _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 _baseUrl() async { + final configService = AppConfigService(); + final baseUrl = await configService.getString('api_url') ?? ''; + if (baseUrl.isEmpty) { + return null; + } + return baseUrl; + } + + Future _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 _refreshCurrentOverview() async { + final zongpaiNo = _overviewZongpaiNo; + if (zongpaiNo == null) return; + await _loadOverview(zongpaiNo, force: true); + } +} diff --git a/lib/pages/registration/parts/registration_scan_part.dart b/lib/pages/registration/parts/registration_scan_part.dart new file mode 100644 index 0000000..7b0293c --- /dev/null +++ b/lib/pages/registration/parts/registration_scan_part.dart @@ -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 _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); + }); + } +} diff --git a/lib/pages/registration/parts/registration_status_part.dart b/lib/pages/registration/parts/registration_status_part.dart new file mode 100644 index 0000000..a13cb5e --- /dev/null +++ b/lib/pages/registration/parts/registration_status_part.dart @@ -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); +} diff --git a/lib/pages/registration/parts/registration_submit_part.dart b/lib/pages/registration/parts/registration_submit_part.dart new file mode 100644 index 0000000..58c99ee --- /dev/null +++ b/lib/pages/registration/parts/registration_submit_part.dart @@ -0,0 +1,317 @@ +// ignore_for_file: invalid_use_of_protected_member + +part of '../../registration_page.dart'; + +extension _RegistrationSubmitPart on _RegistrationPageState { + Future _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 _ensureBatchSamePaicha(String baseUrl) async { + final paichaByCode = {}; + 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 _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 _submitBatch(String baseUrl) async { + int successCount = 0; + int offShelfCount = 0; + final failed = []; + final toRemove = []; + final transitCodes = []; + + 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 _navigateToBoxing({ + required BoxingMode mode, + required List codes, + required List 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.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), + ); + } + } +} diff --git a/lib/pages/registration/registration_calculations.dart b/lib/pages/registration/registration_calculations.dart new file mode 100644 index 0000000..fe2042c --- /dev/null +++ b/lib/pages/registration/registration_calculations.dart @@ -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 scanned; + final List 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 zongpaiNos, +}) { + final scannedSet = zongpaiNos.toSet(); + final scanned = []; + final unscanned = []; + 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 findOnShelfItemsInScanned({ + required PaichaOverviewResult? overview, + required Iterable zongpaiNos, +}) { + return _findScannedItemsByStatus( + overview: overview, + zongpaiNos: zongpaiNos, + status: 'on_shelf', + ); +} + +List findTransferredItemsInScanned({ + required PaichaOverviewResult? overview, + required Iterable zongpaiNos, +}) { + return _findScannedItemsByStatus( + overview: overview, + zongpaiNos: zongpaiNos, + status: 'transferred', + ); +} + +bool hasAnyLocatedInScanned({ + required PaichaOverviewResult? overview, + required Iterable 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 _findScannedItemsByStatus({ + required PaichaOverviewResult? overview, + required Iterable 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(); +} diff --git a/lib/pages/registration/widgets/registration_widgets_part.dart b/lib/pages/registration/widgets/registration_widgets_part.dart new file mode 100644 index 0000000..2e3411e --- /dev/null +++ b/lib/pages/registration/widgets/registration_widgets_part.dart @@ -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); diff --git a/lib/pages/registration_page.dart b/lib/pages/registration_page.dart index fd85c9c..05b97df 100644 --- a/lib/pages/registration_page.dart +++ b/lib/pages/registration_page.dart @@ -12,6 +12,16 @@ import 'package:vibration/vibration.dart'; import 'package:pad_scanner/pages/boxing_page.dart'; 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; + +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_mode_part.dart'; +part 'registration/dialogs/registration_dialogs_part.dart'; +part 'registration/widgets/registration_widgets_part.dart'; enum RegistrationMode { singleCode, // 单码上架 @@ -70,912 +80,6 @@ class _RegistrationPageState extends State { super.dispose(); } - void _startScanListening() { - _scanSubscription ??= _scannerService.scanResults.listen(_onScan); - } - - Future _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 _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 _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); - }); - } - - 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); - - /// Find all scanned items that are already on shelf. - List _findOnShelfItemsInScanned() { - if (_overview?.success != true) return []; - final scannedSet = _zongpaiNos.toSet(); - return _overview!.items - .where((item) => - scannedSet.contains(item.zongpaiNo) && item.status == 'on_shelf') - .toList(); - } - - /// Find all scanned items that are already transferred. - List _findTransferredItemsInScanned() { - if (_overview?.success != true) return []; - final scannedSet = _zongpaiNos.toSet(); - return _overview!.items - .where((item) => - scannedSet.contains(item.zongpaiNo) && item.status == 'transferred') - .toList(); - } - - /// Whether any scanned item already has a location binding (on_shelf or transferred). - bool _hasAnyLocatedInScanned() { - 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')); - } - - Future _baseUrl() async { - final configService = AppConfigService(); - final baseUrl = await configService.getString('api_url') ?? ''; - if (baseUrl.isEmpty) { - return null; - } - return baseUrl; - } - - Future _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 _refreshCurrentOverview() async { - final zongpaiNo = _overviewZongpaiNo; - if (zongpaiNo == null) return; - await _loadOverview(zongpaiNo, force: true); - } - - Future _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 _ensureBatchSamePaicha(String baseUrl) async { - final paichaByCode = {}; - 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 _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 _submitBatch(String baseUrl) async { - int successCount = 0; - int offShelfCount = 0; - final failed = []; - final toRemove = []; - final transitCodes = []; - - 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 _navigateToBoxing({ - required BoxingMode mode, - required List codes, - required List 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.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), - ); - } - } - - void _showDuplicateDialog(String zongpaiNo, Map? 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 onShelfItems, - required List transferredItems, - }) { - final contentParts = []; - - 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('关闭'), - ), - ], - ), - ); - } - - void _showCrossPaichaDialog(String newZongpaiNo) { - final currentPaicha = _overview?.paichaNo ?? '--'; - final dialogFocus = FocusNode(); - var dismissed = false; - var selectedIndex = 0; // 0 = "否" (default), 1 = "是" - - void dismissAsNo() { - if (dismissed) return; - dismissed = true; - Navigator.of(context).pop(); - setState(() { - _crossPaichaPending = false; - }); - } - - void confirmSwitch() { - if (dismissed) return; - dismissed = true; - Navigator.of(context).pop(); - _switchToNewPaicha(newZongpaiNo); - } - - showDialog( - 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(); - }); - } - - 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); - } - - 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); - } - } - }); - } - - String _locationLabel(CodeType? type) { - if (type == CodeType.locationTransit) return '转运区域'; - return '普通货架'; - } - - Color _locationLabelColor(CodeType? type) { - if (type == CodeType.locationTransit) return Colors.orange; - return Colors.blue; - } - @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -1009,477 +113,4 @@ class _RegistrationPageState extends State { ), ); } - - 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; - - int totalCount = 0, shelvedCount = 0, transferredCount = 0; - if (hasData && overview != null) { - totalCount = overview.totalCount; - for (final item in overview.items) { - switch (item.status) { - case 'on_shelf': - shelvedCount++; - case 'transferred': - transferredCount++; - } - } - } - - 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( - '共 $totalCount', - const Color(0xFFE3F2FD), - const Color(0xFF1565C0), - hasData, - ), - _buildStatTag( - '上架 $shelvedCount', - const Color(0xFFE8F5E9), - const Color(0xFF2E7D32), - hasData, - ), - _buildStatTag( - '转运 $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 scannedSet = _zongpaiNos.toSet(); - final scanned = []; - final unscanned = []; - for (final item in overview.items) { - if (scannedSet.contains(item.zongpaiNo)) { - scanned.add(item); - } else { - unscanned.add(item); - } - } - - final hasDivider = scanned.isNotEmpty && unscanned.isNotEmpty; - - 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 + - (hasDivider ? 1 : 0) + - unscanned.length, - itemBuilder: (context, index) { - if (index < scanned.length) { - return _buildListRow(scanned[index], isScanned: true); - } - if (index == scanned.length && hasDivider) { - return _buildSectionDivider(unscanned.length); - } - final unscannedIdx = - index - scanned.length - (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 Color barColor; - final Color bgColor; - if (isScanned) { - final alreadyOnShelf = item.status == 'on_shelf' && !_isTransitTarget; - if (alreadyOnShelf) { - barColor = Colors.red; - bgColor = Colors.red.shade50; - } else { - barColor = const Color(0xFF43A047); - bgColor = const Color(0xFFF1F8E9); - } - } else { - barColor = _barColor(item.status); - bgColor = _rowBgColor(item.status); - } - - 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), - ), - ), - ], - ], - ), - ); - } - - Color _barColor(String status) { - switch (status) { - case 'on_shelf': - return const Color(0xFF2196F3); - case 'transferred': - return const Color(0xFFFF9800); - default: - return Colors.grey.shade400; - } - } - - Color _rowBgColor(String status) { - switch (status) { - case 'on_shelf': - return const Color(0xFFE3F2FD); - case 'transferred': - return const Color(0xFFFFF3E0); - default: - return const Color(0xFFF5F5F5); - } - } - - 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); diff --git a/test/pages/registration/registration_calculations_test.dart b/test/pages/registration/registration_calculations_test.dart new file mode 100644 index 0000000..fdc40ba --- /dev/null +++ b/test/pages/registration/registration_calculations_test.dart @@ -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 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); +}