- Save API URL and sound paths in a single load-modify-write cycle to prevent race conditions that wiped previously saved config - Strip trailing slashes from API URL to avoid double-slash 404 errors - Make AppConfigService.saveConfig public for batch config writes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 lines
10 KiB
Dart
346 lines
10 KiB
Dart
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});
|
|
|
|
@override
|
|
State<SettingsPage> createState() => _SettingsPageState();
|
|
}
|
|
|
|
class _SettingsPageState extends State<SettingsPage> {
|
|
final _controller = TextEditingController();
|
|
final _apiService = ApiService();
|
|
final _soundService = SoundService();
|
|
bool _saving = false;
|
|
bool _testing = false;
|
|
|
|
String? _successPath;
|
|
String? _failurePath;
|
|
String? _beepPath;
|
|
String? _errorPath;
|
|
String? _alertPath;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadUrl();
|
|
_loadSoundPaths();
|
|
}
|
|
|
|
Future<void> _loadUrl() async {
|
|
final configService = AppConfigService();
|
|
final url = await configService.getString('api_url') ?? '';
|
|
_controller.text = url;
|
|
}
|
|
|
|
Future<void> _loadSoundPaths() async {
|
|
final results = await Future.wait([
|
|
_soundService.getSuccessPath(),
|
|
_soundService.getFailurePath(),
|
|
_soundService.getBeepPath(),
|
|
_soundService.getErrorPath(),
|
|
_soundService.getAlertPath(),
|
|
]);
|
|
if (mounted) {
|
|
setState(() {
|
|
_successPath = results[0];
|
|
_failurePath = results[1];
|
|
_beepPath = results[2];
|
|
_errorPath = results[3];
|
|
_alertPath = results[4];
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _saveUrl() async {
|
|
final url = _controller.text.trim().replaceAll(RegExp(r'/+$'), '');
|
|
if (url.isEmpty) {
|
|
_showSnackBar('请输入 API 地址', isError: true);
|
|
return;
|
|
}
|
|
setState(() => _saving = true);
|
|
// Load config once, apply all changes, then save once to avoid race conditions
|
|
final configService = AppConfigService();
|
|
final config = await configService.loadConfig();
|
|
config['api_url'] = url;
|
|
if (_successPath != null) config['sound_success'] = _successPath;
|
|
else config.remove('sound_success');
|
|
if (_failurePath != null) config['sound_failure'] = _failurePath;
|
|
else config.remove('sound_failure');
|
|
if (_beepPath != null) config['sound_beep'] = _beepPath;
|
|
else config.remove('sound_beep');
|
|
if (_errorPath != null) config['sound_error'] = _errorPath;
|
|
else config.remove('sound_error');
|
|
if (_alertPath != null) config['sound_alert'] = _alertPath;
|
|
else config.remove('sound_alert');
|
|
await configService.saveConfig(config);
|
|
setState(() => _saving = false);
|
|
if (mounted) {
|
|
_showSnackBar('设置已保存');
|
|
}
|
|
}
|
|
|
|
Future<void> _testConnection() async {
|
|
final url = _controller.text.trim();
|
|
if (url.isEmpty) {
|
|
_showSnackBar('请先输入 API 地址', isError: true);
|
|
return;
|
|
}
|
|
setState(() => _testing = true);
|
|
final ok = await _apiService.testConnection(url);
|
|
setState(() => _testing = false);
|
|
if (mounted) {
|
|
_showSnackBar(ok ? '连接成功' : '连接失败,请检查地址和网络', isError: !ok);
|
|
}
|
|
}
|
|
|
|
Future<void> _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!;
|
|
setState(() {
|
|
switch (key) {
|
|
case 'success':
|
|
_successPath = path;
|
|
case 'failure':
|
|
_failurePath = path;
|
|
case 'beep':
|
|
_beepPath = path;
|
|
case 'error':
|
|
_errorPath = path;
|
|
case 'alert':
|
|
_alertPath = path;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _clearSound(String key) async {
|
|
setState(() {
|
|
switch (key) {
|
|
case 'success':
|
|
_successPath = null;
|
|
case 'failure':
|
|
_failurePath = null;
|
|
case 'beep':
|
|
_beepPath = null;
|
|
case 'error':
|
|
_errorPath = null;
|
|
case 'alert':
|
|
_alertPath = 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,
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
_soundService.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('设置')),
|
|
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(
|
|
controller: _controller,
|
|
decoration: const InputDecoration(
|
|
hintText: 'http://192.168.1.100:8000',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.url,
|
|
),
|
|
const SizedBox(height: 20),
|
|
ElevatedButton.icon(
|
|
onPressed: _saving ? null : _saveUrl,
|
|
icon: _saving
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.save),
|
|
label: const Text('保存设置'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: _testing ? null : _testConnection,
|
|
icon: _testing
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: 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: _beepPath,
|
|
onPick: () => _pickSound('beep'),
|
|
onPreview: _beepPath != null
|
|
? () => _previewSound(_beepPath!)
|
|
: null,
|
|
onClear: _beepPath != null ? () => _clearSound('beep') : null,
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
// 成功铃声
|
|
_buildSoundRow(
|
|
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('failure'),
|
|
onPreview: _failurePath != null
|
|
? () => _previewSound(_failurePath!)
|
|
: 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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|