feat: add sound and vibration feedback for registration actions
Integrate audioplayers, vibration, and file_picker packages to provide audio/haptic feedback on registration success/failure. Add sound settings UI in SettingsPage for customizing notification sounds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:vibration/vibration.dart';
|
||||
import 'package:shared_preferences/shared_preferences.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/pages/settings_page.dart';
|
||||
|
||||
class RegistrationPage extends StatefulWidget {
|
||||
@@ -15,6 +18,8 @@ class RegistrationPage extends StatefulWidget {
|
||||
class _RegistrationPageState extends State<RegistrationPage> {
|
||||
final _scannerService = ScannerService();
|
||||
final _apiService = ApiService();
|
||||
final _soundService = SoundService();
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
final _zongpaiNos = <String>[];
|
||||
String? _locationCode;
|
||||
@@ -30,6 +35,23 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scannerService.scanResults.listen(_onScan);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
_soundService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onKeyEvent(KeyEvent event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
_submit();
|
||||
}
|
||||
}
|
||||
|
||||
void _onScan(ScanResult result) {
|
||||
@@ -137,6 +159,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
if (result.success) {
|
||||
_vibrateSuccess();
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
if (!_isLocked) {
|
||||
@@ -151,8 +174,10 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
if (mounted) setState(() => _successMessage = null);
|
||||
});
|
||||
} else if (result.isDuplicate) {
|
||||
_vibrateError();
|
||||
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
|
||||
} else {
|
||||
_vibrateError();
|
||||
_showFeedback(result.errorMessage ?? '提交失败', isError: true);
|
||||
}
|
||||
}
|
||||
@@ -193,12 +218,24 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
});
|
||||
|
||||
if (failed.isEmpty) {
|
||||
_vibrateSuccess();
|
||||
_showFeedback('批量上架成功($successCount 条)', isError: false);
|
||||
} else {
|
||||
_vibrateError();
|
||||
_showFeedback('成功 $successCount 条,失败 ${failed.length} 条', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -266,8 +303,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
return KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: _onKeyEvent,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('上架登记'),
|
||||
actions: [
|
||||
Padding(
|
||||
@@ -551,7 +591,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
: Text(
|
||||
_isLocked && _zongpaiNos.length > 1
|
||||
? '批量上架(${_zongpaiNos.length} 条)'
|
||||
: '确 认 上 架',
|
||||
: '确认上架(P1)',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
@@ -593,6 +633,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
import 'package:pad_scanner/services/sound_service.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
@@ -12,13 +14,18 @@ class SettingsPage extends StatefulWidget {
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
final _controller = TextEditingController();
|
||||
final _apiService = ApiService();
|
||||
final _soundService = SoundService();
|
||||
bool _saving = false;
|
||||
bool _testing = false;
|
||||
|
||||
String? _successPath;
|
||||
String? _failurePath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUrl();
|
||||
_loadSoundPaths();
|
||||
}
|
||||
|
||||
Future<void> _loadUrl() async {
|
||||
@@ -27,6 +34,17 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_controller.text = url;
|
||||
}
|
||||
|
||||
Future<void> _loadSoundPaths() async {
|
||||
final s = await _soundService.getSuccessPath();
|
||||
final f = await _soundService.getFailurePath();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_successPath = s;
|
||||
_failurePath = f;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveUrl() async {
|
||||
final url = _controller.text.trim();
|
||||
if (url.isEmpty) {
|
||||
@@ -59,11 +77,57 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickSound(bool isSuccess) 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);
|
||||
}
|
||||
setState(() {
|
||||
if (isSuccess) {
|
||||
_successPath = path;
|
||||
} else {
|
||||
_failurePath = path;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearSound(bool isSuccess) async {
|
||||
if (isSuccess) {
|
||||
await _soundService.setSuccessPath(null);
|
||||
} else {
|
||||
await _soundService.setFailurePath(null);
|
||||
}
|
||||
setState(() {
|
||||
if (isSuccess) {
|
||||
_successPath = null;
|
||||
} else {
|
||||
_failurePath = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _previewSound(String path) async {
|
||||
await _soundService.play(path);
|
||||
}
|
||||
|
||||
String _fileName(String? path) {
|
||||
if (path == null) return '未设置';
|
||||
return path.split('/').last;
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red.shade700 : Colors.green.shade700,
|
||||
backgroundColor:
|
||||
isError ? Colors.red.shade700 : Colors.green.shade700,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -72,6 +136,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_soundService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -79,11 +144,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('设置')),
|
||||
body: Padding(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ---- API 地址 ----
|
||||
const Text('API 服务器地址', style: TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
@@ -118,9 +184,99 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
: const Icon(Icons.wifi),
|
||||
label: const Text('测试连接'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ---- 铃声设置 ----
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
const Text('铃声设置',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 上架成功铃声
|
||||
_buildSoundRow(
|
||||
label: '上架成功铃声',
|
||||
path: _successPath,
|
||||
onPick: () => _pickSound(true),
|
||||
onPreview: _successPath != null
|
||||
? () => _previewSound(_successPath!)
|
||||
: null,
|
||||
onClear: _successPath != null ? () => _clearSound(true) : null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 上架失败铃声
|
||||
_buildSoundRow(
|
||||
label: '上架失败铃声',
|
||||
path: _failurePath,
|
||||
onPick: () => _pickSound(false),
|
||||
onPreview: _failurePath != null
|
||||
? () => _previewSound(_failurePath!)
|
||||
: null,
|
||||
onClear:
|
||||
_failurePath != null ? () => _clearSound(false) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSoundRow({
|
||||
required String label,
|
||||
required String? path,
|
||||
required VoidCallback onPick,
|
||||
required VoidCallback? onPreview,
|
||||
required VoidCallback? onClear,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_fileName(path),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: path != null ? Colors.black87 : Colors.grey,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.folder_open),
|
||||
tooltip: '选择文件',
|
||||
onPressed: onPick,
|
||||
),
|
||||
if (onPreview != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
tooltip: '试听',
|
||||
onPressed: onPreview,
|
||||
),
|
||||
if (onClear != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 20),
|
||||
tooltip: '清除',
|
||||
onPressed: onClear,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
59
lib/services/sound_service.dart
Normal file
59
lib/services/sound_service.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SoundService {
|
||||
static const _keySuccess = 'sound_success';
|
||||
static const _keyFailure = 'sound_failure';
|
||||
|
||||
final _player = AudioPlayer();
|
||||
|
||||
Future<String?> getSuccessPath() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keySuccess);
|
||||
}
|
||||
|
||||
Future<String?> getFailurePath() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyFailure);
|
||||
}
|
||||
|
||||
Future<void> setSuccessPath(String? path) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (path != null) {
|
||||
await prefs.setString(_keySuccess, path);
|
||||
} else {
|
||||
await prefs.remove(_keySuccess);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setFailurePath(String? path) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (path != null) {
|
||||
await prefs.setString(_keyFailure, path);
|
||||
} else {
|
||||
await prefs.remove(_keyFailure);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playSuccess() async {
|
||||
final path = await getSuccessPath();
|
||||
if (path != null) {
|
||||
await _player.play(DeviceFileSource(path));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playFailure() async {
|
||||
final path = await getFailurePath();
|
||||
if (path != null) {
|
||||
await _player.play(DeviceFileSource(path));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> play(String path) async {
|
||||
await _player.play(DeviceFileSource(path));
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _player.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user