refactor: unify error feedback system with FeedbackService and StatusBar widget

- Create FeedbackService as centralized sound/vibration dispatcher
- Create reusable StatusBar widget with StatusDotColor enum (blue/orange/green/red/yellow/amber)
- Extend SoundService with beep/error/alert sound types and loop playback
- Remove top feedback banners from both registration and boxing pages
- Route all feedback through bottom status bar per PRD requirements
- Add sound and vibration feedback to boxing module (was completely missing)
- Handle network errors with yellow status, general errors with red
- Enhance duplicate dialog with alert loop sound that stops on close
- Add beep/error/alert sound file selectors in settings page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-05-12 17:15:28 +08:00
parent ac77294ecb
commit 1417c8baf9
7 changed files with 1068 additions and 517 deletions

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:vibration/vibration.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/sound_service.dart';
import 'package:pad_scanner/services/feedback_service.dart';
import 'package:pad_scanner/widgets/status_bar.dart';
class RegistrationPage extends StatefulWidget {
const RegistrationPage({super.key});
@@ -17,7 +17,7 @@ class RegistrationPage extends StatefulWidget {
class _RegistrationPageState extends State<RegistrationPage> {
final _scannerService = ScannerService();
final _apiService = ApiService();
final _soundService = SoundService();
final _feedbackService = FeedbackService();
final _focusNode = FocusNode();
final _zongpaiNos = <String>[];
@@ -26,9 +26,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
bool _isLocked = false;
bool _isSubmitting = false;
String? _successMessage;
String? _snackbarMessage;
Color? _snackbarColor;
// Status bar state
StatusDotColor _statusDot = StatusDotColor.blue;
String _statusText = '等待扫描总排号或货位号…';
String? _statusOverrideText;
StatusDotColor? _statusOverrideDot;
@override
void initState() {
@@ -42,7 +44,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
@override
void dispose() {
_focusNode.dispose();
_soundService.dispose();
_feedbackService.dispose();
super.dispose();
}
@@ -57,9 +59,9 @@ class _RegistrationPageState extends State<RegistrationPage> {
final parsed = CodeParser.parse(result.barcode);
switch (parsed.type) {
case CodeType.zongpaiNo:
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
if (_isLocked) {
// 锁定模式:追加到列表(重复则覆盖)
final idx = _zongpaiNos.indexOf(parsed.value);
if (idx >= 0) {
_zongpaiNos[idx] = parsed.value;
@@ -67,28 +69,31 @@ class _RegistrationPageState extends State<RegistrationPage> {
_zongpaiNos.add(parsed.value);
}
} else {
// 单次模式:只有一条,覆盖
if (_zongpaiNos.isNotEmpty) {
_zongpaiNos[0] = parsed.value;
} else {
_zongpaiNos.add(parsed.value);
}
}
_snackbarMessage = null;
_successMessage = null;
_clearStatusOverride();
});
case CodeType.locationNormal:
case CodeType.locationTransit:
if (!_isLocked) {
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
_locationCode = parsed.value;
_locationType = parsed.type;
_snackbarMessage = null;
_successMessage = null;
_clearStatusOverride();
});
}
case CodeType.invalid:
_showFeedback('无效码:${result.barcode}', isError: true);
_feedbackService.trigger(FeedbackEvent.scanInvalid);
_showStatusOverride(
'无效码:${result.barcode}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
@@ -98,28 +103,49 @@ class _RegistrationPageState extends State<RegistrationPage> {
});
}
void _showFeedback(String message, {bool isError = false}) {
// --- Status bar management ---
void _clearStatusOverride() {
_statusOverrideText = null;
_statusOverrideDot = null;
}
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
setState(() {
_snackbarMessage = message;
_snackbarColor = isError ? Colors.red.shade700 : Colors.green.shade700;
_statusOverrideText = text;
_statusOverrideDot = dot;
});
Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _snackbarMessage = null);
Future.delayed(duration, () {
if (mounted) setState(() => _clearStatusOverride());
});
}
String get _statusText {
if (_isSubmitting) return '正在提交…';
if (_successMessage != null) return _successMessage!;
void _updateBaseStatus() {
if (_isSubmitting) {
_statusDot = StatusDotColor.orange;
_statusText = '正在提交…';
return;
}
if (_isLocked && _locationCode != null && _zongpaiNos.isEmpty) {
return '货位已锁定,请扫描下一张执行卡';
_statusDot = StatusDotColor.blue;
_statusText = '货位已锁定,请扫描下一张执行卡';
return;
}
final hasZ = _zongpaiNos.isNotEmpty;
final hasL = _locationCode != null;
if (hasZ && hasL) return '请确认信息并提交';
if (hasZ && !hasL) return '请扫描目标货位号';
if (!hasZ && hasL) return '请扫描执行卡';
return '等待扫描总排号或货位号…';
if (hasZ && hasL) {
_statusDot = StatusDotColor.blue;
_statusText = '请确认信息并提交';
} else if (hasZ && !hasL) {
_statusDot = StatusDotColor.blue;
_statusText = '请扫描目标货位号';
} else if (!hasZ && hasL) {
_statusDot = StatusDotColor.blue;
_statusText = '请扫描执行卡';
} else {
_statusDot = StatusDotColor.blue;
_statusText = '等待扫描总排号或货位号…';
}
}
bool get _canSubmit =>
@@ -131,17 +157,20 @@ class _RegistrationPageState extends State<RegistrationPage> {
final configService = AppConfigService();
final baseUrl = await configService.getString('api_url') ?? '';
if (baseUrl.isEmpty) {
_showFeedback('未配置 API 地址,请前往设置', isError: true);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'未配置 API 地址,请前往设置',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
setState(() => _isSubmitting = true);
if (_isLocked && _zongpaiNos.length > 1) {
// 批量提交:逐个提交列表中的总排号
await _submitBatch(baseUrl);
} else {
// 单条提交
await _submitOne(baseUrl, _zongpaiNos.first);
}
}
@@ -158,25 +187,29 @@ class _RegistrationPageState extends State<RegistrationPage> {
setState(() => _isSubmitting = false);
if (result.success) {
_vibrateSuccess();
_feedbackService.trigger(FeedbackEvent.submitSuccess);
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);
});
final msg = _isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功';
_showStatusOverride(msg, StatusDotColor.green, const Duration(milliseconds: 1500));
} else if (result.isDuplicate) {
_vibrateError();
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
} else {
_vibrateError();
_showFeedback(result.errorMessage ?? '提交失败', isError: true);
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
_feedbackService.trigger(
isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure,
);
_showStatusOverride(
result.errorMessage ?? '提交失败',
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
@@ -197,11 +230,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
} else {
failed.add(zp);
if (result.isDuplicate) {
// 重复上架弹窗中断批量流程
setState(() {
_zongpaiNos.removeWhere((e) => toRemove.contains(e));
_isSubmitting = false;
});
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zp, result.duplicateInfo);
return;
}
@@ -216,24 +249,22 @@ class _RegistrationPageState extends State<RegistrationPage> {
});
if (failed.isEmpty) {
_vibrateSuccess();
_showFeedback('批量上架成功($successCount 条)', isError: false);
_feedbackService.trigger(FeedbackEvent.submitSuccess);
_showStatusOverride(
'批量上架成功($successCount 条)',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
} else {
_vibrateError();
_showFeedback('成功 $successCount 条,失败 ${failed.length}', isError: true);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'成功 $successCount 条,失败 ${failed.length}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
void _vibrateSuccess() {
Vibration.vibrate(duration: 200);
_soundService.playSuccess();
}
void _vibrateError() {
Vibration.vibrate(duration: 1000);
_soundService.playFailure();
}
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
showDialog(
context: context,
@@ -265,7 +296,10 @@ class _RegistrationPageState extends State<RegistrationPage> {
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
onPressed: () {
_feedbackService.stopAlert();
Navigator.pop(ctx);
},
child: const Text('关闭'),
),
],
@@ -275,13 +309,17 @@ class _RegistrationPageState extends State<RegistrationPage> {
void _toggleLock(bool value) {
if (value && _locationCode == null) {
_showFeedback('请先扫描货位号', isError: true);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'请先扫描货位号',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
setState(() {
_isLocked = value;
if (!value) {
// 退出锁定模式,只保留最后一条总排号
if (_zongpaiNos.length > 1) {
_zongpaiNos.removeRange(0, _zongpaiNos.length - 1);
}
@@ -303,240 +341,73 @@ class _RegistrationPageState extends State<RegistrationPage> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Compute effective status
_updateBaseStatus();
final effectiveDot = _statusOverrideDot ?? _statusDot;
final effectiveText = _statusOverrideText ?? _statusText;
return KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
child: 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,
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),
],
),
),
// 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(
],
),
body: Column(
children: [
// Main form area (no top banner)
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// ---- 目标货位 (上方) ----
Row(
children: [
Expanded(
child: Text(
_locationCode ?? '',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: _locationCode != null
? Colors.black87
: Colors.grey,
),
const Text(
'目标货位',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
if (_isLocked)
const Icon(
Icons.lock,
color: Colors.orange,
size: 20,
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: 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 ...[
// 单次模式:单个显示
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
@@ -545,100 +416,231 @@ class _RegistrationPageState extends State<RegistrationPage> {
),
decoration: BoxDecoration(
border: Border.all(
color: _zongpaiNos.isNotEmpty
color: _locationCode != null
? Colors.green
: Colors.grey.shade400,
width: _zongpaiNos.isNotEmpty ? 2 : 1,
width: _locationCode != null ? 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,
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} 条)'
: '确认上架(P1)',
style: const TextStyle(fontSize: 18),
),
),
),
],
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} 条)'
: '确认上架(P1)',
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,
),
),
],
),
),
],
// Bottom status bar
StatusBar(dotColor: effectiveDot, text: effectiveText),
],
),
),
),
);
}
}