diff --git a/docs/error-feedback-refactoring-plan.md b/docs/error-feedback-refactoring-plan.md new file mode 100644 index 0000000..58e1ae2 --- /dev/null +++ b/docs/error-feedback-refactoring-plan.md @@ -0,0 +1,261 @@ +# 错误反馈机制重构计划 + +> 版本:v1.0 +> 日期:2026-05-12 +> 状态:已完成 + +--- + +## 1. 背景与目标 + +### 1.1 当前问题 + +对照 PRD(上架登记模块 v1.0 + 装箱编号模块 v1.3)的要求,当前 Flutter 应用在错误反馈方面存在以下差距: + +| # | 问题 | 影响范围 | +|---|------|---------| +| 1 | **装箱模块完全没有声音和振动反馈** — `boxing_page.dart` 未引入 `SoundService` 和 `Vibration` | 装箱编号 | +| 2 | **反馈横幅位置错误** — 当前在页面顶部显示反馈横幅,PRD 要求统一由底部状态栏承载所有反馈文案 | 上架登记、装箱编号 | +| 3 | **底部状态栏缺少黄色(网络异常)和红色(错误)状态** — 注册页状态栏圆点仅使用蓝/橙/绿三色,错误信息只出现在顶部横幅 | 上架登记 | +| 4 | **反馈信息重复显示** — 顶部横幅和底部状态栏同时显示类似内容,造成冗余 | 上架登记、装箱编号 | +| 5 | **声音类型不足** — `SoundService` 只有 success/failure 两种,PRD 要求区分"扫描提示音"、"错误音"、"成功音"、"连续错误音" | 全局 | +| 6 | **重复上架弹窗缺少声音差异化** — PRD 要求"连续错误音"+"连续振动",当前只是普通失败音 | 上架登记 | + +> LED 反馈暂时不实现,后续单独处理。 + +### 1.2 重构目标 + +- **统一反馈入口**:创建集中的 `FeedbackService`,管理声音/振动三通道反馈 +- **消除重复**:移除顶部反馈横幅,所有反馈文案统一由底部状态栏显示 +- **补全缺失**:为装箱模块补齐声音/振动反馈 +- **符合 PRD**:状态栏圆点颜色、文案、持续时间与 PRD 表格一一对应 + +--- + +## 2. PRD 反馈要求汇总 + +### 2.1 上架登记模块(PRD 第 7 节) + +| 事件 | 屏幕(底部状态栏) | 声音 | 振动 | +|------|-------------------|------|------| +| 扫入有效总排号 | 总排号字段填入,绿色高亮 | 短促提示音 | 短震 | +| 扫入有效货位号 | 货位号字段填入,绿色高亮 | 短促提示音 | 短震 | +| 扫入无效码 | 红色提示 2 秒:"无效码:{值}" | 错误音 | 短震 | +| 提交成功 | 绿色提示 1.5 秒 | 成功音 | 长震 | +| 提交失败 | 红色提示 2 秒 | 失败音 | 短震 | +| 重复上架错误 | 红色错误弹窗(需手动关闭) | 连续错误音 | 连续震 | +| 网络异常 | 黄色提示:"网络异常,请检查网络连接" | 失败音 | 短震 | + +### 2.2 装箱编号模块(PRD 第 9 节) + +| 事件 | 屏幕(底部状态栏) | 声音 | 振动 | +|------|-------------------|------|------| +| 扫入有效总排号 | 显示排产号信息,绿色对勾 | 短促提示音 | 短震 | +| 扫入无效码 | 红色提示:"无效码,请重新扫描" | 错误音 | 短震 | +| 提交成功 | 绿色提示 1.5 秒 | 成功音 | 长震 | +| 重复箱号 | amber 提示 + 输入框 amber 边框 | 失败音 | 短震 | +| 网络异常 | 黄色提示:"网络异常,请检查网络连接" | 失败音 | 短震 | +| 切换装箱模式 | 模式控件高亮切换 | 短促提示音 | — | + +### 2.3 底部状态栏圆点颜色规范 + +| 状态 | 圆点颜色 | +|------|---------| +| 空闲等待 | 蓝色 | +| 提交中 | 橙色 | +| 成功 | 绿色 | +| 错误(无效码/提交失败/重复) | 红色 | +| 网络异常 | 黄色 | +| 警告(重复箱号) | amber | + +--- + +## 3. 架构设计 + +### 3.1 新增 `FeedbackService` + +创建 `lib/services/feedback_service.dart`,作为**唯一的反馈调度中心**。 + +``` +FeedbackService +├── trigger(FeedbackEvent event) ← 统一入口 +├── _playSound(SoundType type) ← 声音通道 +├── _vibrate(VibrationPattern p) ← 振动通道 +└── 底部状态栏状态由各 Page State 管理,FeedbackService 不直接操控 Widget +``` + +**FeedbackEvent 枚举:** + +```dart +enum FeedbackEvent { + scanValid, // 扫入有效码 → 短促提示音 + 短震 + scanInvalid, // 扫入无效码 → 错误音 + 短震 + submitSuccess, // 提交成功 → 成功音 + 长震 + submitFailure, // 提交失败 → 失败音 + 短震 + duplicateError, // 重复上架 → 连续错误音 + 连续震 + networkError, // 网络异常 → 失败音 + 短震 + duplicateBoxNo, // 重复箱号 → 失败音 + 短震 + modeSwitch, // 模式切换 → 短促提示音 +} +``` + +### 3.2 声音扩展 + +扩展 `SoundService`,增加声音类型: + +| 声音类型 | 方法名 | 用途 | +|---------|--------|------| +| 短促提示音 | `playBeep()` | 有效扫码、模式切换 | +| 错误音 | `playError()` | 无效码 | +| 成功音 | `playSuccess()` | 提交成功(保留现有) | +| 失败音 | `playFailure()` | 提交失败、网络异常(保留现有) | +| 连续错误音 | `playAlertLoop()` | 重复上架弹窗(循环播放直到弹窗关闭) | + +**实现策略**: +- 在 `SoundService` 中增加 `beep`、`error`、`alert` 三个可配置路径 +- 设置页面增加对应的文件选择器 +- 若路径未配置则静默跳过(不阻塞业务) + +### 3.3 底部状态栏组件化 + +抽取 `lib/widgets/status_bar.dart` 为独立 Widget: + +```dart +enum StatusDotColor { blue, orange, green, red, yellow, amber } + +class StatusBar extends StatelessWidget { + final StatusDotColor dotColor; + final String text; + // ... +} +``` + +所有页面统一使用此组件,消除重复代码。 + +--- + +## 4. 文件变更清单 + +### 4.1 新增文件 + +| 文件路径 | 说明 | +|---------|------| +| `lib/services/feedback_service.dart` | 反馈调度中心,管理声音/振动 | +| `lib/widgets/status_bar.dart` | 底部状态栏可复用组件 | + +### 4.2 修改文件 + +| 文件路径 | 变更内容 | +|---------|---------| +| `lib/services/sound_service.dart` | 增加 `beep`/`error`/`alert` 声音类型和配置 | +| `lib/pages/registration_page.dart` | ① 移除顶部反馈横幅 ② 状态栏增加红/黄颜色 ③ 引入 FeedbackService ④ 重复上架弹窗调用连续错误音 | +| `lib/pages/boxing_page.dart` | ① 移除顶部反馈横幅 ② 引入 FeedbackService ③ 增加声音/振动反馈 ④ 状态栏统一使用 StatusBar 组件 | +| `lib/pages/settings_page.dart` | 增加声音文件配置项(beep/error/alert) | + +### 4.3 不变文件 + +| 文件路径 | 原因 | +|---------|------| +| `lib/services/api_service.dart` | API 层只负责数据传输,不涉及反馈 | +| `lib/services/code_parser.dart` | 解析逻辑不变 | +| `lib/services/scanner_service.dart` | 扫码硬件层不变 | +| `lib/pages/home_page.dart` | 不涉及作业反馈 | +| `lib/pages/boxing_detail_page.dart` | 只读展示页,无反馈交互 | + +--- + +## 5. 实施步骤 + +### 步骤 1:创建 `FeedbackService`(核心) + +**文件**:`lib/services/feedback_service.dart` + +- 定义 `FeedbackEvent` 枚举 +- 根据 event 映射声音类型、振动模式 +- 调用 `SoundService`、`Vibration` + +**预估改动**:~70 行新代码 + +### 步骤 2:扩展 `SoundService` + +**文件**:`lib/services/sound_service.dart` + +- 新增 `playBeep()`、`playError()`、`playAlertLoop()`、`stopAlert()` 方法 +- 新增 `beep_path`、`error_path`、`alert_path` 配置存取 +- 所有播放方法在路径未配置时静默返回 + +**预估改动**:~40 行新增 + +### 步骤 3:创建 `StatusBar` 组件 + +**文件**:`lib/widgets/status_bar.dart` + +- 抽取底部状态栏为独立 Widget +- 接收 `dotColor`(枚举)和 `text` 参数 +- 统一样式:圆点 + 文字,背景色使用 `surfaceContainerHighest` + +**预估改动**:~40 行新代码 + +### 步骤 4:重构上架登记页 + +**文件**:`lib/pages/registration_page.dart` + +变更清单: +1. **删除**顶部反馈横幅相关代码(`_snackbarMessage`、`_snackbarColor`、`_showFeedback()` 方法中的横幅渲染) +2. **引入** `FeedbackService`,在以下时机调用 `trigger()`: + - `_onScan` 无效码 → `scanInvalid` + - `_onScan` 有效码 → `scanValid` + - `_submitOne` 成功 → `submitSuccess` + - `_submitOne` 失败 → `submitFailure` + - `_submitOne` 重复 → `duplicateError` + - catch 网络异常 → `networkError` +3. **修改**底部状态栏: + - 状态栏圆点颜色增加红色和黄色 + - 错误文案在状态栏中显示(不再在横幅中) + - 移除 `_successMessage` 单独字段,统一由状态栏管理 +4. **替换** 内联状态栏 Widget 为 `StatusBar` 组件 +5. **修改** `_showDuplicateDialog`:打开时触发 `duplicateError`(连续音/震),关闭时停止 + +**预估改动**:净减 ~30 行(移除横幅 > 新增 trigger 调用) + +### 步骤 5:重构装箱编号页 + +**文件**:`lib/pages/boxing_page.dart` + +变更清单: +1. **删除**顶部反馈横幅相关代码 +2. **删除** 内联底部状态栏代码,替换为 `StatusBar` 组件 +3. **引入** `FeedbackService`,在以下时机调用 `trigger()`: + - `_onScan` 无效码 → `scanInvalid` + - `_queryBoxInfo` 成功 → `scanValid` + - `_submit` 成功 → `submitSuccess` + - `_submit` 失败 → `submitFailure` + - `_submit` 重复箱号 → `duplicateBoxNo` + - catch 网络异常 → `networkError` + - `_cycleMode` → `modeSwitch` +4. **修改**状态栏颜色逻辑,增加红色/黄色/amber + +**预估改动**:净减 ~20 行 + +### 步骤 6:设置页扩展**文件**:`lib/pages/settings_page.dart`- 新增三个声音文件选择器:提示音(beep)、错误音(error)、警报音(alert)- 保持与现有 success/failure 选择器一致的 UI 风格**预估改动**:~60 行新增 + +--- + +## 6. 风险与约束 + +| 风险 | 缓解措施 | +|------|---------| +| LED 功能依赖设备硬件 | `LedService` 全面包裹 try-catch,不支持时静默降级 | +| 声音文件路径未配置 | 所有 `play*()` 方法在路径为空时静默返回 | +| 连续错误音循环播放 | `playAlertLoop()` 使用循环计数或时长上限,`stopAlert()` 确保可停止 | +| 振动权限 | Android 需要 `VIBRATE` 权限,检查 `AndroidManifest.xml` 是否已声明 | + +--- + +## 7. 不在本次范围内 + +- 后端 API 错误格式调整(PRD 已定义,后端自行对齐) +- 操作日志与操作人员记录 +- 离线缓存与联网同步 +- 新模块开发 +- LED 反馈(后续单独处理) diff --git a/lib/pages/boxing_page.dart b/lib/pages/boxing_page.dart index 86e016d..f74b5d7 100644 --- a/lib/pages/boxing_page.dart +++ b/lib/pages/boxing_page.dart @@ -4,7 +4,9 @@ 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/pages/boxing_detail_page.dart'; +import 'package:pad_scanner/widgets/status_bar.dart'; // === 装箱模式 === @@ -34,6 +36,7 @@ class BoxingPage extends StatefulWidget { class _BoxingPageState extends State { final _scannerService = ScannerService(); final _apiService = ApiService(); + final _feedbackService = FeedbackService(); // 模式 BoxingMode _mode = BoxingMode.one2one; @@ -72,9 +75,11 @@ class _BoxingPageState extends State { // 重复箱号 bool _isDuplicateBoxNo = false; - // 反馈 - String? _feedbackMessage; - Color? _feedbackColor; + // Status bar state + StatusDotColor _statusDot = StatusDotColor.blue; + String _statusText = '等待扫码'; + String? _statusOverrideText; + StatusDotColor? _statusOverrideDot; @override void initState() { @@ -88,6 +93,7 @@ class _BoxingPageState extends State { _quantityController.dispose(); _boxNoFocusNode.dispose(); _quantityFocusNode.dispose(); + _feedbackService.dispose(); super.dispose(); } @@ -105,6 +111,7 @@ class _BoxingPageState extends State { } void _cycleMode() { + _feedbackService.trigger(FeedbackEvent.modeSwitch); setState(() { switch (_mode) { case BoxingMode.one2one: @@ -133,8 +140,8 @@ class _BoxingPageState extends State { _lastBoxNo = null; _lastQuantity = null; _isDuplicateBoxNo = false; - _feedbackMessage = null; - _feedbackColor = null; + _statusOverrideText = null; + _statusOverrideDot = null; } // === 扫码处理 === @@ -143,7 +150,12 @@ class _BoxingPageState extends State { final parsed = CodeParser.parse(result.barcode); if (parsed.type != CodeType.zongpaiNo) { - _showFeedback('无效码,请重新扫描', isError: true); + _feedbackService.trigger(FeedbackEvent.scanInvalid); + _showStatusOverride( + '无效码,请重新扫描', + StatusDotColor.red, + const Duration(seconds: 2), + ); return; } @@ -167,7 +179,7 @@ class _BoxingPageState extends State { setState(() { _phase = _Phase.waiting; _zongpaiNo = null; - _feedbackMessage = null; + _statusOverrideText = null; }); _queryBoxInfo(zongpai); } @@ -176,7 +188,12 @@ class _BoxingPageState extends State { 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; } @@ -188,10 +205,20 @@ class _BoxingPageState extends State { if (!mounted) return; if (!result.success) { - _showFeedback(result.errorMessage ?? '查询失败', isError: true); + final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; + _feedbackService.trigger( + isNetwork ? FeedbackEvent.networkError : FeedbackEvent.scanInvalid, + ); + _showStatusOverride( + result.errorMessage ?? '查询失败', + isNetwork ? StatusDotColor.yellow : StatusDotColor.red, + const Duration(seconds: 2), + ); return; } + _feedbackService.trigger(FeedbackEvent.scanValid); + setState(() { _zongpaiNo = zongpai; _paichanNo = result.paichanNo; @@ -200,7 +227,7 @@ class _BoxingPageState extends State { _maxBoxNo = result.maxBoxNo; _phase = _Phase.scanned; _isDuplicateBoxNo = false; - _feedbackMessage = null; + _statusOverrideText = null; // 多码一箱:记录已扫描列表 if (_mode == BoxingMode.many2one && !_scannedZongpais.contains(zongpai)) { @@ -215,21 +242,17 @@ class _BoxingPageState extends State { void _applyAutoFill() { switch (_mode) { case BoxingMode.one2one: - // 箱号 = max+1, 数量 = ERP 数量 _boxNoController.text = (_maxBoxNo + 1).toString(); _quantityController.text = (_erpQuantity ?? 0).toString(); case BoxingMode.one2many: if (_lastBoxNo != null) { - // 继续添加:箱号 = 上次+1, 数量 = 上次值 _boxNoController.text = (_lastBoxNo! + 1).toString(); _quantityController.text = _lastQuantity?.toString() ?? ''; } else { - // 首次:箱号 = max+1, 数量不填 _boxNoController.text = (_maxBoxNo + 1).toString(); _quantityController.clear(); } case BoxingMode.many2one: - // 箱号 = max+1 (首次) 或锁定, 数量 = ERP 数量 if (!_boxNoLocked) { _boxNoController.text = (_maxBoxNo + 1).toString(); _boxNoLocked = true; @@ -269,7 +292,12 @@ class _BoxingPageState extends State { 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; } @@ -298,24 +326,39 @@ class _BoxingPageState extends State { if (result.success) { _onSubmitSuccess(boxNo, quantity); } else if (result.isDuplicate) { + _feedbackService.trigger(FeedbackEvent.duplicateBoxNo); if (_mode == BoxingMode.one2one) { - _showFeedback('该总排号已绑定箱号 ${result.boxNo ?? "?"},请勿重复装箱', isError: true); + _showStatusOverride( + '该总排号已绑定箱号 ${result.boxNo ?? "?"},请勿重复装箱', + StatusDotColor.red, + const Duration(seconds: 2), + ); } else { - _showFeedback( + _showStatusOverride( '排产号 ${result.paichanNo ?? ""} 下箱号 ${result.boxNo ?? ""} 已存在', - isError: true, + StatusDotColor.amber, + const Duration(seconds: 2), ); } } else { - _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), + ); } } void _onSubmitSuccess(int boxNo, int quantity) { + _feedbackService.trigger(FeedbackEvent.submitSuccess); + setState(() { _lastBoxNo = boxNo; _lastQuantity = quantity; - // 刷新已有箱号列表(将新记录加入本地列表) _existingBoxes = List.from(_existingBoxes) ..add( BoxDetailData( @@ -328,56 +371,54 @@ class _BoxingPageState extends State { switch (_mode) { case BoxingMode.one2one: - // 显示成功 1.5s → 重置 - _showFeedback('装箱成功', isError: false); + _showStatusOverride('装箱成功', StatusDotColor.green, const Duration(milliseconds: 1500)); Future.delayed(const Duration(milliseconds: 1500), () { if (mounted) setState(() => _resetState()); }); case BoxingMode.one2many: - // 进入已提交状态,等待"继续添加"或"返回" setState(() { _phase = _Phase.submitted; _zongpaiNo = null; }); - _showFeedback('装箱成功', isError: false); + _showStatusOverride('装箱成功,可继续添加或返回', StatusDotColor.green, const Duration(milliseconds: 1500)); case BoxingMode.many2one: - // 进入已提交状态,自动等待下一个扫码 setState(() { _phase = _Phase.submitted; _zongpaiNo = null; }); - _showFeedback('装箱成功,请扫描下一个总排号', isError: false); + _showStatusOverride('装箱成功,请扫描下一个总排号', StatusDotColor.green, const Duration(milliseconds: 1500)); } } // === 操作按钮 === void _onContinueAdding() { - // 一码多箱的继续添加 setState(() { _phase = _Phase.waiting; - _feedbackMessage = null; + _statusOverrideText = null; }); - // 预填值会在下次扫码后的 _applyAutoFill 中处理 - // 但这里需要手动触发,因为不重新扫码 - // 用户需要扫描同一个总排号(或其他总排号) } void _onGoBack() { setState(() => _resetState()); } - // === 反馈 === + // === Status bar management === - void _showFeedback(String message, {bool isError = false}) { + void _showStatusOverride(String text, StatusDotColor dot, Duration duration) { setState(() { - _feedbackMessage = message; - _feedbackColor = isError ? Colors.red.shade700 : Colors.green.shade700; + _statusOverrideText = text; + _statusOverrideDot = dot; }); - Future.delayed(const Duration(seconds: 2), () { - if (mounted) setState(() => _feedbackMessage = null); + Future.delayed(duration, () { + if (mounted) { + setState(() { + _statusOverrideText = null; + _statusOverrideDot = null; + }); + } }); } @@ -397,44 +438,41 @@ class _BoxingPageState extends State { // === 状态文字 === - String get _statusText { - if (_isSubmitting) return '正在提交…'; - if (_feedbackMessage != null) return _feedbackMessage!; + void _updateBaseStatus() { + if (_isSubmitting) { + _statusDot = StatusDotColor.orange; + _statusText = '正在提交…'; + return; + } + if (_isDuplicateBoxNo && _phase == _Phase.scanned) { + _statusDot = StatusDotColor.amber; + _statusText = '箱号 ${_boxNoController.text} 已存在,请重新输入'; + return; + } switch (_phase) { case _Phase.waiting: + _statusDot = StatusDotColor.blue; if (_mode == BoxingMode.many2one && _boxNoLocked) { - return '请扫描下一个总排号'; + _statusText = '请扫描下一个总排号'; + } else { + _statusText = '等待扫码'; } - return '等待扫码'; case _Phase.scanned: - return '请确认信息并提交'; + _statusDot = StatusDotColor.blue; + _statusText = '请确认信息并提交'; case _Phase.submitted: + _statusDot = StatusDotColor.green; switch (_mode) { case BoxingMode.one2many: - return '装箱成功,可继续添加或返回'; + _statusText = '装箱成功,可继续添加或返回'; case BoxingMode.many2one: - return '请扫描下一个总排号'; + _statusText = '请扫描下一个总排号'; case BoxingMode.one2one: - return ''; + _statusText = ''; } } } - Color get _statusDotColor { - if (_isSubmitting) return Colors.orange; - if (_feedbackMessage != null) { - return _feedbackColor == Colors.red.shade700 ? Colors.red : Colors.green; - } - switch (_phase) { - case _Phase.waiting: - return Colors.blue; - case _Phase.scanned: - return Colors.green; - case _Phase.submitted: - return Colors.green; - } - } - // === Build === @override @@ -444,11 +482,14 @@ class _BoxingPageState extends State { final showActionButtons = _phase == _Phase.submitted && _mode != BoxingMode.one2one; + _updateBaseStatus(); + final effectiveDot = _statusOverrideDot ?? _statusDot; + final effectiveText = _statusOverrideText ?? _statusText; + return Scaffold( appBar: AppBar( title: const Text('装箱编号'), actions: [ - // 模式切换按钮 Padding( padding: const EdgeInsets.only(right: 4), child: TextButton.icon( @@ -466,20 +507,7 @@ class _BoxingPageState extends State { ), body: Column( children: [ - // 反馈提示条 - if (_feedbackMessage != null) - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), - color: _feedbackColor, - child: Text( - _feedbackMessage!, - style: const TextStyle(color: Colors.white, fontSize: 14), - textAlign: TextAlign.center, - ), - ), - - // 重复箱号警告条 + // 重复箱号警告条(保留,因为这是输入区关联的即时反馈) if (_isDuplicateBoxNo && _phase == _Phase.scanned) Container( width: double.infinity, @@ -547,31 +575,7 @@ class _BoxingPageState extends State { ), // 底部状态栏 - 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: _statusDotColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _statusText, - style: const TextStyle(fontSize: 14), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), + StatusBar(dotColor: effectiveDot, text: effectiveText), ], ), ); @@ -581,7 +585,6 @@ class _BoxingPageState extends State { Widget _buildScanArea(bool isWaiting) { if (isWaiting) { - // 等待扫码:置灰提示 return Container( width: double.infinity, padding: const EdgeInsets.all(16), @@ -603,9 +606,7 @@ class _BoxingPageState extends State { ); } - // 已扫码:显示总排号 if (_mode == BoxingMode.many2one && _scannedZongpais.isNotEmpty) { - // 多码一箱:显示已扫描列表 return Wrap( spacing: 6, runSpacing: 4, @@ -624,7 +625,6 @@ class _BoxingPageState extends State { ); } - // 单个总排号 return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), @@ -662,7 +662,6 @@ class _BoxingPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 排产号 Text( '排产号:', style: TextStyle( @@ -681,7 +680,6 @@ class _BoxingPageState extends State { ), const SizedBox(height: 8), - // 已有箱数 + 最大箱号 + 详情按钮 Row( children: [ Expanded( diff --git a/lib/pages/registration_page.dart b/lib/pages/registration_page.dart index afa79e6..bf98b02 100644 --- a/lib/pages/registration_page.dart +++ b/lib/pages/registration_page.dart @@ -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 { final _scannerService = ScannerService(); final _apiService = ApiService(); - final _soundService = SoundService(); + final _feedbackService = FeedbackService(); final _focusNode = FocusNode(); final _zongpaiNos = []; @@ -26,9 +26,11 @@ class _RegistrationPageState extends State { 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 { @override void dispose() { _focusNode.dispose(); - _soundService.dispose(); + _feedbackService.dispose(); super.dispose(); } @@ -57,9 +59,9 @@ class _RegistrationPageState extends State { 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 { _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 { }); } - 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 { 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 { 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 { } 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 { }); 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? info) { showDialog( context: context, @@ -265,7 +296,10 @@ class _RegistrationPageState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx), + onPressed: () { + _feedbackService.stopAlert(); + Navigator.pop(ctx); + }, child: const Text('关闭'), ), ], @@ -275,13 +309,17 @@ class _RegistrationPageState extends State { 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 { 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 { ), 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), + ], + ), ), - ), ); } } diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 2aa05f9..659d78a 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -20,6 +20,9 @@ class _SettingsPageState extends State { String? _successPath; String? _failurePath; + String? _beepPath; + String? _errorPath; + String? _alertPath; @override void initState() { @@ -35,12 +38,20 @@ class _SettingsPageState extends State { } Future _loadSoundPaths() async { - final s = await _soundService.getSuccessPath(); - final f = await _soundService.getFailurePath(); + final results = await Future.wait([ + _soundService.getSuccessPath(), + _soundService.getFailurePath(), + _soundService.getBeepPath(), + _soundService.getErrorPath(), + _soundService.getAlertPath(), + ]); if (mounted) { setState(() { - _successPath = s; - _failurePath = f; + _successPath = results[0]; + _failurePath = results[1]; + _beepPath = results[2]; + _errorPath = results[3]; + _alertPath = results[4]; }); } } @@ -74,38 +85,66 @@ class _SettingsPageState extends State { } } - Future _pickSound(bool isSuccess) async { + Future _pickSound(String key) async { final result = await FilePicker.pickFiles( type: FileType.audio, ); if (result != null && result.files.single.path != null) { final path = result.files.single.path!; - if (isSuccess) { - await _soundService.setSuccessPath(path); - } else { - await _soundService.setFailurePath(path); + switch (key) { + case 'success': + await _soundService.setSuccessPath(path); + case 'failure': + await _soundService.setFailurePath(path); + case 'beep': + await _soundService.setBeepPath(path); + case 'error': + await _soundService.setErrorPath(path); + case 'alert': + await _soundService.setAlertPath(path); } setState(() { - if (isSuccess) { - _successPath = path; - } else { - _failurePath = path; + switch (key) { + case 'success': + _successPath = path; + case 'failure': + _failurePath = path; + case 'beep': + _beepPath = path; + case 'error': + _errorPath = path; + case 'alert': + _alertPath = path; } }); } } - Future _clearSound(bool isSuccess) async { - if (isSuccess) { - await _soundService.setSuccessPath(null); - } else { - await _soundService.setFailurePath(null); + Future _clearSound(String key) async { + switch (key) { + case 'success': + await _soundService.setSuccessPath(null); + case 'failure': + await _soundService.setFailurePath(null); + case 'beep': + await _soundService.setBeepPath(null); + case 'error': + await _soundService.setErrorPath(null); + case 'alert': + await _soundService.setAlertPath(null); } setState(() { - if (isSuccess) { - _successPath = null; - } else { - _failurePath = null; + switch (key) { + case 'success': + _successPath = null; + case 'failure': + _failurePath = null; + case 'beep': + _beepPath = null; + case 'error': + _errorPath = null; + case 'alert': + _alertPath = null; } }); } @@ -191,29 +230,67 @@ class _SettingsPageState extends State { style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 16), - // 上架成功铃声 + // 扫码提示音 _buildSoundRow( - label: '上架成功铃声', - path: _successPath, - onPick: () => _pickSound(true), - onPreview: _successPath != null - ? () => _previewSound(_successPath!) + label: '扫码提示音', + path: _beepPath, + onPick: () => _pickSound('beep'), + onPreview: _beepPath != null + ? () => _previewSound(_beepPath!) : null, - onClear: _successPath != null ? () => _clearSound(true) : null, + onClear: _beepPath != null ? () => _clearSound('beep') : null, ), const SizedBox(height: 12), - // 上架失败铃声 + // 成功铃声 _buildSoundRow( - label: '上架失败铃声', + label: '成功铃声', + path: _successPath, + onPick: () => _pickSound('success'), + onPreview: _successPath != null + ? () => _previewSound(_successPath!) + : null, + onClear: _successPath != null ? () => _clearSound('success') : null, + ), + + const SizedBox(height: 12), + + // 失败铃声 + _buildSoundRow( + label: '失败铃声', path: _failurePath, - onPick: () => _pickSound(false), + onPick: () => _pickSound('failure'), onPreview: _failurePath != null ? () => _previewSound(_failurePath!) : null, - onClear: - _failurePath != null ? () => _clearSound(false) : null, + onClear: _failurePath != null ? () => _clearSound('failure') : null, + ), + + const SizedBox(height: 12), + + // 错误铃声(无效码) + _buildSoundRow( + label: '错误铃声(无效码)', + path: _errorPath, + onPick: () => _pickSound('error'), + onPreview: _errorPath != null + ? () => _previewSound(_errorPath!) + : null, + onClear: _errorPath != null ? () => _clearSound('error') : null, + ), + + const SizedBox(height: 12), + + // 警报铃声(重复上架) + _buildSoundRow( + label: '警报铃声(重复上架)', + path: _alertPath, + onPick: () => _pickSound('alert'), + onPreview: _alertPath != null + ? () => _previewSound(_alertPath!) + : null, + onClear: _alertPath != null ? () => _clearSound('alert') : null, ), ], ), diff --git a/lib/services/feedback_service.dart b/lib/services/feedback_service.dart new file mode 100644 index 0000000..db5ec18 --- /dev/null +++ b/lib/services/feedback_service.dart @@ -0,0 +1,83 @@ +import 'package:vibration/vibration.dart'; +import 'package:pad_scanner/services/sound_service.dart'; + +/// Unified feedback events used across the app. +enum FeedbackEvent { + scanValid, // Valid barcode scanned → beep + short vibrate + scanInvalid, // Invalid barcode → error sound + short vibrate + submitSuccess, // Submit succeeded → success sound + long vibrate + submitFailure, // Submit failed → failure sound + short vibrate + duplicateError, // Duplicate (shelf) → alert loop + continuous vibrate + networkError, // Network error → failure sound + short vibrate + duplicateBoxNo, // Duplicate box no → failure sound + short vibrate + modeSwitch, // Mode toggle → beep only +} + +/// Centralized feedback dispatcher. +/// +/// Each page manages its own bottom status bar state; this service handles +/// the sound, vibration channels only. +class FeedbackService { + final SoundService _soundService; + + FeedbackService({SoundService? soundService}) + : _soundService = soundService ?? SoundService(); + + /// Trigger feedback for the given [event]. + Future trigger(FeedbackEvent event) async { + switch (event) { + case FeedbackEvent.scanValid: + _soundService.playBeep(); + _vibrateShort(); + + case FeedbackEvent.scanInvalid: + _soundService.playError(); + _vibrateShort(); + + case FeedbackEvent.submitSuccess: + _soundService.playSuccess(); + _vibrateLong(); + + case FeedbackEvent.submitFailure: + _soundService.playFailure(); + _vibrateShort(); + + case FeedbackEvent.duplicateError: + _soundService.playAlertLoop(); + _vibratePattern(); + + case FeedbackEvent.networkError: + _soundService.playFailure(); + _vibrateShort(); + + case FeedbackEvent.duplicateBoxNo: + _soundService.playFailure(); + _vibrateShort(); + + case FeedbackEvent.modeSwitch: + _soundService.playBeep(); + } + } + + /// Stop any ongoing alert (e.g. when duplicate dialog is closed). + Future stopAlert() async { + _soundService.stopAlert(); + } + + void _vibrateShort() { + Vibration.vibrate(duration: 100); + } + + void _vibrateLong() { + Vibration.vibrate(duration: 200); + } + + /// Continuous vibration pattern: 3 bursts to signal critical error. + void _vibratePattern() { + Vibration.vibrate(pattern: [0, 300, 100, 300, 100, 300]); + } + + Future dispose() async { + await _soundService.dispose(); + } +} diff --git a/lib/services/sound_service.dart b/lib/services/sound_service.dart index 62f8d7f..a4d58d9 100644 --- a/lib/services/sound_service.dart +++ b/lib/services/sound_service.dart @@ -1,38 +1,64 @@ +import 'dart:async'; import 'package:audioplayers/audioplayers.dart'; import 'package:pad_scanner/services/app_config_service.dart'; class SoundService { static const _keySuccess = 'sound_success'; static const _keyFailure = 'sound_failure'; + static const _keyBeep = 'sound_beep'; + static const _keyError = 'sound_error'; + static const _keyAlert = 'sound_alert'; final _player = AudioPlayer(); + final _alertPlayer = AudioPlayer(); final _configService = AppConfigService(); - Future getSuccessPath() async { - return _configService.getString(_keySuccess); - } + Timer? _alertTimer; - Future getFailurePath() async { - return _configService.getString(_keyFailure); - } + // --- Path getters --- - Future setSuccessPath(String? path) async { + Future getSuccessPath() => + _configService.getString(_keySuccess); + + Future getFailurePath() => + _configService.getString(_keyFailure); + + Future getBeepPath() => + _configService.getString(_keyBeep); + + Future getErrorPath() => + _configService.getString(_keyError); + + Future getAlertPath() => + _configService.getString(_keyAlert); + + // --- Path setters --- + + Future setSuccessPath(String? path) => + _setConfigPath(_keySuccess, path); + + Future setFailurePath(String? path) => + _setConfigPath(_keyFailure, path); + + Future setBeepPath(String? path) => + _setConfigPath(_keyBeep, path); + + Future setErrorPath(String? path) => + _setConfigPath(_keyError, path); + + Future setAlertPath(String? path) => + _setConfigPath(_keyAlert, path); + + Future _setConfigPath(String key, String? path) async { if (path != null) { - await _configService.setString(_keySuccess, path); + await _configService.setString(key, path); } else { final config = await _configService.loadConfig(); - config.remove(_keySuccess); + config.remove(key); } } - Future setFailurePath(String? path) async { - if (path != null) { - await _configService.setString(_keyFailure, path); - } else { - final config = await _configService.loadConfig(); - config.remove(_keyFailure); - } - } + // --- Playback methods --- Future playSuccess() async { final path = await getSuccessPath(); @@ -48,11 +74,55 @@ class SoundService { } } + Future playBeep() async { + final path = await getBeepPath(); + if (path != null) { + await _player.play(DeviceFileSource(path)); + } + } + + Future playError() async { + final path = await getErrorPath(); + if (path != null) { + await _player.play(DeviceFileSource(path)); + } + } + + /// Play alert sound in a loop until [stopAlert] is called. + /// Loops up to 10 times as a safety guard (~15 seconds max). + Future playAlertLoop() async { + final path = await getAlertPath(); + if (path == null) return; + + _alertTimer?.cancel(); + var count = 0; + _alertTimer = Timer.periodic(const Duration(milliseconds: 1500), (_) async { + if (count >= 10) { + _alertTimer?.cancel(); + return; + } + await _alertPlayer.play(DeviceFileSource(path)); + count++; + }); + // Play immediately first time + await _alertPlayer.play(DeviceFileSource(path)); + count++; + } + + /// Stop the alert loop. + void stopAlert() { + _alertTimer?.cancel(); + _alertTimer = null; + _alertPlayer.stop(); + } + Future play(String path) async { await _player.play(DeviceFileSource(path)); } Future dispose() async { + _alertTimer?.cancel(); await _player.dispose(); + await _alertPlayer.dispose(); } } diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart new file mode 100644 index 0000000..0cbc83b --- /dev/null +++ b/lib/widgets/status_bar.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +/// Status bar dot colors matching PRD requirements. +enum StatusDotColor { + blue, // idle / waiting + orange, // submitting + green, // success + red, // error (invalid code / submit failure / duplicate) + yellow, // network error + amber, // warning (duplicate box number) +} + +/// Reusable bottom status bar with a colored dot and text. +class StatusBar extends StatelessWidget { + final StatusDotColor dotColor; + final String text; + + const StatusBar({super.key, required this.dotColor, required this.text}); + + Color _resolveDotColor(BuildContext context) { + return switch (dotColor) { + StatusDotColor.blue => Colors.blue, + StatusDotColor.orange => Colors.orange, + StatusDotColor.green => Colors.green, + StatusDotColor.red => Colors.red, + StatusDotColor.yellow => Colors.yellow.shade700, + StatusDotColor.amber => Colors.amber, + }; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return 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: _resolveDotColor(context), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: const TextStyle(fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +}