- Home: add keyboard number shortcuts (1/2/3) for module navigation - Boxing: add P2 key mode switching with pill-style AppBar header - Registration: replace Switch with pill-style lock header matching boxing design - Add Android keyCode fallback for scanner device compatibility - Simplify home page cards to show only module name + shortcut hint - Add lock redesign HTML preview document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1131 lines
34 KiB
Dart
1131 lines
34 KiB
Dart
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: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> {
|
|
static const _androidKeyCodeButtonR1 = 103;
|
|
|
|
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;
|
|
|
|
@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 ||
|
|
_androidKeyCodeFromLogicalKey(key) == _androidKeyCodeButtonR1;
|
|
}
|
|
|
|
int? _androidKeyCodeFromLogicalKey(LogicalKeyboardKey key) {
|
|
final keyId = key.keyId;
|
|
const androidPlane = LogicalKeyboardKey.androidPlane;
|
|
if (keyId >= androidPlane && keyId < androidPlane + 0x100000000) {
|
|
return keyId - androidPlane;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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) {
|
|
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 _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);
|
|
|
|
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;
|
|
}
|
|
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 _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;
|
|
}
|
|
|
|
Color _overviewRowColor(String status) {
|
|
switch (status) {
|
|
case 'on_shelf':
|
|
return const Color(0xFFE3F2FD);
|
|
case 'transferred':
|
|
return const Color(0xFFFFF3E0);
|
|
default:
|
|
return const Color(0xFFF5F5F5);
|
|
}
|
|
}
|
|
|
|
@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: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'登记操作区',
|
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
|
|
),
|
|
const SizedBox(height: 6),
|
|
_isLocked
|
|
? _buildLockedInputPanel()
|
|
: _buildSingleInputPanel(),
|
|
const SizedBox(height: 10),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 46,
|
|
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(8),
|
|
),
|
|
),
|
|
child: _isSubmitting
|
|
? const SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: Text(
|
|
registrationSubmitLabel(
|
|
isTransitTarget: _isTransitTarget,
|
|
isLocked: _isLocked,
|
|
zongpaiCount: _zongpaiNos.length,
|
|
),
|
|
style: const TextStyle(fontSize: 17),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(height: 1, color: Colors.grey.shade300),
|
|
Expanded(child: _buildOverviewSection()),
|
|
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 _buildSingleInputPanel() {
|
|
final hasLocation = _locationCode != null;
|
|
final hasZongpai = _zongpaiNos.isNotEmpty;
|
|
return Container(
|
|
width: double.infinity,
|
|
constraints: const BoxConstraints(minHeight: 58),
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: hasLocation || hasZongpai
|
|
? Colors.green
|
|
: Colors.grey.shade400,
|
|
width: hasLocation || hasZongpai ? 2 : 1,
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Flexible(
|
|
flex: 5,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
_locationCode ?? '',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: hasLocation ? Colors.black87 : Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
if (hasLocation) ...[
|
|
const SizedBox(width: 6),
|
|
_buildLocationChip(),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Text(
|
|
'|',
|
|
style: TextStyle(fontSize: 20, color: Colors.grey.shade500),
|
|
),
|
|
),
|
|
Flexible(
|
|
flex: 3,
|
|
child: Text(
|
|
hasZongpai ? _zongpaiNos.first : '',
|
|
textAlign: TextAlign.right,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: hasZongpai ? Colors.black87 : Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLockedInputPanel() {
|
|
final hasLocation = _locationCode != null;
|
|
return Container(
|
|
width: double.infinity,
|
|
constraints: const BoxConstraints(minHeight: 82),
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: hasLocation || _zongpaiNos.isNotEmpty
|
|
? Colors.green
|
|
: Colors.grey.shade400,
|
|
width: hasLocation || _zongpaiNos.isNotEmpty ? 2 : 1,
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_locationCode ?? '',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: hasLocation ? Colors.black87 : Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
const Icon(Icons.lock, color: Colors.orange, size: 18),
|
|
const SizedBox(width: 6),
|
|
if (hasLocation) _buildLocationChip(),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (_zongpaiNos.isEmpty)
|
|
const SizedBox(height: 24)
|
|
else
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: _zongpaiNos
|
|
.map(
|
|
(zongpaiNo) => InputChip(
|
|
label: Text(zongpaiNo),
|
|
visualDensity: VisualDensity.compact,
|
|
onDeleted: () => _removeZongpai(zongpaiNo),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildOverviewSection() {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Expanded(
|
|
child: Text(
|
|
'排产号货架总览',
|
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
if (_overviewLoading)
|
|
const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Expanded(child: _buildOverviewBody()),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildOverviewBody() {
|
|
if (_overviewZongpaiNo == null) {
|
|
return _buildOverviewMessage('扫描总排号后自动显示排产号上架情况');
|
|
}
|
|
if (_overviewLoading && _overview == null) {
|
|
return _buildOverviewMessage('正在加载排产号上架情况…');
|
|
}
|
|
if (_overviewNotFound) {
|
|
return _buildOverviewMessage('暂无排产信息');
|
|
}
|
|
if (_overviewError != null && _overview == null) {
|
|
return _buildRetryMessage(_overviewError!);
|
|
}
|
|
final overview = _overview;
|
|
if (overview == null) {
|
|
return _buildOverviewMessage('扫描总排号后自动显示排产号上架情况');
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (_overviewError != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
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('重试'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
'${overview.paichaNo ?? "--"} 共 ${overview.totalCount} 个总排号',
|
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 6),
|
|
_buildOverviewHeader(),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: overview.items.length,
|
|
itemBuilder: (context, index) {
|
|
return _buildOverviewRow(overview.items[index]);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildOverviewHeader() {
|
|
return Container(
|
|
height: 32,
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade200,
|
|
border: Border.all(color: Colors.grey.shade400),
|
|
),
|
|
child: const Row(
|
|
children: [
|
|
Expanded(flex: 20, child: Text('总排号', style: _headerStyle)),
|
|
Expanded(flex: 20, child: Text('工令号', style: _headerStyle)),
|
|
Expanded(
|
|
flex: 14,
|
|
child: Text('数量', textAlign: TextAlign.center, style: _headerStyle),
|
|
),
|
|
Expanded(
|
|
flex: 26,
|
|
child: Text('货位号', textAlign: TextAlign.right, style: _headerStyle),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildOverviewRow(PaichaOverviewItem item) {
|
|
final current = item.zongpaiNo == _overviewZongpaiNo;
|
|
final rowStyle = TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: current ? FontWeight.w700 : FontWeight.w500,
|
|
color: Colors.black87,
|
|
);
|
|
return Container(
|
|
constraints: const BoxConstraints(minHeight: 34),
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: _overviewRowColor(item.status),
|
|
border: Border(
|
|
left: BorderSide(
|
|
color: current ? Colors.green : Colors.grey.shade300,
|
|
width: current ? 2 : 1,
|
|
),
|
|
right: BorderSide(
|
|
color: current ? Colors.green : Colors.grey.shade300,
|
|
width: current ? 2 : 1,
|
|
),
|
|
bottom: BorderSide(
|
|
color: current ? Colors.green : Colors.grey.shade300,
|
|
width: current ? 2 : 1,
|
|
),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 20,
|
|
child: Text(
|
|
item.zongpaiNo,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: rowStyle,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 20,
|
|
child: Text(
|
|
item.workOrderNo ?? '--',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: rowStyle,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 14,
|
|
child: Text(
|
|
item.quantity.toString(),
|
|
textAlign: TextAlign.center,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: rowStyle,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 26,
|
|
child: Text(
|
|
item.locationCode ?? '—',
|
|
textAlign: TextAlign.right,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: rowStyle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildOverviewMessage(String text) {
|
|
return Center(
|
|
child: Text(
|
|
text,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRetryMessage(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('重试'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
const _headerStyle = TextStyle(fontSize: 12, fontWeight: FontWeight.w700);
|