diff --git a/lib/features/recording/model/model_recording_session.dart b/lib/features/recording/model/model_recording_session.dart index 9a06e2c..5e25176 100644 --- a/lib/features/recording/model/model_recording_session.dart +++ b/lib/features/recording/model/model_recording_session.dart @@ -7,6 +7,7 @@ class RecordingSessionState { this.isTouchLocked = true, this.isPreviewReady = false, this.isStartingRecording = false, + this.isSwitchingLens = false, this.hasDndAccess = false, this.isBatteryOptimizedIgnored = true, this.notificationsGranted = true, @@ -19,12 +20,14 @@ class RecordingSessionState { this.errorMessage, this.permissionWarning, this.fileSaveFailed = false, + this.segmentOutputPaths = const [], }); final RecordingStatus status; final bool isTouchLocked; final bool isPreviewReady; final bool isStartingRecording; + final bool isSwitchingLens; final bool hasDndAccess; final bool isBatteryOptimizedIgnored; final bool notificationsGranted; @@ -37,6 +40,7 @@ class RecordingSessionState { final String? errorMessage; final String? permissionWarning; final bool fileSaveFailed; + final List segmentOutputPaths; bool get isRecording => status.isRecording; @@ -53,6 +57,7 @@ class RecordingSessionState { bool? isTouchLocked, bool? isPreviewReady, bool? isStartingRecording, + bool? isSwitchingLens, bool? hasDndAccess, bool? isBatteryOptimizedIgnored, bool? notificationsGranted, @@ -65,6 +70,7 @@ class RecordingSessionState { String? errorMessage, String? permissionWarning, bool? fileSaveFailed, + List? segmentOutputPaths, bool clearPermissionWarning = false, bool clearLastSaved = false, }) { @@ -73,6 +79,7 @@ class RecordingSessionState { isTouchLocked: isTouchLocked ?? this.isTouchLocked, isPreviewReady: isPreviewReady ?? this.isPreviewReady, isStartingRecording: isStartingRecording ?? this.isStartingRecording, + isSwitchingLens: isSwitchingLens ?? this.isSwitchingLens, hasDndAccess: hasDndAccess ?? this.hasDndAccess, isBatteryOptimizedIgnored: isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored, @@ -90,6 +97,7 @@ class RecordingSessionState { ? null : (permissionWarning ?? this.permissionWarning), fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed, + segmentOutputPaths: segmentOutputPaths ?? this.segmentOutputPaths, ); } } diff --git a/lib/features/recording/pages/page_record.dart b/lib/features/recording/pages/page_record.dart index 7841308..803e28a 100644 --- a/lib/features/recording/pages/page_record.dart +++ b/lib/features/recording/pages/page_record.dart @@ -173,7 +173,11 @@ class _RecordingPageState extends ConsumerState { if (!mounted) return; final latest = ref.read(recordingViewModelProvider).session; if (latest.fileSaveFailed) { - AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限'); + if (latest.segmentOutputPaths.isNotEmpty) { + AppToast.show('视频合并失败,已为你保存分段文件,可在相册中查看'); + } else { + AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限'); + } return; } await _showRecordingSavedDialogIfNeeded(); @@ -374,6 +378,7 @@ class _RecordingHudLayer extends ConsumerWidget { m.session.notificationsGranted, m.session.isRecording, m.session.isStartingRecording, + m.session.isSwitchingLens, m.session.isTouchLocked, m.session.zoomRatio, m.session.minZoomRatio, @@ -391,6 +396,7 @@ class _RecordingHudLayer extends ConsumerWidget { notificationsGranted, isRecording, isStartingRecording, + isSwitchingLens, isTouchLocked, zoomRatio, minZoomRatio, @@ -408,6 +414,7 @@ class _RecordingHudLayer extends ConsumerWidget { notificationsGranted: notificationsGranted, isRecording: isRecording, isStartingRecording: isStartingRecording, + isSwitchingLens: isSwitchingLens, isTouchLocked: isTouchLocked, zoomRatio: zoomRatio, minZoomRatio: minZoomRatio, diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart index b7dcba6..d4fe6e6 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -349,10 +349,14 @@ class RecordingViewModel extends Notifier { /// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。 Future setZoomRatio(double ratio) async { final session = state.session; + if (session.isSwitchingLens) { + return; + } final clamped = ratio .clamp(session.minZoomRatio, session.maxZoomRatio) .toDouble(); + _updateSession((s) => s.copyWith(isSwitchingLens: true)); try { final zoom = await RecordingPlatform.setZoomRatio(clamped); _updateSession( @@ -368,13 +372,19 @@ class RecordingViewModel extends Notifier { ? '切换镜头失败,请重试' : (error.message ?? '相机倍距设置失败'); _updateSession((s) => s.copyWith(errorMessage: message)); + } finally { + _updateSession( + (s) => s.copyWith(isSwitchingLens: false, errorMessage: s.errorMessage), + ); } } /// 开始录制,可选开启勿扰模式。 Future startRecording({bool enableDoNotDisturb = true}) async { final session = state.session; - if (session.isRecording || session.isStartingRecording) { + if (session.isRecording || + session.isStartingRecording || + session.isSwitchingLens) { return; } if (!session.isPreviewReady) { @@ -401,6 +411,7 @@ class RecordingViewModel extends Notifier { isTouchLocked: true, errorMessage: null, fileSaveFailed: false, + segmentOutputPaths: const [], clearLastSaved: true, ), ); @@ -415,7 +426,7 @@ class RecordingViewModel extends Notifier { /// 停止录制、保存到文件夹,并恢复相机预览。 Future stopRecording() async { - if (!state.session.isRecording) return; + if (!state.session.isRecording || state.session.isSwitchingLens) return; try { final result = await RecordingPlatform.stopRecording(); @@ -432,6 +443,7 @@ class RecordingViewModel extends Notifier { ? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限') : null, fileSaveFailed: fileFailed, + segmentOutputPaths: result.segmentOutputPaths, ), ); } on PlatformException catch (error) { diff --git a/lib/features/recording/widgets/widget_recording_hud.dart b/lib/features/recording/widgets/widget_recording_hud.dart index ede5f1e..c0f52c2 100644 --- a/lib/features/recording/widgets/widget_recording_hud.dart +++ b/lib/features/recording/widgets/widget_recording_hud.dart @@ -18,6 +18,7 @@ class RecordingHudWidget extends StatelessWidget { required this.notificationsGranted, required this.isRecording, required this.isStartingRecording, + required this.isSwitchingLens, required this.isTouchLocked, this.showClipboardHint = false, this.clipboardAddress = '', @@ -39,6 +40,7 @@ class RecordingHudWidget extends StatelessWidget { final bool notificationsGranted; final bool isRecording; final bool isStartingRecording; + final bool isSwitchingLens; final bool isTouchLocked; final bool showClipboardHint; final String clipboardAddress; @@ -143,8 +145,9 @@ class RecordingHudWidget extends StatelessWidget { ), Positioned( right: 16.r, - bottom: _recordButtonBottom + _recordButtonSize + 14.h, + bottom: 260.r, child: _ZoomPresetControl( + enabled: !isSwitchingLens, zoomRatio: zoomRatio, minZoomRatio: minZoomRatio, maxZoomRatio: maxZoomRatio, @@ -160,7 +163,7 @@ class RecordingHudWidget extends StatelessWidget { child: RecordingControlButton( isRecording: isRecording, isStartingRecording: isStartingRecording, - enabled: !isStartingRecording, + enabled: !isStartingRecording && !isSwitchingLens, size: _recordButtonSize, onTap: () { if (isRecording) { @@ -197,6 +200,7 @@ class RecordingHudWidget extends StatelessWidget { class _ZoomPresetControl extends StatelessWidget { const _ZoomPresetControl({ + required this.enabled, required this.zoomRatio, required this.minZoomRatio, required this.maxZoomRatio, @@ -204,6 +208,7 @@ class _ZoomPresetControl extends StatelessWidget { required this.onSelected, }); + final bool enabled; final double zoomRatio; final double minZoomRatio; final double maxZoomRatio; @@ -235,7 +240,7 @@ class _ZoomPresetControl extends StatelessWidget { displayRatio: preset, requestRatio: preset, selected: _isPresetSelected(preset), - enabled: true, + enabled: enabled, onSelected: onSelected, ), ], diff --git a/test/features/recording/view_model_recording_test.dart b/test/features/recording/view_model_recording_test.dart index dbcc0f2..25dde3f 100644 --- a/test/features/recording/view_model_recording_test.dart +++ b/test/features/recording/view_model_recording_test.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:recording_tool/features/recording/model/model_recording_session.dart'; import 'package:recording_tool/features/recording/platform/recording_channel_names.dart'; +import 'package:recording_tool/features/recording/platform/recording_platform.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; void main() { @@ -255,6 +258,125 @@ void main() { expect(session.errorMessage, '切换镜头失败,请重试'); }, ); + + test('sets switching lens while native zoom request is pending', () async { + final completer = Completer>(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) => completer.future, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(recordingViewModelProvider.notifier); + + final future = notifier.setZoomRatio(2); + await Future.delayed(Duration.zero); + + expect( + container.read(recordingViewModelProvider).session.isSwitchingLens, + isTrue, + ); + + completer.complete({ + 'zoomRatio': 2.0, + 'minZoomRatio': 1.0, + 'maxZoomRatio': 3.0, + }); + await future; + + expect( + container.read(recordingViewModelProvider).session.isSwitchingLens, + isFalse, + ); + }); + }); + + group('RecordingViewModel.stopRecording', () { + test('does not call native stop while switching lens', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + calls.add(call); + return {}; + }, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(recordingViewModelProvider.notifier); + // ignore: invalid_use_of_protected_member + notifier.state = container + .read(recordingViewModelProvider) + .copyWith( + session: const RecordingSessionState( + status: RecordingStatus(state: RecordingState.recording), + isSwitchingLens: true, + ), + ); + + await notifier.stopRecording(); + + expect(calls, isEmpty); + }); + + test( + 'stores segment output paths when native save falls back to parts', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + switch (call.method) { + case 'stopRecording': + return { + 'outputPath': 'content://recordings/part1', + 'status': { + 'state': 'error', + 'message': 'Merge failed', + }, + 'fileSaved': false, + 'fileErrorMessage': 'Merge failed', + 'segmentOutputPaths': [ + 'content://recordings/part1', + 'content://recordings/part2', + ], + }; + case 'initializePreview': + return {'state': 'previewing'}; + case 'getZoomCapabilities': + return { + 'zoomRatio': 1.0, + 'minZoomRatio': 1.0, + 'maxZoomRatio': 3.0, + }; + } + return {}; + }, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(recordingViewModelProvider.notifier); + // ignore: invalid_use_of_protected_member + notifier.state = container + .read(recordingViewModelProvider) + .copyWith( + session: const RecordingSessionState( + status: RecordingStatus(state: RecordingState.recording), + ), + ); + + await notifier.stopRecording(); + + final session = container.read(recordingViewModelProvider).session; + expect(session.fileSaveFailed, isTrue); + expect(session.segmentOutputPaths, [ + 'content://recordings/part1', + 'content://recordings/part2', + ]); + }, + ); }); group('recordingFileSavePermissionsForHost', () { diff --git a/test/features/recording/widget_recording_hud_test.dart b/test/features/recording/widget_recording_hud_test.dart index 511d6dc..b5de3d7 100644 --- a/test/features/recording/widget_recording_hud_test.dart +++ b/test/features/recording/widget_recording_hud_test.dart @@ -10,6 +10,8 @@ void main() { double minZoomRatio = 1.0, double maxZoomRatio = 3.0, bool isRecording = false, + bool isSwitchingLens = false, + Future Function()? onStop, ValueChanged? onZoomSelected, }) async { await tester.pumpWidget( @@ -25,12 +27,13 @@ void main() { notificationsGranted: true, isRecording: isRecording, isStartingRecording: false, + isSwitchingLens: isSwitchingLens, isTouchLocked: false, zoomRatio: zoomRatio, minZoomRatio: minZoomRatio, maxZoomRatio: maxZoomRatio, onStart: () async {}, - onStop: () async {}, + onStop: onStop ?? () async {}, onOpenDnd: () {}, onOpenBattery: () {}, onToggleTouchLock: () {}, @@ -112,7 +115,7 @@ void main() { ); await tester.tap(find.text('0.5x')); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(selected, 0.5); }); @@ -128,7 +131,7 @@ void main() { ); await tester.tap(find.text('0.6x')); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(selected, 0.6); }); @@ -155,7 +158,7 @@ void main() { expect(mainButton.enabled, isFalse); await tester.tap(find.text('0.6x')); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(selected, 0.6); }); @@ -183,8 +186,45 @@ void main() { expect(mainButton.enabled, isTrue); await tester.tap(find.text('1x')); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); expect(selected, 1.0); }); + + testWidgets('disables stop button while switching lens', (tester) async { + var stopped = false; + await pumpHud( + tester, + minZoomRatio: 0.6, + isRecording: true, + isSwitchingLens: true, + onStop: () async => stopped = true, + ); + + await tester.tap(find.byType(GestureDetector).last); + await tester.pump(); + + expect(stopped, isFalse); + }); + + testWidgets('disables zoom buttons while switching lens', (tester) async { + double? selected; + await pumpHud( + tester, + minZoomRatio: 0.6, + isRecording: true, + isSwitchingLens: true, + onZoomSelected: (ratio) => selected = ratio, + ); + + final ultraWideButton = tester.widget( + find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), + ); + + expect(ultraWideButton.enabled, isFalse); + await tester.tap(find.text('0.6x')); + await tester.pump(const Duration(milliseconds: 350)); + + expect(selected, isNull); + }); }