更新pubspec。修改启动屏幕的启动后台XML,并通过删除过时的文件和增强会话管理来重构记录功能。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
assets/images/image_dialog_bg.png
Normal file
BIN
assets/images/image_dialog_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
BIN
assets/images/image_logo.png
Normal file
BIN
assets/images/image_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -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});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
82
lib/features/recording/model/model_recording_session.dart
Normal file
82
lib/features/recording/model/model_recording_session.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
121
lib/gen/assets.gen.dart
Normal 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;
|
||||
}
|
||||
11
pubspec.yaml
11
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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user