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

@@ -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<void> 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<void> 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<void> dispose() async {
await _soundService.dispose();
}
}

View File

@@ -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<String?> getSuccessPath() async {
return _configService.getString(_keySuccess);
}
Timer? _alertTimer;
Future<String?> getFailurePath() async {
return _configService.getString(_keyFailure);
}
// --- Path getters ---
Future<void> setSuccessPath(String? path) async {
Future<String?> getSuccessPath() =>
_configService.getString(_keySuccess);
Future<String?> getFailurePath() =>
_configService.getString(_keyFailure);
Future<String?> getBeepPath() =>
_configService.getString(_keyBeep);
Future<String?> getErrorPath() =>
_configService.getString(_keyError);
Future<String?> getAlertPath() =>
_configService.getString(_keyAlert);
// --- Path setters ---
Future<void> setSuccessPath(String? path) =>
_setConfigPath(_keySuccess, path);
Future<void> setFailurePath(String? path) =>
_setConfigPath(_keyFailure, path);
Future<void> setBeepPath(String? path) =>
_setConfigPath(_keyBeep, path);
Future<void> setErrorPath(String? path) =>
_setConfigPath(_keyError, path);
Future<void> setAlertPath(String? path) =>
_setConfigPath(_keyAlert, path);
Future<void> _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<void> setFailurePath(String? path) async {
if (path != null) {
await _configService.setString(_keyFailure, path);
} else {
final config = await _configService.loadConfig();
config.remove(_keyFailure);
}
}
// --- Playback methods ---
Future<void> playSuccess() async {
final path = await getSuccessPath();
@@ -48,11 +74,55 @@ class SoundService {
}
}
Future<void> playBeep() async {
final path = await getBeepPath();
if (path != null) {
await _player.play(DeviceFileSource(path));
}
}
Future<void> 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<void> 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<void> play(String path) async {
await _player.play(DeviceFileSource(path));
}
Future<void> dispose() async {
_alertTimer?.cancel();
await _player.dispose();
await _alertPlayer.dispose();
}
}