diff --git a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingCameraController.kt b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingCameraController.kt index 4b537d9..9021fae 100644 --- a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingCameraController.kt +++ b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingCameraController.kt @@ -2,6 +2,7 @@ package com.dronex.rec.recording import android.content.Context import android.util.Log +import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider @@ -24,8 +25,10 @@ class RecordingCameraController( private var cameraProvider: ProcessCameraProvider? = null private var preview: Preview? = null private var videoCapture: VideoCapture? = null + private var camera: Camera? = null private var activeRecording: Recording? = null private var boundLifecycleOwner: LifecycleOwner? = null + private var currentZoomRatio: Float = 1f var status: RecordingStatus = RecordingStatus(RecordingState.IDLE) private set @@ -61,12 +64,14 @@ class RecordingCameraController( videoCapture = VideoCapture.withOutput(recorder) provider.unbindAll() - provider.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - videoCapture, - ) + camera = + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + videoCapture, + ) + applyCurrentZoom() updateStatus(RecordingStatus(RecordingState.PREVIEWING)) onReady(true) @@ -108,12 +113,14 @@ class RecordingCameraController( try { boundLifecycleOwner = lifecycleOwner provider.unbindAll() - provider.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - videoCapture, - ) + camera = + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + videoCapture, + ) + applyCurrentZoom() onReady(true) } catch (error: Exception) { Log.e(TAG, "rebindForRecording failed", error) @@ -221,6 +228,52 @@ class RecordingCameraController( activeRecording = null } + fun zoomCapabilitiesMap(): Map { + val zoomState = camera?.cameraInfo?.zoomState?.value + val minZoom = zoomState?.minZoomRatio ?: 1f + val maxZoom = zoomState?.maxZoomRatio ?: 3f + val zoom = (zoomState?.zoomRatio ?: currentZoomRatio).coerceIn(minZoom, maxZoom) + currentZoomRatio = zoom + return mapOf( + "zoomRatio" to zoom.toDouble(), + "minZoomRatio" to minZoom.toDouble(), + "maxZoomRatio" to maxZoom.toDouble(), + ) + } + + fun setZoomRatio( + ratio: Double, + onComplete: (Boolean, Map, String?) -> Unit, + ) { + val boundCamera = camera + if (boundCamera == null) { + val clamped = ratio.toFloat().coerceAtLeast(1f) + currentZoomRatio = clamped + onComplete(true, zoomCapabilitiesMap(), null) + return + } + + val zoomState = boundCamera.cameraInfo.zoomState.value + val minZoom = zoomState?.minZoomRatio ?: 1f + val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom() + val nextZoom = ratio.toFloat().coerceIn(minZoom, maxZoom) + currentZoomRatio = nextZoom + + val future = boundCamera.cameraControl.setZoomRatio(nextZoom) + future.addListener( + { + try { + future.get() + onComplete(true, zoomCapabilitiesMap(), null) + } catch (error: Exception) { + Log.e(TAG, "setZoomRatio failed", error) + onComplete(false, zoomCapabilitiesMap(), error.message) + } + }, + mainExecutor, + ) + } + fun unbind() { activeRecording?.stop() activeRecording = null @@ -228,7 +281,9 @@ class RecordingCameraController( cameraProvider = null preview = null videoCapture = null + camera = null boundLifecycleOwner = null + currentZoomRatio = 1f updateStatus(RecordingStatus(RecordingState.IDLE)) } @@ -242,6 +297,19 @@ class RecordingCameraController( statusListener?.invoke(next) } + private fun applyCurrentZoom() { + val boundCamera = camera ?: return + val zoomState = boundCamera.cameraInfo.zoomState.value + val minZoom = zoomState?.minZoomRatio ?: 1f + val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom() + currentZoomRatio = currentZoomRatio.coerceIn(minZoom, maxZoom) + boundCamera.cameraControl.setZoomRatio(currentZoomRatio) + } + + private fun clampedMaxZoom(): Float { + return camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 3f + } + companion object { private const val TAG = "RecordingCamera" } diff --git a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingPlatformHandler.kt b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingPlatformHandler.kt index f7dc26d..0118205 100644 --- a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingPlatformHandler.kt +++ b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingPlatformHandler.kt @@ -50,6 +50,11 @@ class RecordingPlatformHandler( startRecording(withAudio, enableDnd, displayName, result) } "stopRecording" -> stopRecording(result) + "getZoomCapabilities" -> result.success(controller.zoomCapabilitiesMap()) + "setZoomRatio" -> { + val ratio = call.argument("zoomRatio") ?: 1.0 + setZoomRatio(ratio, result) + } "disposePreview" -> { controller.unbind() result.success(null) @@ -172,6 +177,18 @@ class RecordingPlatformHandler( } } + private fun setZoomRatio(ratio: Double, result: MethodChannel.Result) { + controller.setZoomRatio(ratio) { success, capabilities, message -> + mainHandler.post { + if (success) { + result.success(capabilities) + } else { + result.error("ZOOM_FAILED", message ?: "Failed to set camera zoom", null) + } + } + } + } + private fun deliverStopResult(result: MethodChannel.Result, path: String?) { val gallerySaved = path != null && controller.status.state != RecordingState.ERROR val payload = diff --git a/ios/Runner/RecordingPlugin.swift b/ios/Runner/RecordingPlugin.swift index 3d4f30f..9c9469c 100644 --- a/ios/Runner/RecordingPlugin.swift +++ b/ios/Runner/RecordingPlugin.swift @@ -116,6 +116,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco private var recordingStartedAt: Date? private var elapsedTimer: Timer? private var pendingStopResult: FlutterResult? + private var currentZoomRatio: CGFloat = 1.0 private(set) var status = RecordingStatus(state: .idle) { didSet { @@ -291,6 +292,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco self.session.commitConfiguration() self.videoInput = nil self.audioInput = nil + self.currentZoomRatio = 1.0 self.configured = false self.updateStatus(RecordingStatus(state: .idle)) @@ -312,6 +314,52 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco return status.toMap() } + func zoomCapabilities(result: @escaping FlutterResult) { + sessionQueue.async { [weak self] in + guard let self else { return } + let capabilities = self.currentZoomCapabilitiesMap() + DispatchQueue.main.async { + result(capabilities) + } + } + } + + func setZoomRatio(_ ratio: CGFloat, result: @escaping FlutterResult) { + sessionQueue.async { [weak self] in + guard let self else { return } + guard let device = self.videoInput?.device else { + self.currentZoomRatio = max(1.0, ratio) + let capabilities = self.currentZoomCapabilitiesMap() + DispatchQueue.main.async { + result(capabilities) + } + return + } + + do { + let nextZoom = self.clampedZoomRatio(ratio, for: device) + try device.lockForConfiguration() + device.videoZoomFactor = nextZoom + device.unlockForConfiguration() + self.currentZoomRatio = nextZoom + let capabilities = self.currentZoomCapabilitiesMap() + DispatchQueue.main.async { + result(capabilities) + } + } catch { + DispatchQueue.main.async { + result( + FlutterError( + code: "ZOOM_FAILED", + message: error.localizedDescription, + details: nil + ) + ) + } + } + } + } + func fileOutput( _ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, @@ -465,9 +513,43 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco session.commitConfiguration() configured = true + try applyCurrentZoom() try configureAudioInput(enabled: withAudio) } + private func currentZoomCapabilitiesMap() -> [String: Any] { + guard let device = videoInput?.device else { + return [ + "zoomRatio": Double(currentZoomRatio), + "minZoomRatio": 1.0, + "maxZoomRatio": 3.0, + ] + } + + let minZoom = device.minAvailableVideoZoomFactor + let maxZoom = device.maxAvailableVideoZoomFactor + let zoom = clampedZoomRatio(device.videoZoomFactor, for: device) + currentZoomRatio = zoom + return [ + "zoomRatio": Double(zoom), + "minZoomRatio": Double(minZoom), + "maxZoomRatio": Double(maxZoom), + ] + } + + private func applyCurrentZoom() throws { + guard let device = videoInput?.device else { return } + let nextZoom = clampedZoomRatio(currentZoomRatio, for: device) + try device.lockForConfiguration() + device.videoZoomFactor = nextZoom + device.unlockForConfiguration() + currentZoomRatio = nextZoom + } + + private func clampedZoomRatio(_ ratio: CGFloat, for device: AVCaptureDevice) -> CGFloat { + min(max(ratio, device.minAvailableVideoZoomFactor), device.maxAvailableVideoZoomFactor) + } + private func configureAudioInput(enabled: Bool) throws { session.beginConfiguration() defer { session.commitConfiguration() } @@ -587,6 +669,12 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { controller.startRecording(withAudio: withAudio, displayName: displayName, result: result) case "stopRecording": controller.stopRecording(result: result) + case "getZoomCapabilities": + controller.zoomCapabilities(result: result) + case "setZoomRatio": + let args = call.arguments as? [String: Any] + let ratio = args?["zoomRatio"] as? Double ?? 1.0 + controller.setZoomRatio(CGFloat(ratio), result: result) case "disposePreview": controller.disposePreview(result: result) case "getStatus": diff --git a/lib/features/recording/model/model_recording_session.dart b/lib/features/recording/model/model_recording_session.dart index 32423b5..69a95f2 100644 --- a/lib/features/recording/model/model_recording_session.dart +++ b/lib/features/recording/model/model_recording_session.dart @@ -11,6 +11,9 @@ class RecordingSessionState { this.isBatteryOptimizedIgnored = true, this.notificationsGranted = true, this.isMicrophoneGranted = false, + this.zoomRatio = 1.0, + this.minZoomRatio = 1.0, + this.maxZoomRatio = 3.0, this.lastOutputPath, this.lastSavedDisplayName, this.errorMessage, @@ -26,6 +29,9 @@ class RecordingSessionState { final bool isBatteryOptimizedIgnored; final bool notificationsGranted; final bool isMicrophoneGranted; + final double zoomRatio; + final double minZoomRatio; + final double maxZoomRatio; final String? lastOutputPath; final String? lastSavedDisplayName; final String? errorMessage; @@ -51,6 +57,9 @@ class RecordingSessionState { bool? isBatteryOptimizedIgnored, bool? notificationsGranted, bool? isMicrophoneGranted, + double? zoomRatio, + double? minZoomRatio, + double? maxZoomRatio, String? lastOutputPath, String? lastSavedDisplayName, String? errorMessage, @@ -69,6 +78,9 @@ class RecordingSessionState { isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored, notificationsGranted: notificationsGranted ?? this.notificationsGranted, isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted, + zoomRatio: zoomRatio ?? this.zoomRatio, + minZoomRatio: minZoomRatio ?? this.minZoomRatio, + maxZoomRatio: maxZoomRatio ?? this.maxZoomRatio, lastOutputPath: lastOutputPath ?? this.lastOutputPath, lastSavedDisplayName: clearLastSaved ? null diff --git a/lib/features/recording/pages/page_record.dart b/lib/features/recording/pages/page_record.dart index b3a767c..0fe0696 100644 --- a/lib/features/recording/pages/page_record.dart +++ b/lib/features/recording/pages/page_record.dart @@ -357,10 +357,7 @@ class _PreviewLoadingLayer extends ConsumerWidget { } class _RecordingHudLayer extends ConsumerWidget { - const _RecordingHudLayer({ - required this.onStart, - required this.onStop, - }); + const _RecordingHudLayer({required this.onStart, required this.onStop}); final Future Function() onStart; final Future Function() onStop; @@ -378,6 +375,9 @@ class _RecordingHudLayer extends ConsumerWidget { m.session.isRecording, m.session.isStartingRecording, m.session.isTouchLocked, + m.session.zoomRatio, + m.session.minZoomRatio, + m.session.maxZoomRatio, m.hasValidClipboardInfo, m.clipboardRecordingModel.address.trim(), ), @@ -392,6 +392,9 @@ class _RecordingHudLayer extends ConsumerWidget { isRecording, isStartingRecording, isTouchLocked, + zoomRatio, + minZoomRatio, + maxZoomRatio, showClipboardHint, clipboardAddress, ) = hudState; @@ -406,6 +409,9 @@ class _RecordingHudLayer extends ConsumerWidget { isRecording: isRecording, isStartingRecording: isStartingRecording, isTouchLocked: isTouchLocked, + zoomRatio: zoomRatio, + minZoomRatio: minZoomRatio, + maxZoomRatio: maxZoomRatio, showClipboardHint: showClipboardHint, clipboardAddress: clipboardAddress, onStart: onStart, @@ -419,9 +425,15 @@ class _RecordingHudLayer extends ConsumerWidget { await viewModel.refreshBatteryOptimization(); }, onToggleTouchLock: () { - final locked = ref.read(recordingViewModelProvider).session.isTouchLocked; + final locked = ref + .read(recordingViewModelProvider) + .session + .isTouchLocked; viewModel.setTouchLocked(!locked); }, + onZoomSelected: (ratio) async { + await viewModel.setZoomRatio(ratio); + }, ); } } diff --git a/lib/features/recording/platform/recording_platform.dart b/lib/features/recording/platform/recording_platform.dart index c2d2c5d..10fbe91 100644 --- a/lib/features/recording/platform/recording_platform.dart +++ b/lib/features/recording/platform/recording_platform.dart @@ -81,6 +81,21 @@ class RecordingPlatform { return RecordingStatus.fromMap(result ?? const {}); } + static Future getZoomCapabilities() async { + final result = await _channel.invokeMapMethod( + 'getZoomCapabilities', + ); + return RecordingZoomCapabilities.fromMap(result); + } + + static Future setZoomRatio(double ratio) async { + final result = await _channel.invokeMapMethod( + 'setZoomRatio', + {'zoomRatio': ratio}, + ); + return RecordingZoomCapabilities.fromMap(result); + } + static Future startRecording({ bool withAudio = true, bool enableDoNotDisturb = true, @@ -156,6 +171,29 @@ class RecordingPlatform { } } +class RecordingZoomCapabilities { + const RecordingZoomCapabilities({ + required this.zoomRatio, + required this.minZoomRatio, + required this.maxZoomRatio, + }); + + final double zoomRatio; + final double minZoomRatio; + final double maxZoomRatio; + + factory RecordingZoomCapabilities.fromMap(Map? map) { + final minZoomRatio = (map?['minZoomRatio'] as num?)?.toDouble() ?? 1.0; + final maxZoomRatio = (map?['maxZoomRatio'] as num?)?.toDouble() ?? 3.0; + final zoomRatio = (map?['zoomRatio'] as num?)?.toDouble() ?? minZoomRatio; + return RecordingZoomCapabilities( + zoomRatio: zoomRatio.clamp(minZoomRatio, maxZoomRatio).toDouble(), + minZoomRatio: minZoomRatio, + maxZoomRatio: maxZoomRatio, + ); + } +} + class RecordingStartResult { const RecordingStartResult({this.outputPath, required this.status}); diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart index 49fa4ba..d8d9a6c 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -193,6 +193,7 @@ class RecordingViewModel extends Notifier { await _listenStatus(); try { final status = await _initializePreviewWithRetry(); + await _refreshZoomCapabilities(); _updateSession( (s) => s.copyWith( status: status, @@ -239,6 +240,7 @@ class RecordingViewModel extends Notifier { ); try { final status = await _initializePreviewWithRetry(); + await _refreshZoomCapabilities(); _updateSession( (s) => s.copyWith( status: status, @@ -309,6 +311,47 @@ class RecordingViewModel extends Notifier { return status?.isGranted == true || status?.isLimited == true; } + /// 读取相机支持的倍距范围并同步当前倍距。 + Future _refreshZoomCapabilities() async { + try { + final zoom = await RecordingPlatform.getZoomCapabilities(); + _updateSession( + (s) => s.copyWith( + zoomRatio: zoom.zoomRatio, + minZoomRatio: zoom.minZoomRatio, + maxZoomRatio: zoom.maxZoomRatio, + errorMessage: null, + ), + ); + } on PlatformException catch (error) { + AppLogger.debug('读取相机倍距能力失败', error: error); + } + } + + /// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。 + Future setZoomRatio(double ratio) async { + final session = state.session; + final clamped = ratio + .clamp(session.minZoomRatio, session.maxZoomRatio) + .toDouble(); + + try { + final zoom = await RecordingPlatform.setZoomRatio(clamped); + _updateSession( + (s) => s.copyWith( + zoomRatio: zoom.zoomRatio, + minZoomRatio: zoom.minZoomRatio, + maxZoomRatio: zoom.maxZoomRatio, + errorMessage: null, + ), + ); + } on PlatformException catch (error) { + _updateSession( + (s) => s.copyWith(errorMessage: error.message ?? '相机倍距设置失败'), + ); + } + } + /// 开始录制,可选开启勿扰模式。 Future startRecording({bool enableDoNotDisturb = true}) async { final session = state.session; diff --git a/lib/features/recording/widgets/widget_record_header.dart b/lib/features/recording/widgets/widget_record_header.dart index 0b99cec..cde1f34 100644 --- a/lib/features/recording/widgets/widget_record_header.dart +++ b/lib/features/recording/widgets/widget_record_header.dart @@ -170,7 +170,7 @@ class _HeaderPasteActions extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - // _HeaderActionButton(label: 'mock', onPressed: onMockCopy), + _HeaderActionButton(label: 'mock', onPressed: onMockCopy), _HeaderActionButton( label: '粘贴选手信息', onPressed: () => onPasteEventInfo(), diff --git a/lib/features/recording/widgets/widget_recording_hud.dart b/lib/features/recording/widgets/widget_recording_hud.dart index eb5a7ee..c571a32 100644 --- a/lib/features/recording/widgets/widget_recording_hud.dart +++ b/lib/features/recording/widgets/widget_recording_hud.dart @@ -21,11 +21,15 @@ class RecordingHudWidget extends StatelessWidget { required this.isTouchLocked, this.showClipboardHint = false, this.clipboardAddress = '', + required this.zoomRatio, + required this.minZoomRatio, + required this.maxZoomRatio, required this.onStart, required this.onStop, required this.onOpenDnd, required this.onOpenBattery, required this.onToggleTouchLock, + required this.onZoomSelected, }); final String? errorMessage; @@ -38,16 +42,21 @@ class RecordingHudWidget extends StatelessWidget { final bool isTouchLocked; final bool showClipboardHint; final String clipboardAddress; + final double zoomRatio; + final double minZoomRatio; + final double maxZoomRatio; final Future Function() onStart; final Future Function() onStop; final VoidCallback onOpenDnd; final VoidCallback onOpenBattery; final VoidCallback onToggleTouchLock; + final ValueChanged onZoomSelected; static double get _recordButtonSize => 70.r; static double get _recordButtonBottom => 63.r; static double get _overlayInfoLeft => 13.r; static double get _overlayInfoBottom => 10.r; + static const List _zoomPresets = [1.0, 2.0, 3.0]; @override Widget build(BuildContext context) { @@ -133,6 +142,17 @@ class RecordingHudWidget extends StatelessWidget { ), ), ), + Positioned( + right: 16.r, + bottom: _recordButtonBottom + _recordButtonSize + 14.h, + child: _ZoomPresetControl( + zoomRatio: zoomRatio, + minZoomRatio: minZoomRatio, + maxZoomRatio: maxZoomRatio, + presets: _zoomPresets, + onSelected: onZoomSelected, + ), + ), Positioned( left: 0, right: 0, @@ -171,3 +191,94 @@ class RecordingHudWidget extends StatelessWidget { ); } } + +class _ZoomPresetControl extends StatelessWidget { + const _ZoomPresetControl({ + required this.zoomRatio, + required this.minZoomRatio, + required this.maxZoomRatio, + required this.presets, + required this.onSelected, + }); + + final double zoomRatio; + final double minZoomRatio; + final double maxZoomRatio; + final List presets; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final availablePresets = presets + .where((preset) => preset >= minZoomRatio && preset <= maxZoomRatio) + .toList(growable: false); + + if (availablePresets.isEmpty) { + return const SizedBox.shrink(); + } + + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.46), + borderRadius: BorderRadius.circular(18.r), + border: Border.all(color: Colors.white.withValues(alpha: 0.18)), + ), + child: Padding( + padding: EdgeInsets.all(3.r), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final preset in availablePresets) + _ZoomPresetButton( + ratio: preset, + selected: (zoomRatio - preset).abs() < 0.05, + onSelected: onSelected, + ), + ], + ), + ), + ); + } +} + +class _ZoomPresetButton extends StatelessWidget { + const _ZoomPresetButton({ + required this.ratio, + required this.selected, + required this.onSelected, + }); + + final double ratio; + final bool selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 1.r), + child: TextButton( + onPressed: selected ? null : () => onSelected(ratio), + style: TextButton.styleFrom( + minimumSize: Size(38.r, 32.r), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: selected ? Colors.black : Colors.white, + disabledForegroundColor: Colors.black, + backgroundColor: selected ? Colors.white : Colors.transparent, + disabledBackgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.r), + ), + ), + child: Text( + '${ratio.toStringAsFixed(0)}x', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + letterSpacing: 0, + ), + ), + ), + ); + } +} diff --git a/test/features/recording/view_model_recording_test.dart b/test/features/recording/view_model_recording_test.dart index 43b1f8d..f427e3e 100644 --- a/test/features/recording/view_model_recording_test.dart +++ b/test/features/recording/view_model_recording_test.dart @@ -2,6 +2,7 @@ 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/platform/recording_channel_names.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; void main() { @@ -24,6 +25,11 @@ void main() { tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + null, + ); }); group('RecordingViewModel', () { @@ -36,9 +42,89 @@ void main() { expect(model.clipboardRecordingModel.title, defaultClipboardTitle); expect(model.session.isPreviewReady, isFalse); expect(model.session.isRecording, isFalse); + expect(model.session.zoomRatio, 1.0); + expect(model.session.minZoomRatio, 1.0); + expect(model.session.maxZoomRatio, 3.0); }); }); + group('RecordingViewModel.setZoomRatio', () { + test('updates zoom ratio from native response', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + expect(call.method, 'setZoomRatio'); + expect(call.arguments, {'zoomRatio': 2.0}); + return { + 'zoomRatio': 2.0, + 'minZoomRatio': 1.0, + 'maxZoomRatio': 3.0, + }; + }, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container.read(recordingViewModelProvider.notifier).setZoomRatio(2); + + final session = container.read(recordingViewModelProvider).session; + expect(session.zoomRatio, 2.0); + expect(session.minZoomRatio, 1.0); + expect(session.maxZoomRatio, 3.0); + expect(session.errorMessage, isNull); + }); + + test('clamps requested zoom ratio before invoking native', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + calls.add(call); + return { + 'zoomRatio': 1.0, + 'minZoomRatio': 1.0, + 'maxZoomRatio': 1.0, + }; + }, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container.read(recordingViewModelProvider.notifier).setZoomRatio(4); + + expect(calls.single.arguments, {'zoomRatio': 3.0}); + expect(container.read(recordingViewModelProvider).session.zoomRatio, 1.0); + }); + + test( + 'keeps previous zoom ratio and stores error when native fails', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + throw PlatformException( + code: 'ZOOM_FAILED', + message: 'Zoom is unavailable', + ); + }, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container + .read(recordingViewModelProvider.notifier) + .setZoomRatio(2); + + final session = container.read(recordingViewModelProvider).session; + expect(session.zoomRatio, 1.0); + expect(session.errorMessage, 'Zoom is unavailable'); + }, + ); + }); + group('recordingGalleryPermissionsForHost', () { test('requests only add-only photo permission on iOS', () { final permissions = recordingGalleryPermissionsForHost( diff --git a/test/features/recording/widget_recording_hud_test.dart b/test/features/recording/widget_recording_hud_test.dart new file mode 100644 index 0000000..b0a2b2a --- /dev/null +++ b/test/features/recording/widget_recording_hud_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recording_tool/features/recording/widgets/widget_recording_hud.dart'; + +void main() { + Future pumpHud( + WidgetTester tester, { + double zoomRatio = 1.0, + double minZoomRatio = 1.0, + double maxZoomRatio = 3.0, + ValueChanged? onZoomSelected, + }) async { + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(375, 812), + builder: (context, _) { + return MaterialApp( + home: Scaffold( + backgroundColor: Colors.black, + body: RecordingHudWidget( + hasDndAccess: true, + isBatteryOptimizedIgnored: true, + notificationsGranted: true, + isRecording: false, + isStartingRecording: false, + isTouchLocked: false, + zoomRatio: zoomRatio, + minZoomRatio: minZoomRatio, + maxZoomRatio: maxZoomRatio, + onStart: () async {}, + onStop: () async {}, + onOpenDnd: () {}, + onOpenBattery: () {}, + onToggleTouchLock: () {}, + onZoomSelected: onZoomSelected ?? (_) {}, + ), + ), + ); + }, + ), + ); + await tester.pump(); + } + + testWidgets('shows preset zoom buttons', (tester) async { + await pumpHud(tester); + + expect(find.text('1x'), findsOneWidget); + expect(find.text('2x'), findsOneWidget); + expect(find.text('3x'), findsOneWidget); + }); + + testWidgets('marks current zoom ratio as selected', (tester) async { + await pumpHud(tester, zoomRatio: 2.0); + + final selectedButton = tester.widget( + find.ancestor(of: find.text('2x'), matching: find.byType(TextButton)), + ); + expect(selectedButton.enabled, isFalse); + }); + + testWidgets('does not expose presets beyond max zoom ratio', (tester) async { + await pumpHud(tester, maxZoomRatio: 2.0); + + expect(find.text('1x'), findsOneWidget); + expect(find.text('2x'), findsOneWidget); + expect(find.text('3x'), findsNothing); + }); + + testWidgets('tapping zoom preset reports selected ratio', (tester) async { + double? selected; + await pumpHud(tester, onZoomSelected: (ratio) => selected = ratio); + + await tester.tap(find.text('2x')); + await tester.pump(); + + expect(selected, 2.0); + }); +}