更新pubspec。修改启动屏幕的启动后台XML,并通过删除过时的文件和增强会话管理来重构记录功能。
This commit is contained in:
@@ -1,12 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item>
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="fill"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@drawable/startup_background" />
|
||||||
</item> -->
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item>
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="fill"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@drawable/startup_background" />
|
||||||
</item> -->
|
</item>
|
||||||
</layer-list>
|
</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_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.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/config/app_config.dart';
|
||||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||||
import 'package:recording_tool/app/theme/app_theme.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:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
|
||||||
|
|
||||||
class FlutterTemplateApp extends ConsumerStatefulWidget {
|
class FlutterTemplateApp extends ConsumerStatefulWidget {
|
||||||
const FlutterTemplateApp({super.key});
|
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_clipboard.dart';
|
||||||
|
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
||||||
|
|
||||||
class RecordingModel {
|
class RecordingModel {
|
||||||
/// 剪切板内容
|
/// 剪切板内容
|
||||||
@@ -7,11 +8,17 @@ class RecordingModel {
|
|||||||
/// 剪切板是否包含有效的小程序录制信息
|
/// 剪切板是否包含有效的小程序录制信息
|
||||||
final bool hasValidClipboardInfo;
|
final bool hasValidClipboardInfo;
|
||||||
|
|
||||||
|
/// 录制会话状态
|
||||||
|
final RecordingSessionState session;
|
||||||
|
|
||||||
RecordingModel({
|
RecordingModel({
|
||||||
required this.clipboardRecordingModel,
|
required this.clipboardRecordingModel,
|
||||||
this.hasValidClipboardInfo = false,
|
this.hasValidClipboardInfo = false,
|
||||||
|
this.session = const RecordingSessionState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get isRecording => session.isRecording;
|
||||||
|
|
||||||
factory RecordingModel.fromJson(Map<String, dynamic> json) {
|
factory RecordingModel.fromJson(Map<String, dynamic> json) {
|
||||||
return RecordingModel(
|
return RecordingModel(
|
||||||
clipboardRecordingModel: ClipboardRecordingModel.fromJson(
|
clipboardRecordingModel: ClipboardRecordingModel.fromJson(
|
||||||
@@ -32,12 +39,14 @@ class RecordingModel {
|
|||||||
RecordingModel copyWith({
|
RecordingModel copyWith({
|
||||||
ClipboardRecordingModel? clipboardRecordingModel,
|
ClipboardRecordingModel? clipboardRecordingModel,
|
||||||
bool? hasValidClipboardInfo,
|
bool? hasValidClipboardInfo,
|
||||||
|
RecordingSessionState? session,
|
||||||
}) {
|
}) {
|
||||||
return RecordingModel(
|
return RecordingModel(
|
||||||
clipboardRecordingModel:
|
clipboardRecordingModel:
|
||||||
clipboardRecordingModel ?? this.clipboardRecordingModel,
|
clipboardRecordingModel ?? this.clipboardRecordingModel,
|
||||||
hasValidClipboardInfo:
|
hasValidClipboardInfo:
|
||||||
hasValidClipboardInfo ?? this.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/platform/device_health_checker.dart';
|
||||||
import 'package:recording_tool/core/utils/date_time_formatter.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.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_display_name.dart';
|
||||||
import 'package:recording_tool/features/recording/recording_platform.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/view-model/view_model_recording.dart';
|
||||||
import 'package:recording_tool/features/recording/widgets/camera_preview_widget.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_saved_dialog.dart';
|
||||||
import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart';
|
import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart';
|
||||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||||
|
|
||||||
|
/// 录制页入口
|
||||||
class RecordingPage extends ConsumerStatefulWidget {
|
class RecordingPage extends ConsumerStatefulWidget {
|
||||||
const RecordingPage({super.key});
|
const RecordingPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 创建页面状态
|
||||||
ConsumerState<RecordingPage> createState() => _RecordingPageState();
|
ConsumerState<RecordingPage> createState() => _RecordingPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,11 +32,13 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
var _immersiveApplied = false;
|
var _immersiveApplied = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 首帧后初始化录制流程
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 检查设备健康状态并弹窗提示
|
||||||
Future<void> _checkAndShowDeviceHealthAlerts() async {
|
Future<void> _checkAndShowDeviceHealthAlerts() async {
|
||||||
final snapshot = await AppPlatformInfo.deviceHealth();
|
final snapshot = await AppPlatformInfo.deviceHealth();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -45,6 +49,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
await AppDialog.deviceHealthAlert(context, lines: lines);
|
await AppDialog.deviceHealthAlert(context, lines: lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 页面启动:健康检查、读剪贴板、进入录制模式、准备相机会话
|
||||||
Future<void> _bootstrap() async {
|
Future<void> _bootstrap() async {
|
||||||
await _checkAndShowDeviceHealthAlerts();
|
await _checkAndShowDeviceHealthAlerts();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -60,11 +65,10 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
// Allow PlatformView to attach before binding CameraX preview.
|
// Allow PlatformView to attach before binding CameraX preview.
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await ref
|
await ref.read(recordingViewModelProvider.notifier).prepareSession();
|
||||||
.read(recordingSessionControllerProvider.notifier)
|
|
||||||
.prepareSession();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Android 进入沉浸式全屏
|
||||||
Future<void> _enterRecordingMode() async {
|
Future<void> _enterRecordingMode() async {
|
||||||
if (!Platform.isAndroid) return;
|
if (!Platform.isAndroid) return;
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||||
@@ -72,6 +76,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
_immersiveApplied = true;
|
_immersiveApplied = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析保存成功弹窗的标题文案
|
||||||
String _savedDialogSessionTitle(
|
String _savedDialogSessionTitle(
|
||||||
RecordingModel recordingInfo,
|
RecordingModel recordingInfo,
|
||||||
String? savedName,
|
String? savedName,
|
||||||
@@ -87,6 +92,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
return '录制完成';
|
return '录制完成';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 无选手信息时弹窗提示
|
||||||
Future<void> _showNoPlayerInfoDialog() {
|
Future<void> _showNoPlayerInfoDialog() {
|
||||||
return showDialog<void>(
|
return showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -104,6 +110,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击开始录制:校验剪贴板与健康状态
|
||||||
Future<void> _onStartRecording() async {
|
Future<void> _onStartRecording() async {
|
||||||
final recordingInfo = ref.read(recordingViewModelProvider);
|
final recordingInfo = ref.read(recordingViewModelProvider);
|
||||||
if (!recordingInfo.hasClipboardFilename) {
|
if (!recordingInfo.hasClipboardFilename) {
|
||||||
@@ -112,23 +119,24 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
}
|
}
|
||||||
await _checkAndShowDeviceHealthAlerts();
|
await _checkAndShowDeviceHealthAlerts();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await ref.read(recordingSessionControllerProvider.notifier).startRecording();
|
await ref.read(recordingViewModelProvider.notifier).startRecording();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清空剪贴板信息,准备新一轮录制
|
||||||
void _clearClipboardForNewRound() {
|
void _clearClipboardForNewRound() {
|
||||||
ref.read(recordingViewModelProvider.notifier).resetClipboardInfo();
|
final notifier = ref.read(recordingViewModelProvider.notifier);
|
||||||
ref
|
notifier.resetClipboardInfo();
|
||||||
.read(recordingSessionControllerProvider.notifier)
|
notifier.clearSavedRecordingResult();
|
||||||
.clearSavedRecordingResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 保存成功后按需弹出完成对话框
|
||||||
Future<void> _showRecordingSavedDialogIfNeeded() async {
|
Future<void> _showRecordingSavedDialogIfNeeded() async {
|
||||||
final session = ref.read(recordingSessionControllerProvider);
|
final recordingInfo = ref.read(recordingViewModelProvider);
|
||||||
|
final session = recordingInfo.session;
|
||||||
if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
|
if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final recordingInfo = ref.read(recordingViewModelProvider);
|
|
||||||
final sessionTitle = _savedDialogSessionTitle(
|
final sessionTitle = _savedDialogSessionTitle(
|
||||||
recordingInfo,
|
recordingInfo,
|
||||||
session.lastSavedDisplayName,
|
session.lastSavedDisplayName,
|
||||||
@@ -139,16 +147,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
sessionTitle: sessionTitle,
|
sessionTitle: sessionTitle,
|
||||||
onContinueRound: () {
|
onContinueRound: () {
|
||||||
ref
|
ref
|
||||||
.read(recordingSessionControllerProvider.notifier)
|
.read(recordingViewModelProvider.notifier)
|
||||||
.clearSavedRecordingResult();
|
.clearSavedRecordingResult();
|
||||||
},
|
},
|
||||||
onRecordNewRound: _clearClipboardForNewRound,
|
onRecordNewRound: _clearClipboardForNewRound,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 退出沉浸式并释放录制会话
|
||||||
Future<void> _exitRecordingMode() async {
|
Future<void> _exitRecordingMode() async {
|
||||||
if (!_immersiveApplied) return;
|
if (!_immersiveApplied) return;
|
||||||
await ref.read(recordingSessionControllerProvider.notifier).teardown();
|
await ref.read(recordingViewModelProvider.notifier).teardown();
|
||||||
await SystemChrome.setEnabledSystemUIMode(
|
await SystemChrome.setEnabledSystemUIMode(
|
||||||
SystemUiMode.manual,
|
SystemUiMode.manual,
|
||||||
overlays: SystemUiOverlay.values,
|
overlays: SystemUiOverlay.values,
|
||||||
@@ -158,6 +167,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 页面销毁时恢复系统 UI
|
||||||
void dispose() {
|
void dispose() {
|
||||||
if (_immersiveApplied) {
|
if (_immersiveApplied) {
|
||||||
SystemChrome.setEnabledSystemUIMode(
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
@@ -170,10 +180,11 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 构建录制页 UI
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(recordingSessionControllerProvider);
|
|
||||||
final recordingInfo = ref.watch(recordingViewModelProvider);
|
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 clipboard = recordingInfo.clipboardRecordingModel;
|
||||||
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
|
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
|
||||||
|
|
||||||
@@ -199,7 +210,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
if (state.isTouchLocked && state.isRecording)
|
if (state.isTouchLocked && state.isRecording)
|
||||||
RecordingTouchLockOverlay(
|
RecordingTouchLockOverlay(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
onUnlocked: () => controller.setTouchLocked(false),
|
onUnlocked: () => viewModel.setTouchLocked(false),
|
||||||
),
|
),
|
||||||
_RecordingHud(
|
_RecordingHud(
|
||||||
state: state,
|
state: state,
|
||||||
@@ -219,9 +230,9 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
},
|
},
|
||||||
onStart: _onStartRecording,
|
onStart: _onStartRecording,
|
||||||
onStop: () async {
|
onStop: () async {
|
||||||
await controller.stopRecording();
|
await viewModel.stopRecording();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final latest = ref.read(recordingSessionControllerProvider);
|
final latest = ref.read(recordingViewModelProvider).session;
|
||||||
if (latest.gallerySaveFailed) {
|
if (latest.gallerySaveFailed) {
|
||||||
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
||||||
return;
|
return;
|
||||||
@@ -229,15 +240,15 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
await _showRecordingSavedDialogIfNeeded();
|
await _showRecordingSavedDialogIfNeeded();
|
||||||
},
|
},
|
||||||
onOpenDnd: () async {
|
onOpenDnd: () async {
|
||||||
await controller.openDndSettings();
|
await viewModel.openDndSettings();
|
||||||
await controller.refreshDndAccess();
|
await viewModel.refreshDndAccess();
|
||||||
},
|
},
|
||||||
onOpenBattery: () async {
|
onOpenBattery: () async {
|
||||||
await controller.openBatterySettings();
|
await viewModel.openBatterySettings();
|
||||||
await controller.refreshBatteryOptimization();
|
await viewModel.refreshBatteryOptimization();
|
||||||
},
|
},
|
||||||
onToggleTouchLock: () {
|
onToggleTouchLock: () {
|
||||||
controller.setTouchLocked(!state.isTouchLocked);
|
viewModel.setTouchLocked(!state.isTouchLocked);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (state.isStartingRecording)
|
if (state.isStartingRecording)
|
||||||
@@ -249,12 +260,14 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 录制加载遮罩(相机启动/开始录制)
|
||||||
class _RecordingLoadingOverlay extends StatelessWidget {
|
class _RecordingLoadingOverlay extends StatelessWidget {
|
||||||
const _RecordingLoadingOverlay({required this.message});
|
const _RecordingLoadingOverlay({required this.message});
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 显示加载动画与提示文案
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
@@ -281,6 +294,7 @@ class _RecordingLoadingOverlay extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 录制页 HUD 层(赛事信息、控制按钮、状态提示)
|
||||||
class _RecordingHud extends StatelessWidget {
|
class _RecordingHud extends StatelessWidget {
|
||||||
const _RecordingHud({
|
const _RecordingHud({
|
||||||
required this.state,
|
required this.state,
|
||||||
@@ -310,14 +324,17 @@ class _RecordingHud extends StatelessWidget {
|
|||||||
final VoidCallback onOpenBattery;
|
final VoidCallback onOpenBattery;
|
||||||
final VoidCallback onToggleTouchLock;
|
final VoidCallback onToggleTouchLock;
|
||||||
|
|
||||||
|
/// 叠加层文字样式
|
||||||
static TextStyle get _overlayTextStyle => TextStyle(
|
static TextStyle get _overlayTextStyle => TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
shadows: [Shadow(color: Colors.black54, blurRadius: 6.r)],
|
shadows: [Shadow(color: Colors.black54, blurRadius: 6.r)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 底部控制区左右占位宽度
|
||||||
static double get _controlSlotWidth => 48.r;
|
static double get _controlSlotWidth => 48.r;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 构建 HUD 布局
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final showPasteEventInfo = eventTitle == null && !state.isRecording;
|
final showPasteEventInfo = eventTitle == null && !state.isRecording;
|
||||||
|
|
||||||
@@ -541,6 +558,7 @@ class _RecordingHud extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 权限与剪贴板相关设置提示条
|
||||||
class _SetupHints extends StatelessWidget {
|
class _SetupHints extends StatelessWidget {
|
||||||
const _SetupHints({
|
const _SetupHints({
|
||||||
required this.hasDndAccess,
|
required this.hasDndAccess,
|
||||||
@@ -563,6 +581,7 @@ class _SetupHints extends StatelessWidget {
|
|||||||
final VoidCallback onOpenNotificationSettings;
|
final VoidCallback onOpenNotificationSettings;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 按需展示权限/剪贴板提示
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final showPermissionHints =
|
final showPermissionHints =
|
||||||
!hasDndAccess || !isBatteryIgnored || !notificationsGranted;
|
!hasDndAccess || !isBatteryIgnored || !notificationsGranted;
|
||||||
@@ -598,20 +617,24 @@ class _SetupHints extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 显示剪贴板地址与实时时钟的提示芯片
|
||||||
class _ClipboardAddressClockChip extends StatefulWidget {
|
class _ClipboardAddressClockChip extends StatefulWidget {
|
||||||
const _ClipboardAddressClockChip({required this.address});
|
const _ClipboardAddressClockChip({required this.address});
|
||||||
|
|
||||||
final String address;
|
final String address;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 创建芯片状态
|
||||||
State<_ClipboardAddressClockChip> createState() =>
|
State<_ClipboardAddressClockChip> createState() =>
|
||||||
_ClipboardAddressClockChipState();
|
_ClipboardAddressClockChipState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ClipboardAddressClockChipState extends State<_ClipboardAddressClockChip> {
|
class _ClipboardAddressClockChipState
|
||||||
|
extends State<_ClipboardAddressClockChip> {
|
||||||
Timer? _clockTimer;
|
Timer? _clockTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 启动每秒刷新时钟
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
@@ -620,12 +643,14 @@ class _ClipboardAddressClockChipState extends State<_ClipboardAddressClockChip>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 取消定时器
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_clockTimer?.cancel();
|
_clockTimer?.cancel();
|
||||||
_clockTimer = null;
|
_clockTimer = null;
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拼接地址与当前时间文本
|
||||||
String _buildLabel() {
|
String _buildLabel() {
|
||||||
final nowText = DateTimeFormatter.format(
|
final nowText = DateTimeFormatter.format(
|
||||||
DateTime.now(),
|
DateTime.now(),
|
||||||
@@ -636,11 +661,13 @@ class _ClipboardAddressClockChipState extends State<_ClipboardAddressClockChip>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 渲染时钟芯片
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _HintChip(label: _buildLabel(), onTap: () {});
|
return _HintChip(label: _buildLabel(), onTap: () {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 可点击的提示条组件
|
||||||
class _HintChip extends StatelessWidget {
|
class _HintChip extends StatelessWidget {
|
||||||
const _HintChip({required this.label, required this.onTap});
|
const _HintChip({required this.label, required this.onTap});
|
||||||
|
|
||||||
@@ -648,6 +675,7 @@ class _HintChip extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
/// 构建提示条 UI
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
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:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/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_clipboard.dart';
|
||||||
import 'package:recording_tool/features/recording/model/model_recording.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 =
|
final recordingViewModelProvider =
|
||||||
StateNotifierProvider<RecordingViewModel, RecordingModel>((ref) {
|
NotifierProvider<RecordingViewModel, RecordingModel>(
|
||||||
return RecordingViewModel(ref);
|
RecordingViewModel.new,
|
||||||
});
|
);
|
||||||
|
|
||||||
/// 剪切板读取结果,供 UI 决定是否提示用户。
|
/// 剪切板读取结果,供 UI 决定是否提示用户。
|
||||||
enum ClipboardReadResult {
|
enum ClipboardReadResult {
|
||||||
@@ -24,23 +31,33 @@ enum ClipboardReadResult {
|
|||||||
invalid,
|
invalid,
|
||||||
}
|
}
|
||||||
|
|
||||||
class RecordingViewModel extends StateNotifier<RecordingModel> {
|
class RecordingViewModel extends Notifier<RecordingModel> {
|
||||||
RecordingViewModel(this.ref)
|
static const Duration _recordingActionInterval = Duration(milliseconds: 300);
|
||||||
: super(
|
static const Object _startRecordingThrottleKey = 'recording.session.start';
|
||||||
RecordingModel(
|
static const Object _stopRecordingThrottleKey = 'recording.session.stop';
|
||||||
clipboardRecordingModel: ClipboardRecordingModel(
|
|
||||||
title: '',
|
|
||||||
address: '',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final Ref ref;
|
|
||||||
|
|
||||||
static final _defaultClipboard = ClipboardRecordingModel(
|
static final _defaultClipboard = ClipboardRecordingModel(
|
||||||
title: '',
|
title: '',
|
||||||
address: '',
|
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 {
|
Future<ClipboardReadResult> getClipboardContent() async {
|
||||||
try {
|
try {
|
||||||
@@ -95,4 +112,241 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
|
|||||||
hasValidClipboardInfo: false,
|
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
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
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
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# 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
|
# included with your application, so that you can use the icons in
|
||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
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:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
@@ -99,3 +105,8 @@ flutter:
|
|||||||
#
|
#
|
||||||
# For details regarding fonts from package dependencies,
|
# For details regarding fonts from package dependencies,
|
||||||
# see https://flutter.dev/to/font-from-package
|
# 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);
|
.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', () {
|
group('RecordingViewModel.getClipboardContent', () {
|
||||||
test(
|
test(
|
||||||
'updates state when clipboard contains valid mini program JSON',
|
'updates state when clipboard contains valid mini program JSON',
|
||||||
|
|||||||
Reference in New Issue
Block a user