Compare commits

..

4 Commits

Author SHA1 Message Date
Misaka
2937efdefd chore: ignore android/build directory
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 20:43:13 +08:00
Misaka
732befb2a0 merge: integrate boxing module from remote with sound/vibration feedback
Merge remote boxing module (one2one, one2many, many2one modes) with
local sound and vibration feedback feature. Migrate SharedPreferences
to AppConfigService across all services including SoundService.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 20:18:47 +08:00
Misaka
230e6ee07a 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>
2026-05-11 20:13:03 +08:00
Misaka
28006f19a5 docs: add sound settings design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 20:50:35 +08:00
12 changed files with 508 additions and 14 deletions

2
.gitignore vendored
View File

@@ -32,6 +32,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
android/build/
# Symbolication related
app.*.symbols
@@ -43,6 +44,7 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
/android/build/
# AI Agents
.claude/

View File

@@ -0,0 +1,38 @@
# 铃声配置功能设计
## 概述
在设置页新增铃声配置入口,用户可以为"上架成功"和"上架失败"分别指定本地音频文件作为提示铃声。
## 依赖
- `file_picker` — 从设备选择音频文件(支持 mp3/wav/ogg/aac/m4a/flac
- `audioplayers` — 播放本地音频文件
## 数据存储
使用 `SharedPreferences`
- `sound_success` — 上架成功铃声文件路径
- `sound_failure` — 上架失败铃声文件路径
## UI 设计
设置页新增"铃声设置"分区API 地址下方:
```
铃声设置
─────────────────────────────
上架成功铃声
[当前文件名或"未设置"] [选择] [试听] [清除]
上架失败铃声
[当前文件名或"未设置"] [选择] [试听] [清除]
```
## 音频播放逻辑
- 封装为 `SoundService`,统一管理播放
- 通过 `DeviceFileSource` 播放本地文件
- 上架成功/失败时检查是否配置了铃声,有则播放
- 保留现有震动反馈,铃声与震动并行
## 涉及文件
1. `pubspec.yaml` — 添加依赖
2. `lib/services/sound_service.dart` — 新建,封装音频播放
3. `lib/pages/settings_page.dart` — 新增铃声配置 UI
4. `lib/pages/registration_page.dart` — 集成铃声播放

View File

@@ -1,8 +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';
class RegistrationPage extends StatefulWidget {
const RegistrationPage({super.key});
@@ -14,6 +17,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;
@@ -29,6 +34,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) {
@@ -136,6 +158,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
setState(() => _isSubmitting = false);
if (result.success) {
_vibrateSuccess();
setState(() {
_zongpaiNos.remove(zongpaiNo);
if (!_isLocked) {
@@ -149,8 +172,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);
}
}
@@ -191,12 +216,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,7 +303,10 @@ class _RegistrationPageState extends State<RegistrationPage> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
return KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
child: Scaffold(
appBar: AppBar(
title: const Text('上架登记'),
actions: [
@@ -556,7 +596,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
: Text(
_isLocked && _zongpaiNos.length > 1
? '批量上架(${_zongpaiNos.length} 条)'
: ' 认 上 架',
: '认上架(P1)',
style: const TextStyle(fontSize: 18),
),
),
@@ -598,6 +638,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
),
],
),
),
);
}
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/services/app_config_service.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) {
@@ -56,11 +74,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),
),
);
@@ -69,6 +133,7 @@ class _SettingsPageState extends State<SettingsPage> {
@override
void dispose() {
_controller.dispose();
_soundService.dispose();
super.dispose();
}
@@ -76,11 +141,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(
@@ -115,9 +181,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,
),
],
),
],
);
}
}

View File

@@ -0,0 +1,58 @@
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';
final _player = AudioPlayer();
final _configService = AppConfigService();
Future<String?> getSuccessPath() async {
return _configService.getString(_keySuccess);
}
Future<String?> getFailurePath() async {
return _configService.getString(_keyFailure);
}
Future<void> setSuccessPath(String? path) async {
if (path != null) {
await _configService.setString(_keySuccess, path);
} else {
final config = await _configService.loadConfig();
config.remove(_keySuccess);
}
}
Future<void> setFailurePath(String? path) async {
if (path != null) {
await _configService.setString(_keyFailure, path);
} else {
final config = await _configService.loadConfig();
config.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();
}
}

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
}

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -5,6 +5,12 @@
import FlutterMacOS
import Foundation
import audioplayers_darwin
import device_info_plus
import file_picker
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
}

View File

@@ -13,10 +13,66 @@ packages:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.0"
version: "2.13.1"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70
url: "https://pub.dev"
source: hosted
version: "6.6.0"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91
url: "https://pub.dev"
source: hosted
version: "6.4.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9
url: "https://pub.dev"
source: hosted
version: "5.2.0"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734
url: "https://pub.dev"
source: hosted
version: "4.3.0"
boolean_selector:
dependency: transitive
description:
@@ -57,6 +113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@@ -69,10 +133,34 @@ packages:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.8"
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
device_info_plus:
dependency: transitive
description:
name: device_info_plus
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
url: "https://pub.dev"
source: hosted
version: "12.4.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
fake_async:
dependency: transitive
description:
@@ -97,6 +185,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "27c58ff4ec17d8e05b8b27fa66175bf5786b9ff9bd94ca418a571c7f6771b27b"
url: "https://pub.dev"
source: hosted
version: "11.0.0"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -110,11 +214,24 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
@@ -307,6 +424,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -348,10 +473,10 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
version: "1.10.2"
stack_trace:
dependency: transitive
description:
@@ -376,6 +501,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
url: "https://pub.dev"
source: hosted
version: "3.4.0+1"
term_glyph:
dependency: transitive
description:
@@ -400,6 +533,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -408,14 +549,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vibration:
dependency: "direct main"
description:
name: vibration
sha256: "9bb06614c69260f8bd11c80fe01ed7988905cf00e3417d656c2647e41f261d87"
url: "https://pub.dev"
source: hosted
version: "3.1.8"
vibration_platform_interface:
dependency: transitive
description:
name: vibration_platform_interface
sha256: "258c273268f8aa40c88d29741137c536874a738779b92ddb8aa32ed093721ec5"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.0.0"
version: "15.2.0"
web:
dependency: transitive
description:
@@ -424,6 +581,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
xdg_directories:
dependency: transitive
description:
@@ -432,6 +605,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
@@ -441,5 +622,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.3 <4.0.0"
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"

View File

@@ -36,6 +36,9 @@ dependencies:
cupertino_icons: ^1.0.8
http: ^1.2.0
path_provider: ^2.1.1
vibration: ^3.1.8
file_picker: 11.0.0
audioplayers: ^6.6.0
dev_dependencies:
flutter_test:

View File

@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
}

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST