更新pubspec。修改启动屏幕的启动后台XML,并通过删除过时的文件和增强会话管理来重构记录功能。

This commit is contained in:
2026-06-05 11:44:51 +08:00
parent 4c5bf22638
commit 1e936bfc12
13 changed files with 567 additions and 387 deletions

View File

@@ -1,12 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:gravity="fill"
android:src="@drawable/startup_background" />
</item>
</layer-list>

View File

@@ -1,12 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:gravity="fill"
android:src="@drawable/startup_background" />
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -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});

View File

@@ -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<String, dynamic> 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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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<RecordingPage> createState() => _RecordingPageState();
}
@@ -30,11 +32,13 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
var _immersiveApplied = false;
@override
///
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
}
///
Future<void> _checkAndShowDeviceHealthAlerts() async {
final snapshot = await AppPlatformInfo.deviceHealth();
if (!mounted) return;
@@ -45,6 +49,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
await AppDialog.deviceHealthAlert(context, lines: lines);
}
///
Future<void> _bootstrap() async {
await _checkAndShowDeviceHealthAlerts();
if (!mounted) return;
@@ -60,11 +65,10 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
// Allow PlatformView to attach before binding CameraX preview.
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
await ref
.read(recordingSessionControllerProvider.notifier)
.prepareSession();
await ref.read(recordingViewModelProvider.notifier).prepareSession();
}
/// Android
Future<void> _enterRecordingMode() async {
if (!Platform.isAndroid) return;
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
@@ -72,6 +76,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
_immersiveApplied = true;
}
///
String _savedDialogSessionTitle(
RecordingModel recordingInfo,
String? savedName,
@@ -87,6 +92,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
return '录制完成';
}
///
Future<void> _showNoPlayerInfoDialog() {
return showDialog<void>(
context: context,
@@ -104,6 +110,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
);
}
///
Future<void> _onStartRecording() async {
final recordingInfo = ref.read(recordingViewModelProvider);
if (!recordingInfo.hasClipboardFilename) {
@@ -112,23 +119,24 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
}
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<void> _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<RecordingPage> {
sessionTitle: sessionTitle,
onContinueRound: () {
ref
.read(recordingSessionControllerProvider.notifier)
.read(recordingViewModelProvider.notifier)
.clearSavedRecordingResult();
},
onRecordNewRound: _clearClipboardForNewRound,
);
}
/// 退
Future<void> _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<RecordingPage> {
}
@override
/// UI
void dispose() {
if (_immersiveApplied) {
SystemChrome.setEnabledSystemUIMode(
@@ -170,10 +180,11 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
}
@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<RecordingPage> {
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<RecordingPage> {
},
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<RecordingPage> {
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<RecordingPage> {
}
}
/// /
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,

View File

@@ -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, RecordingSessionState>(
RecordingSessionController.new,
);
class RecordingSessionController extends Notifier<RecordingSessionState> {
static const Duration _recordingActionInterval = Duration(milliseconds: 300);
static const Object _startRecordingThrottleKey = 'recording.session.start';
static const Object _stopRecordingThrottleKey = 'recording.session.stop';
StreamSubscription<RecordingStatus>? _statusSubscription;
final _rateLimit = RateLimitHub();
@override
RecordingSessionState build() {
ref.onDispose(_dispose);
return const RecordingSessionState();
}
Future<void> 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 = <String>[];
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<RecordingStatus> _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<void>.delayed(Duration(milliseconds: 150 * (attempt + 1)));
}
}
throw StateError('initializePreview retry exhausted');
}
List<Permission> _galleryPermissions() {
if (Platform.isIOS) {
return [Permission.photosAddOnly, Permission.photos];
}
if (Platform.isAndroid) {
return [Permission.videos, Permission.storage];
}
return const [];
}
bool _isGalleryPermissionGranted(Map<Permission, PermissionStatus> 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<void>(
key: key,
value: null,
duration: _recordingActionInterval,
options: const ThrottleOptions(leading: true, trailing: false),
onCallback: (_) => executed = true,
);
return executed;
}
Future<void> 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<void> 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<void> openDndSettings() =>
RecordingPlatform.openNotificationPolicySettings();
Future<void> refreshDndAccess() async {
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
state = state.copyWith(hasDndAccess: hasDnd);
}
Future<void> openBatterySettings() =>
RecordingPlatform.openBatteryOptimizationSettings();
Future<void> refreshBatteryOptimization() async {
final ignored = await RecordingPlatform.isIgnoringBatteryOptimizations();
state = state.copyWith(isBatteryOptimizedIgnored: ignored);
}
Future<void> teardown() async {
await RecordingPlatform.setImmersiveMode(enabled: false);
await RecordingPlatform.disableDoNotDisturb();
await RecordingPlatform.disposePreview();
await _statusSubscription?.cancel();
_statusSubscription = null;
state = const RecordingSessionState();
}
Future<void> _listenStatus() async {
await _statusSubscription?.cancel();
_statusSubscription = RecordingPlatform.statusStream().listen((status) {
state = state.copyWith(status: status);
});
}
Future<void> _dispose() async {
_rateLimit.clear();
await _statusSubscription?.cancel();
}
}

View File

@@ -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<RecordingViewModel, RecordingModel>((ref) {
return RecordingViewModel(ref);
});
NotifierProvider<RecordingViewModel, RecordingModel>(
RecordingViewModel.new,
);
/// 剪切板读取结果,供 UI 决定是否提示用户。
enum ClipboardReadResult {
@@ -24,23 +31,33 @@ enum ClipboardReadResult {
invalid,
}
class RecordingViewModel extends StateNotifier<RecordingModel> {
RecordingViewModel(this.ref)
: super(
RecordingModel(
clipboardRecordingModel: ClipboardRecordingModel(
title: '',
address: '',
),
),
);
final Ref ref;
class RecordingViewModel extends Notifier<RecordingModel> {
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<RecordingStatus>? _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<ClipboardReadResult> getClipboardContent() async {
try {
@@ -95,4 +112,241 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
hasValidClipboardInfo: false,
);
}
Future<void> 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 = <String>[];
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<RecordingStatus> _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<void>.delayed(Duration(milliseconds: 150 * (attempt + 1)));
}
}
throw StateError('initializePreview retry exhausted');
}
List<Permission> _galleryPermissions() {
if (Platform.isIOS) {
return [Permission.photosAddOnly, Permission.photos];
}
if (Platform.isAndroid) {
return [Permission.videos, Permission.storage];
}
return const [];
}
bool _isGalleryPermissionGranted(Map<Permission, PermissionStatus> 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<void>(
key: key,
value: null,
duration: _recordingActionInterval,
options: const ThrottleOptions(leading: true, trailing: false),
onCallback: (_) => executed = true,
);
return executed;
}
Future<void> 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<void> 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<void> openDndSettings() =>
RecordingPlatform.openNotificationPolicySettings();
Future<void> refreshDndAccess() async {
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
_updateSession((s) => s.copyWith(hasDndAccess: hasDnd));
}
Future<void> openBatterySettings() =>
RecordingPlatform.openBatteryOptimizationSettings();
Future<void> refreshBatteryOptimization() async {
final ignored = await RecordingPlatform.isIgnoringBatteryOptimizations();
_updateSession((s) => s.copyWith(isBatteryOptimizedIgnored: ignored));
}
Future<void> 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<void> _listenStatus() async {
await _statusSubscription?.cancel();
_statusSubscription = RecordingPlatform.statusStream().listen((status) {
_updateSession((s) => s.copyWith(status: status));
});
}
Future<void> _dispose() async {
_rateLimit.clear();
await _statusSubscription?.cancel();
}
}

121
lib/gen/assets.gen.dart Normal file
View File

@@ -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<AssetGenImage> 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<String> 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<double>? 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;
}

View File

@@ -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

View File

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