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 9021fae..e4b8cf8 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 @@ -1,7 +1,10 @@ package com.dronex.rec.recording import android.content.Context +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager import android.util.Log +import androidx.camera.camera2.interop.Camera2CameraInfo import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.Preview @@ -15,6 +18,7 @@ import androidx.camera.video.VideoRecordEvent import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner +import kotlin.math.atan import java.util.concurrent.Executor class RecordingCameraController( @@ -26,6 +30,9 @@ class RecordingCameraController( private var preview: Preview? = null private var videoCapture: VideoCapture? = null private var camera: Camera? = null + private var mainCameraId: String? = null + private var ultraWideCameraId: String? = null + private var currentLensMode: LensMode = LensMode.MAIN private var activeRecording: Recording? = null private var boundLifecycleOwner: LifecycleOwner? = null private var currentZoomRatio: Float = 1f @@ -63,14 +70,8 @@ class RecordingCameraController( .build() videoCapture = VideoCapture.withOutput(recorder) - provider.unbindAll() - camera = - provider.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - videoCapture, - ) + discoverBackCameras(provider) + bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode()) applyCurrentZoom() updateStatus(RecordingStatus(RecordingState.PREVIEWING)) @@ -112,14 +113,7 @@ class RecordingCameraController( try { boundLifecycleOwner = lifecycleOwner - provider.unbindAll() - camera = - provider.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - videoCapture, - ) + bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode()) applyCurrentZoom() onReady(true) } catch (error: Exception) { @@ -230,9 +224,14 @@ class RecordingCameraController( fun zoomCapabilitiesMap(): Map { val zoomState = camera?.cameraInfo?.zoomState?.value - val minZoom = zoomState?.minZoomRatio ?: 1f + val minZoom = if (hasUltraWideCamera()) 0.5f else (zoomState?.minZoomRatio ?: 1f) val maxZoom = zoomState?.maxZoomRatio ?: 3f - val zoom = (zoomState?.zoomRatio ?: currentZoomRatio).coerceIn(minZoom, maxZoom) + val zoom = + if (currentLensMode == LensMode.ULTRA_WIDE) { + 0.5f + } else { + (zoomState?.zoomRatio ?: currentZoomRatio).coerceIn(minZoom, maxZoom) + } currentZoomRatio = zoom return mapOf( "zoomRatio" to zoom.toDouble(), @@ -247,12 +246,27 @@ class RecordingCameraController( ) { val boundCamera = camera if (boundCamera == null) { - val clamped = ratio.toFloat().coerceAtLeast(1f) + val clamped = + if (ratio < 1.0 && hasUltraWideCamera()) { + 0.5f + } else { + ratio.toFloat().coerceAtLeast(1f) + } currentZoomRatio = clamped onComplete(true, zoomCapabilitiesMap(), null) return } + if (ratio < 1.0 && hasUltraWideCamera()) { + switchToUltraWide(onComplete) + return + } + + if (currentLensMode == LensMode.ULTRA_WIDE) { + switchToMainAndZoom(ratio, onComplete) + return + } + val zoomState = boundCamera.cameraInfo.zoomState.value val minZoom = zoomState?.minZoomRatio ?: 1f val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom() @@ -283,6 +297,7 @@ class RecordingCameraController( videoCapture = null camera = null boundLifecycleOwner = null + currentLensMode = LensMode.MAIN currentZoomRatio = 1f updateStatus(RecordingStatus(RecordingState.IDLE)) } @@ -299,6 +314,11 @@ class RecordingCameraController( private fun applyCurrentZoom() { val boundCamera = camera ?: return + if (currentLensMode == LensMode.ULTRA_WIDE) { + currentZoomRatio = 0.5f + boundCamera.cameraControl.setZoomRatio(1f) + return + } val zoomState = boundCamera.cameraInfo.zoomState.value val minZoom = zoomState?.minZoomRatio ?: 1f val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom() @@ -310,7 +330,218 @@ class RecordingCameraController( return camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 3f } + private fun discoverBackCameras(provider: ProcessCameraProvider) { + if (mainCameraId == null) { + mainCameraId = cameraIdForSelector(provider, CameraSelector.DEFAULT_BACK_CAMERA) + } + ultraWideCameraId = findUltraWideCameraId(provider, mainCameraId) + if (ultraWideCameraId == null && currentLensMode == LensMode.ULTRA_WIDE) { + currentLensMode = LensMode.MAIN + currentZoomRatio = 1f + } + Log.d(TAG, "mainCameraId=$mainCameraId ultraWideCameraId=$ultraWideCameraId") + } + + private fun cameraIdForSelector( + provider: ProcessCameraProvider, + selector: CameraSelector, + ): String? { + return try { + val infos = selector.filter(provider.availableCameraInfos) + infos.firstOrNull()?.let { Camera2CameraInfo.from(it).cameraId } + } catch (error: Exception) { + Log.w(TAG, "cameraIdForSelector failed", error) + null + } + } + + private fun findUltraWideCameraId( + provider: ProcessCameraProvider, + excludedCameraId: String?, + ): String? { + val manager = appContext.getSystemService(Context.CAMERA_SERVICE) as CameraManager + val candidates = + manager.cameraIdList + .mapNotNull { cameraId -> backCameraProfile(manager, cameraId) } + .filter { it.cameraId != excludedCameraId } + .filter { provider.hasCameraSafely(selectorForCameraId(it.cameraId)) } + .sortedWith( + compareByDescending { it.horizontalFov } + .thenBy { it.minFocalLength }, + ) + + val mainProfile = excludedCameraId?.let { backCameraProfile(manager, it) } + val widest = candidates.firstOrNull() ?: return null + if (mainProfile == null) { + return widest.cameraId + } + + val meaningfullyWider = + widest.horizontalFov > mainProfile.horizontalFov * ULTRA_WIDE_FOV_FACTOR || + widest.minFocalLength < mainProfile.minFocalLength * ULTRA_WIDE_FOCAL_FACTOR + return if (meaningfullyWider) widest.cameraId else null + } + + private fun backCameraProfile( + manager: CameraManager, + cameraId: String, + ): CameraProfile? { + return try { + val characteristics = manager.getCameraCharacteristics(cameraId) + val facing = characteristics.get(CameraCharacteristics.LENS_FACING) + if (facing != CameraCharacteristics.LENS_FACING_BACK) { + return null + } + val focalLengths = + characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) + ?: return null + val physicalSize = + characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE) + ?: return null + val minFocalLength = focalLengths.minOrNull() ?: return null + val horizontalFov = + 2.0 * atan((physicalSize.width / (2.0f * minFocalLength)).toDouble()) + CameraProfile(cameraId, minFocalLength, horizontalFov) + } catch (error: Exception) { + Log.w(TAG, "backCameraProfile failed for cameraId=$cameraId", error) + null + } + } + + private fun selectorForCurrentLensMode(): CameraSelector { + val cameraId = + if (currentLensMode == LensMode.ULTRA_WIDE) { + ultraWideCameraId + } else { + mainCameraId + } + return if (cameraId != null) { + selectorForCameraId(cameraId) + } else { + CameraSelector.DEFAULT_BACK_CAMERA + } + } + + private fun selectorForCameraId(cameraId: String): CameraSelector { + return CameraSelector.Builder() + .addCameraFilter { cameraInfos -> + cameraInfos.filter { Camera2CameraInfo.from(it).cameraId == cameraId } + } + .build() + } + + private fun bindUseCases( + provider: ProcessCameraProvider, + lifecycleOwner: LifecycleOwner, + selector: CameraSelector, + ) { + val boundPreview = preview ?: throw IllegalStateException("Preview is not ready") + val boundVideoCapture = + videoCapture ?: throw IllegalStateException("Video capture is not ready") + provider.unbindAll() + camera = + provider.bindToLifecycle( + lifecycleOwner, + selector, + boundPreview, + boundVideoCapture, + ) + } + + private fun switchToUltraWide( + onComplete: (Boolean, Map, String?) -> Unit, + ) { + val ultraWideId = ultraWideCameraId + if (ultraWideId == null) { + onComplete(false, zoomCapabilitiesMap(), "Ultra-wide camera is unavailable") + return + } + if (currentLensMode == LensMode.ULTRA_WIDE) { + currentZoomRatio = 0.5f + onComplete(true, zoomCapabilitiesMap(), null) + return + } + if (activeRecording != null) { + onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording") + return + } + val provider = cameraProvider + val lifecycleOwner = boundLifecycleOwner + if (provider == null || lifecycleOwner == null) { + onComplete(false, zoomCapabilitiesMap(), "Camera is not ready") + return + } + try { + currentLensMode = LensMode.ULTRA_WIDE + currentZoomRatio = 0.5f + bindUseCases(provider, lifecycleOwner, selectorForCameraId(ultraWideId)) + applyCurrentZoom() + onComplete(true, zoomCapabilitiesMap(), null) + } catch (error: Exception) { + Log.e(TAG, "switchToUltraWide failed", error) + currentLensMode = LensMode.MAIN + currentZoomRatio = 1f + try { + bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode()) + applyCurrentZoom() + } catch (restoreError: Exception) { + Log.e(TAG, "restore main camera after ultra-wide failure failed", restoreError) + } + onComplete(false, zoomCapabilitiesMap(), error.message) + } + } + + private fun switchToMainAndZoom( + ratio: Double, + onComplete: (Boolean, Map, String?) -> Unit, + ) { + if (activeRecording != null) { + onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording") + return + } + val provider = cameraProvider + val lifecycleOwner = boundLifecycleOwner + if (provider == null || lifecycleOwner == null) { + onComplete(false, zoomCapabilitiesMap(), "Camera is not ready") + return + } + try { + currentLensMode = LensMode.MAIN + currentZoomRatio = ratio.toFloat().coerceAtLeast(1f) + bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode()) + setZoomRatio(ratio, onComplete) + } catch (error: Exception) { + Log.e(TAG, "switchToMainAndZoom failed", error) + onComplete(false, zoomCapabilitiesMap(), error.message) + } + } + + private fun hasUltraWideCamera(): Boolean { + return ultraWideCameraId != null + } + + private fun ProcessCameraProvider.hasCameraSafely(selector: CameraSelector): Boolean { + return try { + hasCamera(selector) + } catch (error: Exception) { + false + } + } + + private enum class LensMode { + MAIN, + ULTRA_WIDE, + } + + private data class CameraProfile( + val cameraId: String, + val minFocalLength: Float, + val horizontalFov: Double, + ) + companion object { private const val TAG = "RecordingCamera" + private const val ULTRA_WIDE_FOV_FACTOR = 1.08 + private const val ULTRA_WIDE_FOCAL_FACTOR = 0.92 } } diff --git a/lib/features/recording/widgets/widget_recording_hud.dart b/lib/features/recording/widgets/widget_recording_hud.dart index c571a32..26c0b63 100644 --- a/lib/features/recording/widgets/widget_recording_hud.dart +++ b/lib/features/recording/widgets/widget_recording_hud.dart @@ -56,7 +56,7 @@ class RecordingHudWidget extends StatelessWidget { 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]; + static const List _zoomPresets = [0.6, 1.0]; @override Widget build(BuildContext context) { @@ -146,6 +146,7 @@ class RecordingHudWidget extends StatelessWidget { right: 16.r, bottom: _recordButtonBottom + _recordButtonSize + 14.h, child: _ZoomPresetControl( + isRecording: isRecording, zoomRatio: zoomRatio, minZoomRatio: minZoomRatio, maxZoomRatio: maxZoomRatio, @@ -194,6 +195,7 @@ class RecordingHudWidget extends StatelessWidget { class _ZoomPresetControl extends StatelessWidget { const _ZoomPresetControl({ + required this.isRecording, required this.zoomRatio, required this.minZoomRatio, required this.maxZoomRatio, @@ -201,6 +203,7 @@ class _ZoomPresetControl extends StatelessWidget { required this.onSelected, }); + final bool isRecording; final double zoomRatio; final double minZoomRatio; final double maxZoomRatio; @@ -210,7 +213,7 @@ class _ZoomPresetControl extends StatelessWidget { @override Widget build(BuildContext context) { final availablePresets = presets - .where((preset) => preset >= minZoomRatio && preset <= maxZoomRatio) + .where(_isPresetAvailable) .toList(growable: false); if (availablePresets.isEmpty) { @@ -230,8 +233,10 @@ class _ZoomPresetControl extends StatelessWidget { children: [ for (final preset in availablePresets) _ZoomPresetButton( - ratio: preset, - selected: (zoomRatio - preset).abs() < 0.05, + displayRatio: preset, + requestRatio: _requestRatioFor(preset), + selected: _isPresetSelected(preset), + enabled: !_wouldSwitchPhysicalCamera(preset), onSelected: onSelected, ), ], @@ -239,17 +244,51 @@ class _ZoomPresetControl extends StatelessWidget { ), ); } + + bool _isPresetAvailable(double preset) { + if (preset < 1.0) { + return minZoomRatio <= preset && maxZoomRatio >= preset; + } + return preset >= minZoomRatio && preset <= maxZoomRatio; + } + + bool _isPresetSelected(double preset) { + if (preset < 1.0) { + return zoomRatio < 1.0; + } + return (zoomRatio - preset).abs() < 0.05; + } + + double _requestRatioFor(double preset) { + if (preset < 1.0) { + return minZoomRatio <= 0.5 ? 0.5 : preset; + } + return preset; + } + + bool _wouldSwitchPhysicalCamera(double preset) { + if (!isRecording) { + return false; + } + final currentIsUltraWide = zoomRatio < 1.0; + final targetIsUltraWide = preset < 1.0; + return currentIsUltraWide != targetIsUltraWide; + } } class _ZoomPresetButton extends StatelessWidget { const _ZoomPresetButton({ - required this.ratio, + required this.displayRatio, + required this.requestRatio, required this.selected, + required this.enabled, required this.onSelected, }); - final double ratio; + final double displayRatio; + final double requestRatio; final bool selected; + final bool enabled; final ValueChanged onSelected; @override @@ -257,7 +296,7 @@ class _ZoomPresetButton extends StatelessWidget { return Padding( padding: EdgeInsets.symmetric(horizontal: 1.r), child: TextButton( - onPressed: selected ? null : () => onSelected(ratio), + onPressed: selected || !enabled ? null : () => onSelected(requestRatio), style: TextButton.styleFrom( minimumSize: Size(38.r, 32.r), padding: EdgeInsets.zero, @@ -271,7 +310,7 @@ class _ZoomPresetButton extends StatelessWidget { ), ), child: Text( - '${ratio.toStringAsFixed(0)}x', + '${_formatZoomRatio(displayRatio)}x', style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w700, @@ -281,4 +320,11 @@ class _ZoomPresetButton extends StatelessWidget { ), ); } + + String _formatZoomRatio(double ratio) { + if (ratio == ratio.roundToDouble()) { + return ratio.toStringAsFixed(0); + } + return ratio.toStringAsFixed(1); + } } diff --git a/test/features/recording/view_model_recording_test.dart b/test/features/recording/view_model_recording_test.dart index f427e3e..c568b60 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/model/model_recording_session.dart'; import 'package:recording_tool/features/recording/platform/recording_channel_names.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; @@ -75,6 +76,83 @@ void main() { expect(session.errorMessage, isNull); }); + test( + 'passes native ultra-wide ratio when camera capabilities allow it', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + calls.add(call); + return { + 'zoomRatio': 0.5, + 'minZoomRatio': 0.5, + 'maxZoomRatio': 3.0, + }; + }, + ); + 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( + zoomRatio: 1.0, + minZoomRatio: 0.5, + maxZoomRatio: 3.0, + ), + ); + + await notifier.setZoomRatio(0.5); + + expect(calls.single.arguments, {'zoomRatio': 0.5}); + final session = container.read(recordingViewModelProvider).session; + expect(session.zoomRatio, 0.5); + expect(session.minZoomRatio, 0.5); + expect(session.maxZoomRatio, 3.0); + }, + ); + + test('passes 0.6x to native when camera capabilities allow it', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + calls.add(call); + return { + 'zoomRatio': 0.6, + 'minZoomRatio': 0.6, + 'maxZoomRatio': 3.0, + }; + }, + ); + 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( + zoomRatio: 1.0, + minZoomRatio: 0.6, + maxZoomRatio: 3.0, + ), + ); + + await notifier.setZoomRatio(0.6); + + expect(calls.single.arguments, {'zoomRatio': 0.6}); + final session = container.read(recordingViewModelProvider).session; + expect(session.zoomRatio, 0.6); + expect(session.minZoomRatio, 0.6); + expect(session.maxZoomRatio, 3.0); + }); + test('clamps requested zoom ratio before invoking native', () async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -98,6 +176,37 @@ void main() { expect(container.read(recordingViewModelProvider).session.zoomRatio, 1.0); }); + test( + 'clamps 0.6x to 1x when camera capabilities do not allow it', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + calls.add(call); + return { + 'zoomRatio': 1.0, + 'minZoomRatio': 1.0, + 'maxZoomRatio': 3.0, + }; + }, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container + .read(recordingViewModelProvider.notifier) + .setZoomRatio(0.6); + + expect(calls.single.arguments, {'zoomRatio': 1.0}); + expect( + container.read(recordingViewModelProvider).session.zoomRatio, + 1.0, + ); + }, + ); + test( 'keeps previous zoom ratio and stores error when native fails', () async { diff --git a/test/features/recording/widget_recording_hud_test.dart b/test/features/recording/widget_recording_hud_test.dart index b0a2b2a..97aad5f 100644 --- a/test/features/recording/widget_recording_hud_test.dart +++ b/test/features/recording/widget_recording_hud_test.dart @@ -9,6 +9,7 @@ void main() { double zoomRatio = 1.0, double minZoomRatio = 1.0, double maxZoomRatio = 3.0, + bool isRecording = false, ValueChanged? onZoomSelected, }) async { await tester.pumpWidget( @@ -22,7 +23,7 @@ void main() { hasDndAccess: true, isBatteryOptimizedIgnored: true, notificationsGranted: true, - isRecording: false, + isRecording: isRecording, isStartingRecording: false, isTouchLocked: false, zoomRatio: zoomRatio, @@ -46,35 +47,120 @@ void main() { testWidgets('shows preset zoom buttons', (tester) async { await pumpHud(tester); + expect(find.text('0.5x'), findsNothing); + expect(find.text('0.6x'), findsNothing); expect(find.text('1x'), findsOneWidget); - expect(find.text('2x'), findsOneWidget); - expect(find.text('3x'), findsOneWidget); + expect(find.text('2x'), findsNothing); + expect(find.text('3x'), findsNothing); }); - testWidgets('marks current zoom ratio as selected', (tester) async { - await pumpHud(tester, zoomRatio: 2.0); + testWidgets('shows 0.6x when 0.5x camera capability supports it', ( + tester, + ) async { + await pumpHud(tester, minZoomRatio: 0.5); + + expect(find.text('0.5x'), findsNothing); + expect(find.text('0.6x'), findsOneWidget); + expect(find.text('1x'), findsOneWidget); + expect(find.text('2x'), findsNothing); + expect(find.text('3x'), findsNothing); + }); + + testWidgets('shows 0.6x when 0.6x camera capability supports it', ( + tester, + ) async { + await pumpHud(tester, minZoomRatio: 0.6); + + expect(find.text('0.6x'), findsOneWidget); + expect(find.text('1x'), findsOneWidget); + }); + + testWidgets('marks current 0.5x zoom ratio as selected on 0.6x UI', ( + tester, + ) async { + await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5); final selectedButton = tester.widget( - find.ancestor(of: find.text('2x'), matching: find.byType(TextButton)), + find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), + ); + expect(selectedButton.enabled, isFalse); + }); + + testWidgets('marks current 0.6x zoom ratio as selected', (tester) async { + await pumpHud(tester, zoomRatio: 0.6, minZoomRatio: 0.6); + + final selectedButton = tester.widget( + find.ancestor(of: find.text('0.6x'), 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); + await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55); - expect(find.text('1x'), findsOneWidget); - expect(find.text('2x'), findsOneWidget); - expect(find.text('3x'), findsNothing); + expect(find.text('0.6x'), findsNothing); + expect(find.text('1x'), findsNothing); }); - testWidgets('tapping zoom preset reports selected ratio', (tester) async { + testWidgets('tapping 0.6x reports 0.5 when camera supports 0.5x', ( + tester, + ) async { double? selected; - await pumpHud(tester, onZoomSelected: (ratio) => selected = ratio); + await pumpHud( + tester, + minZoomRatio: 0.5, + onZoomSelected: (ratio) => selected = ratio, + ); - await tester.tap(find.text('2x')); + await tester.tap(find.text('0.6x')); await tester.pump(); - expect(selected, 2.0); + expect(selected, 0.5); + }); + + testWidgets('tapping 0.6x reports 0.6 when camera only supports 0.6x', ( + tester, + ) async { + double? selected; + await pumpHud( + tester, + minZoomRatio: 0.6, + onZoomSelected: (ratio) => selected = ratio, + ); + + await tester.tap(find.text('0.6x')); + await tester.pump(); + + expect(selected, 0.6); + }); + + testWidgets('disables 0.6x while recording on main camera', (tester) async { + await pumpHud(tester, minZoomRatio: 0.5, isRecording: true); + + final ultraWideButton = tester.widget( + find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), + ); + final mainButton = tester.widget( + find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)), + ); + + expect(ultraWideButton.enabled, isFalse); + expect(mainButton.enabled, isFalse); + }); + + testWidgets('disables main zoom presets while recording on 0.5x', ( + tester, + ) async { + await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5, isRecording: true); + + final ultraWideButton = tester.widget( + find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), + ); + final mainButton = tester.widget( + find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)), + ); + + expect(ultraWideButton.enabled, isFalse); + expect(mainButton.enabled, isFalse); }); }