Files
pad_scanner/lib/pages/registration_page.dart
Misaka_Company cdc64bf82f feat: replace SharedPreferences with file-based AppConfigService and add HomePage navigation
- Implement AppConfigService using path_provider to store settings in a local JSON file,\n   ensuring configuration survives app updates/reinstalls.\n - Replace shared_preferences with AppConfigService in HomePage, SettingsPage,\n   and RegistrationPage.\n - Replace shared_preferences with AppConfigService in HomePage, SettingsPage,\n   and RegistrationPage.\n - Add HomePage.dart as the main navigation hub with feature cards (Registration,\n   Boxing, Shelf Query) and dynamic connection status bar.\n - Remove Settings entry from RegistrationPage; Settings are only accessible from\n   the Home page. Use 'flutter analyze' to verify the code.
2026-05-11 16:24:16 +08:00

604 lines
21 KiB
Dart

import 'package:flutter/material.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';
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 _zongpaiNos = <String>[];
String? _locationCode;
CodeType? _locationType;
bool _isLocked = false;
bool _isSubmitting = false;
String? _successMessage;
String? _snackbarMessage;
Color? _snackbarColor;
@override
void initState() {
super.initState();
_scannerService.scanResults.listen(_onScan);
}
void _onScan(ScanResult result) {
final parsed = CodeParser.parse(result.barcode);
switch (parsed.type) {
case CodeType.zongpaiNo:
setState(() {
if (_isLocked) {
// 锁定模式:追加到列表(重复则覆盖)
final idx = _zongpaiNos.indexOf(parsed.value);
if (idx >= 0) {
_zongpaiNos[idx] = parsed.value;
} else {
_zongpaiNos.add(parsed.value);
}
} else {
// 单次模式:只有一条,覆盖
if (_zongpaiNos.isNotEmpty) {
_zongpaiNos[0] = parsed.value;
} else {
_zongpaiNos.add(parsed.value);
}
}
_snackbarMessage = null;
_successMessage = null;
});
case CodeType.locationNormal:
case CodeType.locationTransit:
if (!_isLocked) {
setState(() {
_locationCode = parsed.value;
_locationType = parsed.type;
_snackbarMessage = null;
_successMessage = null;
});
}
case CodeType.invalid:
_showFeedback('无效码:${result.barcode}', isError: true);
}
}
void _removeZongpai(int index) {
setState(() {
_zongpaiNos.removeAt(index);
});
}
void _showFeedback(String message, {bool isError = false}) {
setState(() {
_snackbarMessage = message;
_snackbarColor = isError ? Colors.red.shade700 : Colors.green.shade700;
});
Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _snackbarMessage = null);
});
}
String get _statusText {
if (_isSubmitting) return '正在提交…';
if (_successMessage != null) return _successMessage!;
if (_isLocked && _locationCode != null && _zongpaiNos.isEmpty) {
return '货位已锁定,请扫描下一张执行卡';
}
final hasZ = _zongpaiNos.isNotEmpty;
final hasL = _locationCode != null;
if (hasZ && hasL) return '请确认信息并提交';
if (hasZ && !hasL) return '请扫描目标货位号';
if (!hasZ && hasL) return '请扫描执行卡';
return '等待扫描总排号或货位号…';
}
bool get _canSubmit =>
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
Future<void> _submit() async {
if (!_canSubmit) return;
final configService = AppConfigService();
final baseUrl = await configService.getString('api_url') ?? '';
if (baseUrl.isEmpty) {
_showFeedback('未配置 API 地址,请前往设置', isError: true);
return;
}
setState(() => _isSubmitting = true);
if (_isLocked && _zongpaiNos.length > 1) {
// 批量提交:逐个提交列表中的总排号
await _submitBatch(baseUrl);
} else {
// 单条提交
await _submitOne(baseUrl, _zongpaiNos.first);
}
}
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) {
setState(() {
_zongpaiNos.remove(zongpaiNo);
if (!_isLocked) {
_locationCode = null;
_locationType = null;
}
_successMessage = _isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功';
});
_showFeedback('上架成功', isError: false);
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) setState(() => _successMessage = null);
});
} else if (result.isDuplicate) {
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
} else {
_showFeedback(result.errorMessage ?? '提交失败', isError: true);
}
}
Future<void> _submitBatch(String baseUrl) async {
int successCount = 0;
final failed = <String>[];
final toRemove = <String>[];
for (final zp in List.of(_zongpaiNos)) {
final result = await _apiService.registerLocation(
baseUrl: baseUrl,
zongpaiNo: zp,
locationCode: _locationCode!,
);
if (result.success) {
successCount++;
toRemove.add(zp);
} else {
failed.add(zp);
if (result.isDuplicate) {
// 重复上架弹窗中断批量流程
setState(() {
_zongpaiNos.removeWhere((e) => toRemove.contains(e));
_isSubmitting = false;
});
_showDuplicateDialog(zp, result.duplicateInfo);
return;
}
}
}
if (!mounted) return;
setState(() {
_zongpaiNos.removeWhere((e) => toRemove.contains(e));
_isSubmitting = false;
});
if (failed.isEmpty) {
_showFeedback('批量上架成功($successCount 条)', isError: false);
} else {
_showFeedback('成功 $successCount 条,失败 ${failed.length}', isError: true);
}
}
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: () => Navigator.pop(ctx),
child: const Text('关闭'),
),
],
),
);
}
void _toggleLock(bool value) {
if (value && _locationCode == null) {
_showFeedback('请先扫描货位号', isError: true);
return;
}
setState(() {
_isLocked = value;
if (!value) {
// 退出锁定模式,只保留最后一条总排号
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;
return Scaffold(
appBar: AppBar(
title: const Text('上架登记'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 4),
child: Row(
children: [
const Text('锁定货位', style: TextStyle(fontSize: 13)),
Switch(value: _isLocked, onChanged: _toggleLock),
],
),
),
],
),
body: Column(
children: [
// Feedback banner
if (_snackbarMessage != null)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
color: _snackbarColor,
child: Text(
_snackbarMessage!,
style: const TextStyle(color: Colors.white, fontSize: 14),
textAlign: TextAlign.center,
),
),
// Main form area
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// ---- 目标货位 (上方) ----
Row(
children: [
const Text(
'目标货位',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
if (_locationCode != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: _locationLabelColor(
_locationType,
).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_locationLabel(_locationType),
style: TextStyle(
fontSize: 11,
color: _locationLabelColor(_locationType),
fontWeight: FontWeight.bold,
),
),
),
],
],
),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
decoration: BoxDecoration(
border: Border.all(
color: _locationCode != null
? Colors.green
: Colors.grey.shade400,
width: _locationCode != null ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
_locationCode ?? '',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: _locationCode != null
? Colors.black87
: Colors.grey,
),
),
),
if (_isLocked)
const Icon(
Icons.lock,
color: Colors.orange,
size: 20,
),
],
),
),
const SizedBox(height: 16),
// ---- 总排号 (下方) ----
Row(
children: [
const Text(
'总排号',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
if (_isLocked && _zongpaiNos.isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${_zongpaiNos.length}',
style: const TextStyle(
fontSize: 11,
color: Colors.blue,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
const SizedBox(height: 6),
if (_isLocked) ...[
// 锁定模式:列表显示多条总排号
Expanded(
child: _zongpaiNos.isEmpty
? Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'',
style: TextStyle(
fontSize: 20,
color: Colors.grey,
),
),
)
: ListView.separated(
itemCount: _zongpaiNos.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 6),
itemBuilder: (context, index) {
return Dismissible(
key: ValueKey('${_zongpaiNos[index]}-$index'),
direction: DismissDirection.endToStart,
onDismissed: (_) => _removeZongpai(index),
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.delete,
color: Colors.red,
),
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
decoration: BoxDecoration(
border: Border.all(
color: Colors.green,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
_zongpaiNos[index],
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
IconButton(
icon: const Icon(
Icons.close,
size: 20,
),
onPressed: () =>
_removeZongpai(index),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
);
},
),
),
] else ...[
// 单次模式:单个显示
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
decoration: BoxDecoration(
border: Border.all(
color: _zongpaiNos.isNotEmpty
? Colors.green
: Colors.grey.shade400,
width: _zongpaiNos.isNotEmpty ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_zongpaiNos.isNotEmpty ? _zongpaiNos.first : '',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: _zongpaiNos.isNotEmpty
? Colors.black87
: Colors.grey,
),
),
),
],
const SizedBox(height: 16),
// Submit button
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _canSubmit ? _submit : null,
style: ElevatedButton.styleFrom(
backgroundColor: _canSubmit
? colorScheme.primary
: Colors.grey.shade300,
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(
_isLocked && _zongpaiNos.length > 1
? '批量上架(${_zongpaiNos.length} 条)'
: '确 认 上 架',
style: const TextStyle(fontSize: 18),
),
),
),
],
),
),
),
// Status bar at bottom
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
color: colorScheme.surfaceContainerHighest,
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _isSubmitting
? Colors.orange
: _successMessage != null
? Colors.green
: Colors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_statusText,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
);
}
}