Files
pad_scanner/lib/pages/registration_page.dart
Misaka_Company 928e7296b1 feat: 多码凑箱已完成装箱时支持跳转箱号查看 & 统一按键识别
- 多码凑箱模式下,扫描已完成装箱的总排号时显示可点击警告横幅,
  点击或按P4键可跳转到该总排号所在箱号查看装箱信息
- 统一所有页面按键处理为 LogicalKeyboardKey 命名常量形式,
  移除 _androidKeyCodeFromLogicalKey 回退路径

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 14:21:38 +08:00

1344 lines
40 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/services/app_config_service.dart';
import 'package:pad_scanner/services/scanner_service.dart';
import 'package:pad_scanner/services/code_parser.dart';
import 'package:pad_scanner/services/api_service.dart';
import 'package:pad_scanner/services/feedback_service.dart';
import 'package:pad_scanner/services/registration_linking.dart';
import 'package:vibration/vibration.dart';
import 'package:pad_scanner/pages/boxing_page.dart';
import 'package:pad_scanner/widgets/status_bar.dart';
class RegistrationPage extends StatefulWidget {
const RegistrationPage({super.key});
@override
State<RegistrationPage> createState() => _RegistrationPageState();
}
class _RegistrationPageState extends State<RegistrationPage> {
final _scannerService = ScannerService();
final _apiService = ApiService();
final _feedbackService = FeedbackService();
final _focusNode = FocusNode();
StreamSubscription<ScanResult>? _scanSubscription;
final _zongpaiNos = <String>[];
String? _locationCode;
CodeType? _locationType;
bool _isLocked = false;
bool _isSubmitting = false;
StatusDotColor _statusDot = StatusDotColor.blue;
String _statusText = '等待扫描总排号或货位号…';
String? _statusOverrideText;
StatusDotColor? _statusOverrideDot;
bool _overviewLoading = false;
bool _overviewNotFound = false;
String? _overviewError;
String? _overviewZongpaiNo;
PaichaOverviewResult? _overview;
int _overviewRequestId = 0;
/// Whether a cross-paicha warning dialog is currently showing.
bool _crossPaichaPending = false;
@override
void initState() {
super.initState();
_startScanListening();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_scanSubscription?.cancel();
_focusNode.dispose();
_feedbackService.dispose();
super.dispose();
}
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)) {
_toggleLock(!_isLocked);
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:
if (!_isLocked) {
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
_locationCode = parsed.value;
_locationType = parsed.type;
_clearStatusOverride();
});
} else {}
case CodeType.invalid:
_feedbackService.trigger(FeedbackEvent.scanInvalid);
_showStatusOverride(
'无效码:${result.barcode}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
void _handleZongpaiScan(String zongpaiNo) {
// If cross-paicha dialog is showing, just vibrate and discard
if (_crossPaichaPending) {
_triggerDoubleVibration();
return;
}
// In locked mode with existing data, check for cross-paicha scan
if (_isLocked &&
_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 (_isLocked) {
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 (_isLocked && _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 the first scanned item that is already on shelf (for batch submit blocking).
PaichaOverviewItem? _findOnShelfInScanned() {
if (_overview?.success != true) return null;
final scannedSet = _zongpaiNos.toSet();
for (final item in _overview!.items) {
if (scannedSet.contains(item.zongpaiNo) && item.status == 'on_shelf') {
return item;
}
}
return null;
}
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);
}
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);
if (_isLocked && _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 is already on shelf (normal shelf)
if (!_isTransitTarget) {
final onShelfItem = _findOnShelfInScanned();
if (onShelfItem != null) {
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(
onShelfItem.zongpaiNo,
{'location_code': onShelfItem.locationCode ?? '未知'},
);
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: !_isLocked,
);
return;
}
setState(() {
_zongpaiNos.remove(zongpaiNo);
if (!_isLocked) {
_locationCode = null;
_locationType = null;
}
});
final msg = result.isOffShelfSuccess
? '下架成功'
: (_isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功');
_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),
);
}
}
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 _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 _toggleLock(bool value) {
if (value && _locationCode == null) {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'请先扫描货位号',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
setState(() {
_isLocked = value;
if (!value && _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;
_updateBaseStatus();
final effectiveDot = _statusOverrideDot ?? _statusDot;
final effectiveText = _statusOverrideText ?? _statusText;
return KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
child: Scaffold(
appBar: AppBar(
title: Row(
children: [
const Text('上架登记'),
const SizedBox(width: 12),
Expanded(child: _buildLockHeader()),
],
),
titleSpacing: 12,
),
body: Column(
children: [
_buildPaichaHeader(),
if (_overview?.success == true) _buildTableHead(),
Expanded(child: _buildListBody()),
_buildBottomBar(colorScheme),
StatusBar(dotColor: effectiveDot, text: effectiveText),
],
),
),
);
}
Widget _buildLockHeader() {
final backgroundColor =
_isLocked ? Colors.orange.shade700 : Colors.blue.shade700;
final label = _isLocked ? '货位锁定' : '未锁定';
return Material(
borderRadius: BorderRadius.circular(8),
color: backgroundColor,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _toggleLock(!_isLocked),
child: Container(
width: double.infinity,
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.swap_horiz, color: Colors.white, size: 18),
const SizedBox(width: 6),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
label,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
),
const SizedBox(width: 8),
Text(
'P2',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.88),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
);
}
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('总排号', style: _headerStyle)),
Expanded(flex: 18, child: Text('工令号', style: _headerStyle)),
Expanded(
flex: 12,
child: Text(
'数量',
textAlign: TextAlign.center,
style: _headerStyle,
),
),
Expanded(
flex: 28,
child: Text(
'货位号',
textAlign: TextAlign.right,
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 = <PaichaOverviewItem>[];
final unscanned = <PaichaOverviewItem>[];
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: 12,
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,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 18,
child: Text(
item.workOrderNo ?? '--',
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.right,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
if (isScanned && _isLocked) ...[
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,
isLocked: _isLocked,
zongpaiCount: _zongpaiNos.length,
),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
}
const _headerStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);