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:
Misaka
2026-05-11 20:13:03 +08:00
parent 28006f19a5
commit 230e6ee07a
11 changed files with 596 additions and 7 deletions

View File

@@ -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,
),
],
),
],
);
}
}