在使用過程中,常常會出現各種無意的誤操作或系統權限問題,例如:
今天我們就來寫一套完整的 AppException 體系與 UI 防呆機制!
在 lib/core/errors/建立 app_exception.dart
/// 全域統一例外處理模型
sealed class AppException implements Exception {
final String message;
final String code;
AppException(this.message, this.code);
@override
String toString() => '[$code] $message';
}
/// 權限類例外 (麥克風未授權)
class PermissionDeniedException extends AppException {
PermissionDeniedException([String message = '未取得麥克風使用權限,請至手機設定開啟'])
: super(message, 'PERMISSION_DENIED');
}
/// API 額度或密鑰例外 (401 / 429)
class ApiKeyOrQuotaException extends AppException {
ApiKeyOrQuotaException([String message = 'API 金鑰無效或額度已耗盡'])
: super(message, 'API_QUOTA_EXCEEDED');
}
/// 音訊過短例外
class AudioTooShortException extends AppException {
AudioTooShortException([String message = '錄音時間過短,請再試一次'])
: super(message, 'AUDIO_TOO_SHORT');
}
/// 網路連線例外
class NetworkException extends AppException {
NetworkException([String message = '網路連線異常,請檢查您的網路狀態'])
: super(message, 'NETWORK_ERROR');
}
建立 lib/core/utils/debouncer.dart,避免使用者在按下「一鍵生成摘要」或「啟動錄音」時連續猛點
import 'dart:async';
class Debouncer {
final Duration delay;
Timer? _timer;
Debouncer({this.delay = const Duration(milliseconds: 500)});
/// 在指定的 delay 時間內重複觸發只會執行最後一次
void run(Function action) {
_timer?.cancel();
_timer = Timer(delay, () => action());
}
void dispose() {
_timer?.cancel();
}
}
AudioRecorderService 加入權限與長度防呆import 'package:permission_handler/permission_handler.dart';
import '../../core/errors/app_exception.dart';
class AudioRecorderService {
DateTime? _recordingStartTime;
/// 啟動錄音(帶有權限防呆)
Future<void> startChunkedRecording(Function(Uint8List chunk) onAudioChunkReceived) async {
// 1. 檢查並請求麥克風權限
final status = await Permission.microphone.request();
if (status.isDenied || status.isPermanentlyDenied) {
throw PermissionDeniedException(); // 拋出友善的自訂 Exception
}
_recordingStartTime = DateTime.now();
// ... 原本的啟動錄音邏輯 ...
}
/// 停止錄音(帶有時間長度防呆)
Future<void> stopRecording() async {
if (_recordingStartTime != null) {
final duration = DateTime.now().difference(_recordingStartTime!);
// 2. 錄音少於 0.8 秒視為誤觸,自動攔截
if (duration.inMilliseconds < 800) {
_recordingStartTime = null;
throw AudioTooShortException();
}
}
_recordingStartTime = null;
// ... 原本的停止錄音邏輯 ...
}
}
更新 multi_lang_page.dart 的事件處理邏輯,優化防呆與 Exception 攔截
final Debouncer _btnDebouncer = Debouncer(delay: Duration(milliseconds: 600));
// 啟動錄音按鈕觸發事件
void _handleToggleRecording() async {
// 1. 使用 Debouncer 避免連點
_btnDebouncer.run(() async {
try {
if (_isRecording) {
await _audioRecorderService.stopRecording();
setState(() => _isRecording = false);
} else {
await _audioRecorderService.startChunkedRecording((chunk) {
_processChunk(chunk);
});
setState(() => _isRecording = true);
}
} on PermissionDeniedException catch (e) {
_showErrorSnackBar(e.message, isSettingsAction: true);
} on AudioTooShortException catch (e) {
_showErrorSnackBar(e.message);
} on ApiKeyOrQuotaException catch (e) {
_showErrorSnackBar('${e.message}');
} catch (e) {
_showErrorSnackBar('發生未知錯誤,請稍後再試');
}
});
}
/// 友善的 SnackBar UI 顯示工具
void _showErrorSnackBar(String message, {bool isSettingsAction = false}) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.redAccent.shade700,
behavior: SnackBarBehavior.floating,
action: isSettingsAction
? SnackBarAction(
label: '前往設定',
textColor: Colors.amber,
onPressed: () => openAppSettings(),
)
: null,
),
);
}