1.广角/主摄切换按钮已上移:

2.新增 isSwitchingLens 状态、避免切换镜头    stop 按钮禁用,不会触发 stopRecording
倍距按钮禁用,避免连续切换
ViewModel 层也兜底:isSwitchingLens=true 时直接拒绝 stop
3.合并失败兜底提示
This commit is contained in:
2026-07-01 10:59:28 +08:00
parent 02614c2817
commit c8a7494344
6 changed files with 205 additions and 11 deletions
@@ -7,6 +7,7 @@ class RecordingSessionState {
this.isTouchLocked = true, this.isTouchLocked = true,
this.isPreviewReady = false, this.isPreviewReady = false,
this.isStartingRecording = false, this.isStartingRecording = false,
this.isSwitchingLens = false,
this.hasDndAccess = false, this.hasDndAccess = false,
this.isBatteryOptimizedIgnored = true, this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true, this.notificationsGranted = true,
@@ -19,12 +20,14 @@ class RecordingSessionState {
this.errorMessage, this.errorMessage,
this.permissionWarning, this.permissionWarning,
this.fileSaveFailed = false, this.fileSaveFailed = false,
this.segmentOutputPaths = const [],
}); });
final RecordingStatus status; final RecordingStatus status;
final bool isTouchLocked; final bool isTouchLocked;
final bool isPreviewReady; final bool isPreviewReady;
final bool isStartingRecording; final bool isStartingRecording;
final bool isSwitchingLens;
final bool hasDndAccess; final bool hasDndAccess;
final bool isBatteryOptimizedIgnored; final bool isBatteryOptimizedIgnored;
final bool notificationsGranted; final bool notificationsGranted;
@@ -37,6 +40,7 @@ class RecordingSessionState {
final String? errorMessage; final String? errorMessage;
final String? permissionWarning; final String? permissionWarning;
final bool fileSaveFailed; final bool fileSaveFailed;
final List<String> segmentOutputPaths;
bool get isRecording => status.isRecording; bool get isRecording => status.isRecording;
@@ -53,6 +57,7 @@ class RecordingSessionState {
bool? isTouchLocked, bool? isTouchLocked,
bool? isPreviewReady, bool? isPreviewReady,
bool? isStartingRecording, bool? isStartingRecording,
bool? isSwitchingLens,
bool? hasDndAccess, bool? hasDndAccess,
bool? isBatteryOptimizedIgnored, bool? isBatteryOptimizedIgnored,
bool? notificationsGranted, bool? notificationsGranted,
@@ -65,6 +70,7 @@ class RecordingSessionState {
String? errorMessage, String? errorMessage,
String? permissionWarning, String? permissionWarning,
bool? fileSaveFailed, bool? fileSaveFailed,
List<String>? segmentOutputPaths,
bool clearPermissionWarning = false, bool clearPermissionWarning = false,
bool clearLastSaved = false, bool clearLastSaved = false,
}) { }) {
@@ -73,6 +79,7 @@ class RecordingSessionState {
isTouchLocked: isTouchLocked ?? this.isTouchLocked, isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady, isPreviewReady: isPreviewReady ?? this.isPreviewReady,
isStartingRecording: isStartingRecording ?? this.isStartingRecording, isStartingRecording: isStartingRecording ?? this.isStartingRecording,
isSwitchingLens: isSwitchingLens ?? this.isSwitchingLens,
hasDndAccess: hasDndAccess ?? this.hasDndAccess, hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored: isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored, isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
@@ -90,6 +97,7 @@ class RecordingSessionState {
? null ? null
: (permissionWarning ?? this.permissionWarning), : (permissionWarning ?? this.permissionWarning),
fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed, fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed,
segmentOutputPaths: segmentOutputPaths ?? this.segmentOutputPaths,
); );
} }
} }
@@ -173,7 +173,11 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
if (!mounted) return; if (!mounted) return;
final latest = ref.read(recordingViewModelProvider).session; final latest = ref.read(recordingViewModelProvider).session;
if (latest.fileSaveFailed) { if (latest.fileSaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限'); if (latest.segmentOutputPaths.isNotEmpty) {
AppToast.show('视频合并失败,已为你保存分段文件,可在相册中查看');
} else {
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
}
return; return;
} }
await _showRecordingSavedDialogIfNeeded(); await _showRecordingSavedDialogIfNeeded();
@@ -374,6 +378,7 @@ class _RecordingHudLayer extends ConsumerWidget {
m.session.notificationsGranted, m.session.notificationsGranted,
m.session.isRecording, m.session.isRecording,
m.session.isStartingRecording, m.session.isStartingRecording,
m.session.isSwitchingLens,
m.session.isTouchLocked, m.session.isTouchLocked,
m.session.zoomRatio, m.session.zoomRatio,
m.session.minZoomRatio, m.session.minZoomRatio,
@@ -391,6 +396,7 @@ class _RecordingHudLayer extends ConsumerWidget {
notificationsGranted, notificationsGranted,
isRecording, isRecording,
isStartingRecording, isStartingRecording,
isSwitchingLens,
isTouchLocked, isTouchLocked,
zoomRatio, zoomRatio,
minZoomRatio, minZoomRatio,
@@ -408,6 +414,7 @@ class _RecordingHudLayer extends ConsumerWidget {
notificationsGranted: notificationsGranted, notificationsGranted: notificationsGranted,
isRecording: isRecording, isRecording: isRecording,
isStartingRecording: isStartingRecording, isStartingRecording: isStartingRecording,
isSwitchingLens: isSwitchingLens,
isTouchLocked: isTouchLocked, isTouchLocked: isTouchLocked,
zoomRatio: zoomRatio, zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio, minZoomRatio: minZoomRatio,
@@ -349,10 +349,14 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。 /// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。
Future<void> setZoomRatio(double ratio) async { Future<void> setZoomRatio(double ratio) async {
final session = state.session; final session = state.session;
if (session.isSwitchingLens) {
return;
}
final clamped = ratio final clamped = ratio
.clamp(session.minZoomRatio, session.maxZoomRatio) .clamp(session.minZoomRatio, session.maxZoomRatio)
.toDouble(); .toDouble();
_updateSession((s) => s.copyWith(isSwitchingLens: true));
try { try {
final zoom = await RecordingPlatform.setZoomRatio(clamped); final zoom = await RecordingPlatform.setZoomRatio(clamped);
_updateSession( _updateSession(
@@ -368,13 +372,19 @@ class RecordingViewModel extends Notifier<RecordingModel> {
? '切换镜头失败,请重试' ? '切换镜头失败,请重试'
: (error.message ?? '相机倍距设置失败'); : (error.message ?? '相机倍距设置失败');
_updateSession((s) => s.copyWith(errorMessage: message)); _updateSession((s) => s.copyWith(errorMessage: message));
} finally {
_updateSession(
(s) => s.copyWith(isSwitchingLens: false, errorMessage: s.errorMessage),
);
} }
} }
/// 开始录制,可选开启勿扰模式。 /// 开始录制,可选开启勿扰模式。
Future<void> startRecording({bool enableDoNotDisturb = true}) async { Future<void> startRecording({bool enableDoNotDisturb = true}) async {
final session = state.session; final session = state.session;
if (session.isRecording || session.isStartingRecording) { if (session.isRecording ||
session.isStartingRecording ||
session.isSwitchingLens) {
return; return;
} }
if (!session.isPreviewReady) { if (!session.isPreviewReady) {
@@ -401,6 +411,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
isTouchLocked: true, isTouchLocked: true,
errorMessage: null, errorMessage: null,
fileSaveFailed: false, fileSaveFailed: false,
segmentOutputPaths: const [],
clearLastSaved: true, clearLastSaved: true,
), ),
); );
@@ -415,7 +426,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 停止录制、保存到文件夹,并恢复相机预览。 /// 停止录制、保存到文件夹,并恢复相机预览。
Future<void> stopRecording() async { Future<void> stopRecording() async {
if (!state.session.isRecording) return; if (!state.session.isRecording || state.session.isSwitchingLens) return;
try { try {
final result = await RecordingPlatform.stopRecording(); final result = await RecordingPlatform.stopRecording();
@@ -432,6 +443,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限') ? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限')
: null, : null,
fileSaveFailed: fileFailed, fileSaveFailed: fileFailed,
segmentOutputPaths: result.segmentOutputPaths,
), ),
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {
@@ -18,6 +18,7 @@ class RecordingHudWidget extends StatelessWidget {
required this.notificationsGranted, required this.notificationsGranted,
required this.isRecording, required this.isRecording,
required this.isStartingRecording, required this.isStartingRecording,
required this.isSwitchingLens,
required this.isTouchLocked, required this.isTouchLocked,
this.showClipboardHint = false, this.showClipboardHint = false,
this.clipboardAddress = '', this.clipboardAddress = '',
@@ -39,6 +40,7 @@ class RecordingHudWidget extends StatelessWidget {
final bool notificationsGranted; final bool notificationsGranted;
final bool isRecording; final bool isRecording;
final bool isStartingRecording; final bool isStartingRecording;
final bool isSwitchingLens;
final bool isTouchLocked; final bool isTouchLocked;
final bool showClipboardHint; final bool showClipboardHint;
final String clipboardAddress; final String clipboardAddress;
@@ -143,8 +145,9 @@ class RecordingHudWidget extends StatelessWidget {
), ),
Positioned( Positioned(
right: 16.r, right: 16.r,
bottom: _recordButtonBottom + _recordButtonSize + 14.h, bottom: 260.r,
child: _ZoomPresetControl( child: _ZoomPresetControl(
enabled: !isSwitchingLens,
zoomRatio: zoomRatio, zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio, minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio, maxZoomRatio: maxZoomRatio,
@@ -160,7 +163,7 @@ class RecordingHudWidget extends StatelessWidget {
child: RecordingControlButton( child: RecordingControlButton(
isRecording: isRecording, isRecording: isRecording,
isStartingRecording: isStartingRecording, isStartingRecording: isStartingRecording,
enabled: !isStartingRecording, enabled: !isStartingRecording && !isSwitchingLens,
size: _recordButtonSize, size: _recordButtonSize,
onTap: () { onTap: () {
if (isRecording) { if (isRecording) {
@@ -197,6 +200,7 @@ class RecordingHudWidget extends StatelessWidget {
class _ZoomPresetControl extends StatelessWidget { class _ZoomPresetControl extends StatelessWidget {
const _ZoomPresetControl({ const _ZoomPresetControl({
required this.enabled,
required this.zoomRatio, required this.zoomRatio,
required this.minZoomRatio, required this.minZoomRatio,
required this.maxZoomRatio, required this.maxZoomRatio,
@@ -204,6 +208,7 @@ class _ZoomPresetControl extends StatelessWidget {
required this.onSelected, required this.onSelected,
}); });
final bool enabled;
final double zoomRatio; final double zoomRatio;
final double minZoomRatio; final double minZoomRatio;
final double maxZoomRatio; final double maxZoomRatio;
@@ -235,7 +240,7 @@ class _ZoomPresetControl extends StatelessWidget {
displayRatio: preset, displayRatio: preset,
requestRatio: preset, requestRatio: preset,
selected: _isPresetSelected(preset), selected: _isPresetSelected(preset),
enabled: true, enabled: enabled,
onSelected: onSelected, onSelected: onSelected,
), ),
], ],
@@ -1,9 +1,12 @@
import 'dart:async';
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_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:permission_handler/permission_handler.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/model/model_recording_session.dart';
import 'package:recording_tool/features/recording/platform/recording_channel_names.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'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
void main() { void main() {
@@ -255,6 +258,125 @@ void main() {
expect(session.errorMessage, '切换镜头失败,请重试'); expect(session.errorMessage, '切换镜头失败,请重试');
}, },
); );
test('sets switching lens while native zoom request is pending', () async {
final completer = Completer<Map<String, dynamic>>();
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<void>.delayed(Duration.zero);
expect(
container.read(recordingViewModelProvider).session.isSwitchingLens,
isTrue,
);
completer.complete(<String, dynamic>{
'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 = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{};
},
);
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 <String, dynamic>{
'outputPath': 'content://recordings/part1',
'status': <String, dynamic>{
'state': 'error',
'message': 'Merge failed',
},
'fileSaved': false,
'fileErrorMessage': 'Merge failed',
'segmentOutputPaths': <String>[
'content://recordings/part1',
'content://recordings/part2',
],
};
case 'initializePreview':
return <String, dynamic>{'state': 'previewing'};
case 'getZoomCapabilities':
return <String, dynamic>{
'zoomRatio': 1.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
};
}
return <String, dynamic>{};
},
);
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, <String>[
'content://recordings/part1',
'content://recordings/part2',
]);
},
);
}); });
group('recordingFileSavePermissionsForHost', () { group('recordingFileSavePermissionsForHost', () {
@@ -10,6 +10,8 @@ void main() {
double minZoomRatio = 1.0, double minZoomRatio = 1.0,
double maxZoomRatio = 3.0, double maxZoomRatio = 3.0,
bool isRecording = false, bool isRecording = false,
bool isSwitchingLens = false,
Future<void> Function()? onStop,
ValueChanged<double>? onZoomSelected, ValueChanged<double>? onZoomSelected,
}) async { }) async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -25,12 +27,13 @@ void main() {
notificationsGranted: true, notificationsGranted: true,
isRecording: isRecording, isRecording: isRecording,
isStartingRecording: false, isStartingRecording: false,
isSwitchingLens: isSwitchingLens,
isTouchLocked: false, isTouchLocked: false,
zoomRatio: zoomRatio, zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio, minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio, maxZoomRatio: maxZoomRatio,
onStart: () async {}, onStart: () async {},
onStop: () async {}, onStop: onStop ?? () async {},
onOpenDnd: () {}, onOpenDnd: () {},
onOpenBattery: () {}, onOpenBattery: () {},
onToggleTouchLock: () {}, onToggleTouchLock: () {},
@@ -112,7 +115,7 @@ void main() {
); );
await tester.tap(find.text('0.5x')); await tester.tap(find.text('0.5x'));
await tester.pump(); await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.5); expect(selected, 0.5);
}); });
@@ -128,7 +131,7 @@ void main() {
); );
await tester.tap(find.text('0.6x')); await tester.tap(find.text('0.6x'));
await tester.pump(); await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.6); expect(selected, 0.6);
}); });
@@ -155,7 +158,7 @@ void main() {
expect(mainButton.enabled, isFalse); expect(mainButton.enabled, isFalse);
await tester.tap(find.text('0.6x')); await tester.tap(find.text('0.6x'));
await tester.pump(); await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.6); expect(selected, 0.6);
}); });
@@ -183,8 +186,45 @@ void main() {
expect(mainButton.enabled, isTrue); expect(mainButton.enabled, isTrue);
await tester.tap(find.text('1x')); await tester.tap(find.text('1x'));
await tester.pump(); await tester.pump(const Duration(milliseconds: 350));
expect(selected, 1.0); 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<TextButton>(
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);
});
} }