diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f..3ddae0d 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,12 +1,8 @@ - - - - - + android:gravity="fill" + android:src="@drawable/startup_background" /> + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..3ddae0d 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,8 @@ - - - - - + android:gravity="fill" + android:src="@drawable/startup_background" /> + diff --git a/assets/images/image_dialog_bg.png b/assets/images/image_dialog_bg.png new file mode 100644 index 0000000..1096831 Binary files /dev/null and b/assets/images/image_dialog_bg.png differ diff --git a/assets/images/image_logo.png b/assets/images/image_logo.png new file mode 100644 index 0000000..f543645 Binary files /dev/null and b/assets/images/image_logo.png differ diff --git a/lib/app/app.dart b/lib/app/app.dart index 271fb18..fe0d984 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -3,12 +3,12 @@ import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; import 'package:recording_tool/app/config/app_config.dart'; import 'package:recording_tool/app/router/app_navigator.dart'; import 'package:recording_tool/app/theme/app_theme.dart'; -import 'package:recording_tool/features/recording/recording_page.dart'; +import 'package:recording_tool/features/recording/pages/page_record.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; -import 'package:pull_to_refresh/pull_to_refresh.dart'; class FlutterTemplateApp extends ConsumerStatefulWidget { const FlutterTemplateApp({super.key}); diff --git a/lib/features/recording/model/model_recording.dart b/lib/features/recording/model/model_recording.dart index e137f26..8d29388 100644 --- a/lib/features/recording/model/model_recording.dart +++ b/lib/features/recording/model/model_recording.dart @@ -1,4 +1,5 @@ import 'package:recording_tool/features/recording/model/model_clipboard.dart'; +import 'package:recording_tool/features/recording/model/model_recording_session.dart'; class RecordingModel { /// 剪切板内容 @@ -7,11 +8,17 @@ class RecordingModel { /// 剪切板是否包含有效的小程序录制信息 final bool hasValidClipboardInfo; + /// 录制会话状态 + final RecordingSessionState session; + RecordingModel({ required this.clipboardRecordingModel, this.hasValidClipboardInfo = false, + this.session = const RecordingSessionState(), }); + bool get isRecording => session.isRecording; + factory RecordingModel.fromJson(Map json) { return RecordingModel( clipboardRecordingModel: ClipboardRecordingModel.fromJson( @@ -32,12 +39,14 @@ class RecordingModel { RecordingModel copyWith({ ClipboardRecordingModel? clipboardRecordingModel, bool? hasValidClipboardInfo, + RecordingSessionState? session, }) { return RecordingModel( clipboardRecordingModel: clipboardRecordingModel ?? this.clipboardRecordingModel, hasValidClipboardInfo: hasValidClipboardInfo ?? this.hasValidClipboardInfo, + session: session ?? this.session, ); } } diff --git a/lib/features/recording/model/model_recording_session.dart b/lib/features/recording/model/model_recording_session.dart new file mode 100644 index 0000000..8284498 --- /dev/null +++ b/lib/features/recording/model/model_recording_session.dart @@ -0,0 +1,82 @@ +import 'package:recording_tool/features/recording/recording_platform.dart'; + +/// 录制会话状态(相机预览、权限、录制进度等)。 +class RecordingSessionState { + const RecordingSessionState({ + this.status = const RecordingStatus(state: RecordingState.idle), + this.isTouchLocked = true, + this.isPreviewReady = false, + this.isStartingRecording = false, + this.hasDndAccess = false, + this.isBatteryOptimizedIgnored = true, + this.notificationsGranted = true, + this.isMicrophoneGranted = false, + this.lastOutputPath, + this.lastSavedDisplayName, + this.errorMessage, + this.permissionWarning, + this.gallerySaveFailed = false, + }); + + final RecordingStatus status; + final bool isTouchLocked; + final bool isPreviewReady; + final bool isStartingRecording; + final bool hasDndAccess; + final bool isBatteryOptimizedIgnored; + final bool notificationsGranted; + final bool isMicrophoneGranted; + final String? lastOutputPath; + final String? lastSavedDisplayName; + final String? errorMessage; + final String? permissionWarning; + final bool gallerySaveFailed; + + bool get isRecording => status.isRecording; + + String get elapsedLabel { + final totalSeconds = status.elapsedMillis ~/ 1000; + final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0'); + final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + RecordingSessionState copyWith({ + RecordingStatus? status, + bool? isTouchLocked, + bool? isPreviewReady, + bool? isStartingRecording, + bool? hasDndAccess, + bool? isBatteryOptimizedIgnored, + bool? notificationsGranted, + bool? isMicrophoneGranted, + String? lastOutputPath, + String? lastSavedDisplayName, + String? errorMessage, + String? permissionWarning, + bool? gallerySaveFailed, + bool clearPermissionWarning = false, + bool clearLastSaved = false, + }) { + return RecordingSessionState( + status: status ?? this.status, + isTouchLocked: isTouchLocked ?? this.isTouchLocked, + isPreviewReady: isPreviewReady ?? this.isPreviewReady, + isStartingRecording: isStartingRecording ?? this.isStartingRecording, + hasDndAccess: hasDndAccess ?? this.hasDndAccess, + isBatteryOptimizedIgnored: + isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored, + notificationsGranted: notificationsGranted ?? this.notificationsGranted, + isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted, + lastOutputPath: lastOutputPath ?? this.lastOutputPath, + lastSavedDisplayName: clearLastSaved + ? null + : (lastSavedDisplayName ?? this.lastSavedDisplayName), + errorMessage: errorMessage, + permissionWarning: clearPermissionWarning + ? null + : (permissionWarning ?? this.permissionWarning), + gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed, + ); + } +} diff --git a/lib/features/recording/recording_page.dart b/lib/features/recording/pages/page_record.dart similarity index 89% rename from lib/features/recording/recording_page.dart rename to lib/features/recording/pages/page_record.dart index b266fc1..53160c1 100644 --- a/lib/features/recording/recording_page.dart +++ b/lib/features/recording/pages/page_record.dart @@ -10,19 +10,21 @@ import 'package:recording_tool/core/platform/app_platform_info.dart'; import 'package:recording_tool/core/platform/device_health_checker.dart'; import 'package:recording_tool/core/utils/date_time_formatter.dart'; import 'package:recording_tool/features/recording/model/model_recording.dart'; +import 'package:recording_tool/features/recording/model/model_recording_session.dart'; import 'package:recording_tool/features/recording/recording_display_name.dart'; import 'package:recording_tool/features/recording/recording_platform.dart'; -import 'package:recording_tool/features/recording/recording_session_controller.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; import 'package:recording_tool/features/recording/widgets/camera_preview_widget.dart'; import 'package:recording_tool/features/recording/widgets/recording_saved_dialog.dart'; import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart'; import 'package:recording_tool/shared/widgets/widgets.dart'; +/// 录制页入口 class RecordingPage extends ConsumerStatefulWidget { const RecordingPage({super.key}); @override + /// 创建页面状态 ConsumerState createState() => _RecordingPageState(); } @@ -30,11 +32,13 @@ class _RecordingPageState extends ConsumerState { var _immersiveApplied = false; @override + /// 首帧后初始化录制流程 void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap()); } + /// 检查设备健康状态并弹窗提示 Future _checkAndShowDeviceHealthAlerts() async { final snapshot = await AppPlatformInfo.deviceHealth(); if (!mounted) return; @@ -45,6 +49,7 @@ class _RecordingPageState extends ConsumerState { await AppDialog.deviceHealthAlert(context, lines: lines); } + /// 页面启动:健康检查、读剪贴板、进入录制模式、准备相机会话 Future _bootstrap() async { await _checkAndShowDeviceHealthAlerts(); if (!mounted) return; @@ -60,11 +65,10 @@ class _RecordingPageState extends ConsumerState { // Allow PlatformView to attach before binding CameraX preview. await Future.delayed(const Duration(milliseconds: 400)); if (!mounted) return; - await ref - .read(recordingSessionControllerProvider.notifier) - .prepareSession(); + await ref.read(recordingViewModelProvider.notifier).prepareSession(); } + /// Android 进入沉浸式全屏 Future _enterRecordingMode() async { if (!Platform.isAndroid) return; await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); @@ -72,6 +76,7 @@ class _RecordingPageState extends ConsumerState { _immersiveApplied = true; } + /// 解析保存成功弹窗的标题文案 String _savedDialogSessionTitle( RecordingModel recordingInfo, String? savedName, @@ -87,6 +92,7 @@ class _RecordingPageState extends ConsumerState { return '录制完成'; } + /// 无选手信息时弹窗提示 Future _showNoPlayerInfoDialog() { return showDialog( context: context, @@ -104,6 +110,7 @@ class _RecordingPageState extends ConsumerState { ); } + /// 点击开始录制:校验剪贴板与健康状态 Future _onStartRecording() async { final recordingInfo = ref.read(recordingViewModelProvider); if (!recordingInfo.hasClipboardFilename) { @@ -112,23 +119,24 @@ class _RecordingPageState extends ConsumerState { } await _checkAndShowDeviceHealthAlerts(); if (!mounted) return; - await ref.read(recordingSessionControllerProvider.notifier).startRecording(); + await ref.read(recordingViewModelProvider.notifier).startRecording(); } + /// 清空剪贴板信息,准备新一轮录制 void _clearClipboardForNewRound() { - ref.read(recordingViewModelProvider.notifier).resetClipboardInfo(); - ref - .read(recordingSessionControllerProvider.notifier) - .clearSavedRecordingResult(); + final notifier = ref.read(recordingViewModelProvider.notifier); + notifier.resetClipboardInfo(); + notifier.clearSavedRecordingResult(); } + /// 保存成功后按需弹出完成对话框 Future _showRecordingSavedDialogIfNeeded() async { - final session = ref.read(recordingSessionControllerProvider); + final recordingInfo = ref.read(recordingViewModelProvider); + final session = recordingInfo.session; if (session.lastSavedDisplayName == null || session.gallerySaveFailed) { return; } - final recordingInfo = ref.read(recordingViewModelProvider); final sessionTitle = _savedDialogSessionTitle( recordingInfo, session.lastSavedDisplayName, @@ -139,16 +147,17 @@ class _RecordingPageState extends ConsumerState { sessionTitle: sessionTitle, onContinueRound: () { ref - .read(recordingSessionControllerProvider.notifier) + .read(recordingViewModelProvider.notifier) .clearSavedRecordingResult(); }, onRecordNewRound: _clearClipboardForNewRound, ); } + /// 退出沉浸式并释放录制会话 Future _exitRecordingMode() async { if (!_immersiveApplied) return; - await ref.read(recordingSessionControllerProvider.notifier).teardown(); + await ref.read(recordingViewModelProvider.notifier).teardown(); await SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, overlays: SystemUiOverlay.values, @@ -158,6 +167,7 @@ class _RecordingPageState extends ConsumerState { } @override + /// 页面销毁时恢复系统 UI void dispose() { if (_immersiveApplied) { SystemChrome.setEnabledSystemUIMode( @@ -170,10 +180,11 @@ class _RecordingPageState extends ConsumerState { } @override + /// 构建录制页 UI Widget build(BuildContext context) { - final state = ref.watch(recordingSessionControllerProvider); final recordingInfo = ref.watch(recordingViewModelProvider); - final controller = ref.read(recordingSessionControllerProvider.notifier); + final state = recordingInfo.session; + final viewModel = ref.read(recordingViewModelProvider.notifier); final clipboard = recordingInfo.clipboardRecordingModel; final showClipboardInfo = recordingInfo.hasValidClipboardInfo; @@ -199,7 +210,7 @@ class _RecordingPageState extends ConsumerState { if (state.isTouchLocked && state.isRecording) RecordingTouchLockOverlay( enabled: true, - onUnlocked: () => controller.setTouchLocked(false), + onUnlocked: () => viewModel.setTouchLocked(false), ), _RecordingHud( state: state, @@ -219,9 +230,9 @@ class _RecordingPageState extends ConsumerState { }, onStart: _onStartRecording, onStop: () async { - await controller.stopRecording(); + await viewModel.stopRecording(); if (!context.mounted) return; - final latest = ref.read(recordingSessionControllerProvider); + final latest = ref.read(recordingViewModelProvider).session; if (latest.gallerySaveFailed) { AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限'); return; @@ -229,15 +240,15 @@ class _RecordingPageState extends ConsumerState { await _showRecordingSavedDialogIfNeeded(); }, onOpenDnd: () async { - await controller.openDndSettings(); - await controller.refreshDndAccess(); + await viewModel.openDndSettings(); + await viewModel.refreshDndAccess(); }, onOpenBattery: () async { - await controller.openBatterySettings(); - await controller.refreshBatteryOptimization(); + await viewModel.openBatterySettings(); + await viewModel.refreshBatteryOptimization(); }, onToggleTouchLock: () { - controller.setTouchLocked(!state.isTouchLocked); + viewModel.setTouchLocked(!state.isTouchLocked); }, ), if (state.isStartingRecording) @@ -249,12 +260,14 @@ class _RecordingPageState extends ConsumerState { } } +/// 录制加载遮罩(相机启动/开始录制) class _RecordingLoadingOverlay extends StatelessWidget { const _RecordingLoadingOverlay({required this.message}); final String message; @override + /// 显示加载动画与提示文案 Widget build(BuildContext context) { return ColoredBox( color: Colors.black, @@ -281,6 +294,7 @@ class _RecordingLoadingOverlay extends StatelessWidget { } } +/// 录制页 HUD 层(赛事信息、控制按钮、状态提示) class _RecordingHud extends StatelessWidget { const _RecordingHud({ required this.state, @@ -310,14 +324,17 @@ class _RecordingHud extends StatelessWidget { final VoidCallback onOpenBattery; final VoidCallback onToggleTouchLock; + /// 叠加层文字样式 static TextStyle get _overlayTextStyle => TextStyle( color: Colors.white, shadows: [Shadow(color: Colors.black54, blurRadius: 6.r)], ); + /// 底部控制区左右占位宽度 static double get _controlSlotWidth => 48.r; @override + /// 构建 HUD 布局 Widget build(BuildContext context) { final showPasteEventInfo = eventTitle == null && !state.isRecording; @@ -541,6 +558,7 @@ class _RecordingHud extends StatelessWidget { } } +/// 权限与剪贴板相关设置提示条 class _SetupHints extends StatelessWidget { const _SetupHints({ required this.hasDndAccess, @@ -563,6 +581,7 @@ class _SetupHints extends StatelessWidget { final VoidCallback onOpenNotificationSettings; @override + /// 按需展示权限/剪贴板提示 Widget build(BuildContext context) { final showPermissionHints = !hasDndAccess || !isBatteryIgnored || !notificationsGranted; @@ -598,20 +617,24 @@ class _SetupHints extends StatelessWidget { } } +/// 显示剪贴板地址与实时时钟的提示芯片 class _ClipboardAddressClockChip extends StatefulWidget { const _ClipboardAddressClockChip({required this.address}); final String address; @override + /// 创建芯片状态 State<_ClipboardAddressClockChip> createState() => _ClipboardAddressClockChipState(); } -class _ClipboardAddressClockChipState extends State<_ClipboardAddressClockChip> { +class _ClipboardAddressClockChipState + extends State<_ClipboardAddressClockChip> { Timer? _clockTimer; @override + /// 启动每秒刷新时钟 void initState() { super.initState(); _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { @@ -620,12 +643,14 @@ class _ClipboardAddressClockChipState extends State<_ClipboardAddressClockChip> } @override + /// 取消定时器 void dispose() { _clockTimer?.cancel(); _clockTimer = null; super.dispose(); } + /// 拼接地址与当前时间文本 String _buildLabel() { final nowText = DateTimeFormatter.format( DateTime.now(), @@ -636,11 +661,13 @@ class _ClipboardAddressClockChipState extends State<_ClipboardAddressClockChip> } @override + /// 渲染时钟芯片 Widget build(BuildContext context) { return _HintChip(label: _buildLabel(), onTap: () {}); } } +/// 可点击的提示条组件 class _HintChip extends StatelessWidget { const _HintChip({required this.label, required this.onTap}); @@ -648,6 +675,7 @@ class _HintChip extends StatelessWidget { final VoidCallback onTap; @override + /// 构建提示条 UI Widget build(BuildContext context) { return GestureDetector( onTap: onTap, diff --git a/lib/features/recording/recording_session_controller.dart b/lib/features/recording/recording_session_controller.dart deleted file mode 100644 index 91daaaf..0000000 --- a/lib/features/recording/recording_session_controller.dart +++ /dev/null @@ -1,330 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:recording_tool/core/permission/permission_service.dart'; -import 'package:recording_tool/core/utils/rate_limiter.dart'; -import 'package:recording_tool/features/recording/recording_display_name.dart'; -import 'package:recording_tool/features/recording/recording_platform.dart'; -import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; -import 'package:permission_handler/permission_handler.dart'; - -class RecordingSessionState { - const RecordingSessionState({ - this.status = const RecordingStatus(state: RecordingState.idle), - this.isTouchLocked = true, - this.isPreviewReady = false, - this.isStartingRecording = false, - this.hasDndAccess = false, - this.isBatteryOptimizedIgnored = true, - this.notificationsGranted = true, - this.isMicrophoneGranted = false, - this.lastOutputPath, - this.lastSavedDisplayName, - this.errorMessage, - this.permissionWarning, - this.gallerySaveFailed = false, - }); - - final RecordingStatus status; - final bool isTouchLocked; - final bool isPreviewReady; - final bool isStartingRecording; - final bool hasDndAccess; - final bool isBatteryOptimizedIgnored; - final bool notificationsGranted; - final bool isMicrophoneGranted; - final String? lastOutputPath; - final String? lastSavedDisplayName; - final String? errorMessage; - final String? permissionWarning; - final bool gallerySaveFailed; - - bool get isRecording => status.isRecording; - - String get elapsedLabel { - final totalSeconds = status.elapsedMillis ~/ 1000; - final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0'); - final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } - - RecordingSessionState copyWith({ - RecordingStatus? status, - bool? isTouchLocked, - bool? isPreviewReady, - bool? isStartingRecording, - bool? hasDndAccess, - bool? isBatteryOptimizedIgnored, - bool? notificationsGranted, - bool? isMicrophoneGranted, - String? lastOutputPath, - String? lastSavedDisplayName, - String? errorMessage, - String? permissionWarning, - bool? gallerySaveFailed, - bool clearPermissionWarning = false, - bool clearLastSaved = false, - }) { - return RecordingSessionState( - status: status ?? this.status, - isTouchLocked: isTouchLocked ?? this.isTouchLocked, - isPreviewReady: isPreviewReady ?? this.isPreviewReady, - isStartingRecording: isStartingRecording ?? this.isStartingRecording, - hasDndAccess: hasDndAccess ?? this.hasDndAccess, - isBatteryOptimizedIgnored: - isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored, - notificationsGranted: notificationsGranted ?? this.notificationsGranted, - isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted, - lastOutputPath: lastOutputPath ?? this.lastOutputPath, - lastSavedDisplayName: clearLastSaved - ? null - : (lastSavedDisplayName ?? this.lastSavedDisplayName), - errorMessage: errorMessage, - permissionWarning: clearPermissionWarning - ? null - : (permissionWarning ?? this.permissionWarning), - gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed, - ); - } -} - -final recordingSessionControllerProvider = - NotifierProvider( - RecordingSessionController.new, - ); - -class RecordingSessionController extends Notifier { - static const Duration _recordingActionInterval = Duration(milliseconds: 300); - static const Object _startRecordingThrottleKey = 'recording.session.start'; - static const Object _stopRecordingThrottleKey = 'recording.session.stop'; - - StreamSubscription? _statusSubscription; - final _rateLimit = RateLimitHub(); - - @override - RecordingSessionState build() { - ref.onDispose(_dispose); - return const RecordingSessionState(); - } - - Future prepareSession() async { - if (!RecordingPlatform.isSupported) { - state = state.copyWith(errorMessage: '当前设备不支持录制'); - return; - } - - final permissions = await PermissionService.requestMissing([ - Permission.camera, - Permission.microphone, - if (Platform.isAndroid) Permission.notification, - ..._galleryPermissions(), - ]); - - final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; - if (!cameraGranted) { - state = state.copyWith(errorMessage: '需要相机权限才能录制'); - return; - } - - final microphoneGranted = - permissions[Permission.microphone]?.isGranted ?? false; - final notificationsGranted = Platform.isAndroid - ? (permissions[Permission.notification]?.isGranted ?? false) - : true; - - final warnings = []; - if (Platform.isAndroid && !notificationsGranted) { - warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制'); - } - if (!microphoneGranted) { - warnings.add('未授予麦克风权限,当前将以静音模式录制'); - } - if (!_isGalleryPermissionGranted(permissions)) { - warnings.add('未授予相册权限,录制结束后可能无法保存到相册'); - } - - final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); - final batteryIgnored = - await RecordingPlatform.isIgnoringBatteryOptimizations(); - - state = state.copyWith( - hasDndAccess: hasDnd, - isBatteryOptimizedIgnored: batteryIgnored, - isMicrophoneGranted: microphoneGranted, - notificationsGranted: notificationsGranted, - permissionWarning: warnings.isEmpty ? null : warnings.join('\n'), - errorMessage: null, - clearPermissionWarning: warnings.isEmpty, - ); - - await _listenStatus(); - try { - final status = await _initializePreviewWithRetry(); - state = state.copyWith( - status: status, - isPreviewReady: status.state == RecordingState.previewing, - errorMessage: status.state == RecordingState.previewing - ? null - : (status.message ?? '相机预览初始化失败'), - ); - } on PlatformException catch (error) { - state = state.copyWith( - isPreviewReady: false, - errorMessage: error.message ?? '相机预览初始化失败', - ); - } - } - - Future _initializePreviewWithRetry() async { - const maxAttempts = 8; - for (var attempt = 0; attempt < maxAttempts; attempt++) { - try { - return await RecordingPlatform.initializePreview(); - } on PlatformException catch (error) { - final shouldRetry = - error.code == 'NO_PREVIEW' && attempt < maxAttempts - 1; - if (!shouldRetry) { - rethrow; - } - await Future.delayed(Duration(milliseconds: 150 * (attempt + 1))); - } - } - throw StateError('initializePreview retry exhausted'); - } - - List _galleryPermissions() { - if (Platform.isIOS) { - return [Permission.photosAddOnly, Permission.photos]; - } - if (Platform.isAndroid) { - return [Permission.videos, Permission.storage]; - } - return const []; - } - - bool _isGalleryPermissionGranted(Map permissions) { - for (final permission in _galleryPermissions()) { - if (permissions[permission]?.isGranted ?? false) { - return true; - } - } - return _galleryPermissions().isEmpty; - } - - bool _tryAcquireRecordingAction(Object key) { - var executed = false; - _rateLimit.throttle( - key: key, - value: null, - duration: _recordingActionInterval, - options: const ThrottleOptions(leading: true, trailing: false), - onCallback: (_) => executed = true, - ); - return executed; - } - - Future startRecording({bool enableDoNotDisturb = true}) async { - if (!_tryAcquireRecordingAction(_startRecordingThrottleKey)) return; - - if (!state.isPreviewReady || - state.isRecording || - state.isStartingRecording) { - return; - } - - final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel; - final displayName = recordingFileNameForPlatform(clipboard.filename); - - state = state.copyWith(isStartingRecording: true, errorMessage: null); - try { - final result = await RecordingPlatform.startRecording( - enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess, - displayName: displayName, - ); - state = state.copyWith( - status: result.status, - lastOutputPath: result.outputPath, - isTouchLocked: true, - errorMessage: null, - gallerySaveFailed: false, - clearLastSaved: true, - ); - } on PlatformException catch (error) { - state = state.copyWith(errorMessage: error.message ?? '开始录制失败'); - } finally { - state = state.copyWith(isStartingRecording: false); - } - } - - Future stopRecording() async { - if (!_tryAcquireRecordingAction(_stopRecordingThrottleKey)) return; - - if (!state.isRecording) return; - - try { - final result = await RecordingPlatform.stopRecording(); - final galleryFailed = !result.gallerySaved; - final savedName = recordingFileNameForPlatform( - ref.read(recordingViewModelProvider).clipboardRecordingModel.filename, - ); - state = state.copyWith( - status: result.status, - lastOutputPath: result.outputPath ?? state.lastOutputPath, - lastSavedDisplayName: galleryFailed ? null : savedName, - errorMessage: galleryFailed - ? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限') - : null, - gallerySaveFailed: galleryFailed, - ); - } on PlatformException catch (error) { - state = state.copyWith(errorMessage: error.message ?? '停止录制失败'); - } - } - - void setTouchLocked(bool locked) { - state = state.copyWith(isTouchLocked: locked); - } - - void clearSavedRecordingResult() { - state = state.copyWith(clearLastSaved: true); - } - - Future openDndSettings() => - RecordingPlatform.openNotificationPolicySettings(); - - Future refreshDndAccess() async { - final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); - state = state.copyWith(hasDndAccess: hasDnd); - } - - Future openBatterySettings() => - RecordingPlatform.openBatteryOptimizationSettings(); - - Future refreshBatteryOptimization() async { - final ignored = await RecordingPlatform.isIgnoringBatteryOptimizations(); - state = state.copyWith(isBatteryOptimizedIgnored: ignored); - } - - Future teardown() async { - await RecordingPlatform.setImmersiveMode(enabled: false); - await RecordingPlatform.disableDoNotDisturb(); - await RecordingPlatform.disposePreview(); - await _statusSubscription?.cancel(); - _statusSubscription = null; - state = const RecordingSessionState(); - } - - Future _listenStatus() async { - await _statusSubscription?.cancel(); - _statusSubscription = RecordingPlatform.statusStream().listen((status) { - state = state.copyWith(status: status); - }); - } - - Future _dispose() async { - _rateLimit.clear(); - await _statusSubscription?.cancel(); - } -} diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart index b30a1dc..5e1fb0f 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -1,16 +1,23 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_riverpod/legacy.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:recording_tool/core/logging/app_logger.dart'; +import 'package:recording_tool/core/permission/permission_service.dart'; +import 'package:recording_tool/core/utils/rate_limiter.dart'; import 'package:recording_tool/features/recording/model/model_clipboard.dart'; import 'package:recording_tool/features/recording/model/model_recording.dart'; +import 'package:recording_tool/features/recording/model/model_recording_session.dart'; +import 'package:recording_tool/features/recording/recording_display_name.dart'; +import 'package:recording_tool/features/recording/recording_platform.dart'; final recordingViewModelProvider = - StateNotifierProvider((ref) { - return RecordingViewModel(ref); - }); + NotifierProvider( + RecordingViewModel.new, + ); /// 剪切板读取结果,供 UI 决定是否提示用户。 enum ClipboardReadResult { @@ -24,23 +31,33 @@ enum ClipboardReadResult { invalid, } -class RecordingViewModel extends StateNotifier { - RecordingViewModel(this.ref) - : super( - RecordingModel( - clipboardRecordingModel: ClipboardRecordingModel( - title: '', - address: '', - ), - ), - ); - final Ref ref; +class RecordingViewModel extends Notifier { + static const Duration _recordingActionInterval = Duration(milliseconds: 300); + static const Object _startRecordingThrottleKey = 'recording.session.start'; + static const Object _stopRecordingThrottleKey = 'recording.session.stop'; static final _defaultClipboard = ClipboardRecordingModel( title: '', address: '', ); + StreamSubscription? _statusSubscription; + final _rateLimit = RateLimitHub(); + + @override + RecordingModel build() { + ref.onDispose(_dispose); + return RecordingModel( + clipboardRecordingModel: _defaultClipboard, + ); + } + + void _updateSession( + RecordingSessionState Function(RecordingSessionState session) update, + ) { + state = state.copyWith(session: update(state.session)); + } + /// 从剪切板获取小程序复制的录制信息。 Future getClipboardContent() async { try { @@ -95,4 +112,241 @@ class RecordingViewModel extends StateNotifier { hasValidClipboardInfo: false, ); } + + Future prepareSession() async { + if (!RecordingPlatform.isSupported) { + _updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制')); + return; + } + + final permissions = await PermissionService.requestMissing([ + Permission.camera, + Permission.microphone, + if (Platform.isAndroid) Permission.notification, + ..._galleryPermissions(), + ]); + + final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; + if (!cameraGranted) { + _updateSession((s) => s.copyWith(errorMessage: '需要相机权限才能录制')); + return; + } + + final microphoneGranted = + permissions[Permission.microphone]?.isGranted ?? false; + final notificationsGranted = Platform.isAndroid + ? (permissions[Permission.notification]?.isGranted ?? false) + : true; + + final warnings = []; + if (Platform.isAndroid && !notificationsGranted) { + warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制'); + } + if (!microphoneGranted) { + warnings.add('未授予麦克风权限,当前将以静音模式录制'); + } + if (!_isGalleryPermissionGranted(permissions)) { + warnings.add('未授予相册权限,录制结束后可能无法保存到相册'); + } + + final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); + final batteryIgnored = + await RecordingPlatform.isIgnoringBatteryOptimizations(); + + _updateSession( + (s) => s.copyWith( + hasDndAccess: hasDnd, + isBatteryOptimizedIgnored: batteryIgnored, + isMicrophoneGranted: microphoneGranted, + notificationsGranted: notificationsGranted, + permissionWarning: warnings.isEmpty ? null : warnings.join('\n'), + errorMessage: null, + clearPermissionWarning: warnings.isEmpty, + ), + ); + + await _listenStatus(); + try { + final status = await _initializePreviewWithRetry(); + _updateSession( + (s) => s.copyWith( + status: status, + isPreviewReady: status.state == RecordingState.previewing, + errorMessage: status.state == RecordingState.previewing + ? null + : (status.message ?? '相机预览初始化失败'), + ), + ); + } on PlatformException catch (error) { + _updateSession( + (s) => s.copyWith( + isPreviewReady: false, + errorMessage: error.message ?? '相机预览初始化失败', + ), + ); + } + } + + Future _initializePreviewWithRetry() async { + const maxAttempts = 8; + for (var attempt = 0; attempt < maxAttempts; attempt++) { + try { + return await RecordingPlatform.initializePreview(); + } on PlatformException catch (error) { + final shouldRetry = + error.code == 'NO_PREVIEW' && attempt < maxAttempts - 1; + if (!shouldRetry) { + rethrow; + } + await Future.delayed(Duration(milliseconds: 150 * (attempt + 1))); + } + } + throw StateError('initializePreview retry exhausted'); + } + + List _galleryPermissions() { + if (Platform.isIOS) { + return [Permission.photosAddOnly, Permission.photos]; + } + if (Platform.isAndroid) { + return [Permission.videos, Permission.storage]; + } + return const []; + } + + bool _isGalleryPermissionGranted(Map permissions) { + for (final permission in _galleryPermissions()) { + if (permissions[permission]?.isGranted ?? false) { + return true; + } + } + return _galleryPermissions().isEmpty; + } + + bool _tryAcquireRecordingAction(Object key) { + var executed = false; + _rateLimit.throttle( + key: key, + value: null, + duration: _recordingActionInterval, + options: const ThrottleOptions(leading: true, trailing: false), + onCallback: (_) => executed = true, + ); + return executed; + } + + Future startRecording({bool enableDoNotDisturb = true}) async { + if (!_tryAcquireRecordingAction(_startRecordingThrottleKey)) return; + + final session = state.session; + if (!session.isPreviewReady || + session.isRecording || + session.isStartingRecording) { + return; + } + + final displayName = recordingFileNameForPlatform( + state.clipboardRecordingModel.filename, + ); + + _updateSession( + (s) => s.copyWith(isStartingRecording: true, errorMessage: null), + ); + try { + final result = await RecordingPlatform.startRecording( + enableDoNotDisturb: enableDoNotDisturb && state.session.hasDndAccess, + displayName: displayName, + ); + _updateSession( + (s) => s.copyWith( + status: result.status, + lastOutputPath: result.outputPath, + isTouchLocked: true, + errorMessage: null, + gallerySaveFailed: false, + clearLastSaved: true, + ), + ); + } on PlatformException catch (error) { + _updateSession( + (s) => s.copyWith(errorMessage: error.message ?? '开始录制失败'), + ); + } finally { + _updateSession((s) => s.copyWith(isStartingRecording: false)); + } + } + + Future stopRecording() async { + if (!_tryAcquireRecordingAction(_stopRecordingThrottleKey)) return; + + if (!state.session.isRecording) return; + + try { + final result = await RecordingPlatform.stopRecording(); + final galleryFailed = !result.gallerySaved; + final savedName = recordingFileNameForPlatform( + state.clipboardRecordingModel.filename, + ); + _updateSession( + (s) => s.copyWith( + status: result.status, + lastOutputPath: result.outputPath ?? s.lastOutputPath, + lastSavedDisplayName: galleryFailed ? null : savedName, + errorMessage: galleryFailed + ? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限') + : null, + gallerySaveFailed: galleryFailed, + ), + ); + } on PlatformException catch (error) { + _updateSession( + (s) => s.copyWith(errorMessage: error.message ?? '停止录制失败'), + ); + } + } + + void setTouchLocked(bool locked) { + _updateSession((s) => s.copyWith(isTouchLocked: locked)); + } + + void clearSavedRecordingResult() { + _updateSession((s) => s.copyWith(clearLastSaved: true)); + } + + Future openDndSettings() => + RecordingPlatform.openNotificationPolicySettings(); + + Future refreshDndAccess() async { + final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); + _updateSession((s) => s.copyWith(hasDndAccess: hasDnd)); + } + + Future openBatterySettings() => + RecordingPlatform.openBatteryOptimizationSettings(); + + Future refreshBatteryOptimization() async { + final ignored = await RecordingPlatform.isIgnoringBatteryOptimizations(); + _updateSession((s) => s.copyWith(isBatteryOptimizedIgnored: ignored)); + } + + Future teardown() async { + await RecordingPlatform.setImmersiveMode(enabled: false); + await RecordingPlatform.disableDoNotDisturb(); + await RecordingPlatform.disposePreview(); + await _statusSubscription?.cancel(); + _statusSubscription = null; + state = state.copyWith(session: const RecordingSessionState()); + } + + Future _listenStatus() async { + await _statusSubscription?.cancel(); + _statusSubscription = RecordingPlatform.statusStream().listen((status) { + _updateSession((s) => s.copyWith(status: status)); + }); + } + + Future _dispose() async { + _rateLimit.clear(); + await _statusSubscription?.cancel(); + } } diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart new file mode 100644 index 0000000..15be050 --- /dev/null +++ b/lib/gen/assets.gen.dart @@ -0,0 +1,121 @@ +// dart format width=80 + +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: deprecated_member_use,directives_ordering,implicit_dynamic_list_literal,unnecessary_import + +import 'package:flutter/widgets.dart'; + +class $AssetsImagesGen { + const $AssetsImagesGen(); + + /// File path: assets/images/image_dialog_bg.png + AssetGenImage get imageDialogBg => + const AssetGenImage('assets/images/image_dialog_bg.png'); + + /// File path: assets/images/image_logo.png + AssetGenImage get imageLogo => + const AssetGenImage('assets/images/image_logo.png'); + + /// List of all assets + List get values => [imageDialogBg, imageLogo]; +} + +class Assets { + const Assets._(); + + static const $AssetsImagesGen images = $AssetsImagesGen(); +} + +class AssetGenImage { + const AssetGenImage( + this._assetName, { + this.size, + this.flavors = const {}, + this.animation, + }); + + final String _assetName; + + final Size? size; + final Set flavors; + final AssetGenImageAnimation? animation; + + Image image({ + Key? key, + AssetBundle? bundle, + ImageFrameBuilder? frameBuilder, + ImageErrorWidgetBuilder? errorBuilder, + String? semanticLabel, + bool excludeFromSemantics = false, + double? scale, + double? width, + double? height, + Color? color, + Animation? opacity, + BlendMode? colorBlendMode, + BoxFit? fit, + AlignmentGeometry alignment = Alignment.center, + ImageRepeat repeat = ImageRepeat.noRepeat, + Rect? centerSlice, + bool matchTextDirection = false, + bool gaplessPlayback = true, + bool isAntiAlias = false, + String? package, + FilterQuality filterQuality = FilterQuality.medium, + int? cacheWidth, + int? cacheHeight, + }) { + return Image.asset( + _assetName, + key: key, + bundle: bundle, + frameBuilder: frameBuilder, + errorBuilder: errorBuilder, + semanticLabel: semanticLabel, + excludeFromSemantics: excludeFromSemantics, + scale: scale, + width: width, + height: height, + color: color, + opacity: opacity, + colorBlendMode: colorBlendMode, + fit: fit, + alignment: alignment, + repeat: repeat, + centerSlice: centerSlice, + matchTextDirection: matchTextDirection, + gaplessPlayback: gaplessPlayback, + isAntiAlias: isAntiAlias, + package: package, + filterQuality: filterQuality, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + ); + } + + ImageProvider provider({AssetBundle? bundle, String? package}) { + return AssetImage(_assetName, bundle: bundle, package: package); + } + + String get path => _assetName; + + String get keyName => _assetName; +} + +class AssetGenImageAnimation { + const AssetGenImageAnimation({ + required this.isAnimation, + required this.duration, + required this.frames, + }); + + final bool isAnimation; + final Duration duration; + final int frames; +} diff --git a/pubspec.yaml b/pubspec.yaml index b2ea6ec..9ffac15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,8 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 + build_runner: ^2.15.0 + flutter_gen_runner: ^5.14.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -68,7 +70,11 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + generate: true + # To add assets to your application, add an assets section, like this: + assets: + - assets/images/ # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg @@ -99,3 +105,8 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + +flutter_gen: + output: lib/gen/ + integrations: + flutter_svg: true diff --git a/test/features/recording/view_model_recording_test.dart b/test/features/recording/view_model_recording_test.dart index bee487a..d73bb5c 100644 --- a/test/features/recording/view_model_recording_test.dart +++ b/test/features/recording/view_model_recording_test.dart @@ -25,6 +25,19 @@ void main() { .setMockMethodCallHandler(SystemChannels.platform, null); }); + group('RecordingViewModel', () { + test('initializes with default clipboard and session state', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final model = container.read(recordingViewModelProvider); + expect(model.hasValidClipboardInfo, isFalse); + expect(model.clipboardRecordingModel.title, defaultClipboardTitle); + expect(model.session.isPreviewReady, isFalse); + expect(model.session.isRecording, isFalse); + }); + }); + group('RecordingViewModel.getClipboardContent', () { test( 'updates state when clipboard contains valid mini program JSON',