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',