实现缩放功能:增加缩放功能检索和设置方法,更新UI以支持缩放调整,增强缩放比例的状态管理。

This commit is contained in:
2026-06-12 16:38:31 +08:00
parent cf1c2d7d0e
commit a3a02e623f
11 changed files with 573 additions and 18 deletions

View File

@@ -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<Recorder>? = 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<String, Any> {
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, Any>, 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"
}

View File

@@ -50,6 +50,11 @@ class RecordingPlatformHandler(
startRecording(withAudio, enableDnd, displayName, result)
}
"stopRecording" -> stopRecording(result)
"getZoomCapabilities" -> result.success(controller.zoomCapabilitiesMap())
"setZoomRatio" -> {
val ratio = call.argument<Double>("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 =

View File

@@ -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":

View File

@@ -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

View File

@@ -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<void> Function() onStart;
final Future<void> 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);
},
);
}
}

View File

@@ -81,6 +81,21 @@ class RecordingPlatform {
return RecordingStatus.fromMap(result ?? const {});
}
static Future<RecordingZoomCapabilities> getZoomCapabilities() async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'getZoomCapabilities',
);
return RecordingZoomCapabilities.fromMap(result);
}
static Future<RecordingZoomCapabilities> setZoomRatio(double ratio) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'setZoomRatio',
<String, dynamic>{'zoomRatio': ratio},
);
return RecordingZoomCapabilities.fromMap(result);
}
static Future<RecordingStartResult> 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<String, dynamic>? 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});

View File

@@ -193,6 +193,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
await _listenStatus();
try {
final status = await _initializePreviewWithRetry();
await _refreshZoomCapabilities();
_updateSession(
(s) => s.copyWith(
status: status,
@@ -239,6 +240,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
);
try {
final status = await _initializePreviewWithRetry();
await _refreshZoomCapabilities();
_updateSession(
(s) => s.copyWith(
status: status,
@@ -309,6 +311,47 @@ class RecordingViewModel extends Notifier<RecordingModel> {
return status?.isGranted == true || status?.isLimited == true;
}
/// 读取相机支持的倍距范围并同步当前倍距。
Future<void> _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<void> 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<void> startRecording({bool enableDoNotDisturb = true}) async {
final session = state.session;

View File

@@ -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(),

View File

@@ -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<void> Function() onStart;
final Future<void> Function() onStop;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock;
final ValueChanged<double> 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<double> _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<double> presets;
final ValueChanged<double> 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<double> 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,
),
),
),
);
}
}

View File

@@ -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, <String, dynamic>{'zoomRatio': 2.0});
return <String, dynamic>{
'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 = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{
'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, <String, dynamic>{'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(

View File

@@ -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<void> pumpHud(
WidgetTester tester, {
double zoomRatio = 1.0,
double minZoomRatio = 1.0,
double maxZoomRatio = 3.0,
ValueChanged<double>? 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<TextButton>(
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);
});
}