camera: Dart facade + backend + channel + preview + tests
Phase 1c+1d of the ux.camera plan (see ~/banlu/plans/ux_camera.md).
lib/src/camera/camera.dart — UxCameraController (ValueNotifier),
UxCameraValue, UxCameraDescription,
enums, UxCameraException,
uxAvailableCameras().
lib/src/camera/camera_backend.dart — abstract UxCameraBackend +
UxCameraCreateResult + sealed
UxCameraEvent variants.
lib/src/camera/camera_channel.dart — MethodChannelUxCameraBackend over
ux/camera + ux/camera/events. Per-
handle event demux. Maps
PlatformException → UxCameraException.
lib/src/camera/camera_preview.dart — UxCameraPreview: Texture-backed,
Hero-flightable preview widget.
lib/src/testing/fake_camera.dart — FakeUxCameraBackend with per-method
call lists + emitXxx event injection.
Exported from package:ux/testing.dart.
test/camera/camera_controller_test — 16 tests covering init/dispose,
orientation events, takePicture
(explicit + UxSensor fallback),
startVideoRecording / stop,
flip, flash, lock/unlock,
multi-instance, error propagation.
test/camera/camera_channel_test — 10 tests pinning the wire format
for every method + PlatformException
mapping.
Orientation snapshot for capture is computed Dart-side and passed in as an
explicit arg to takePicture / startVideoRecording (default falls back to
UxSensor.orientation at call time). Native never queries UIDevice itself
for the snapshot — Dart-side fakes drive orientation deterministically.
Native plugin code lands in Phase 2+; today every channel call throws
MissingPluginException at runtime, which is fine — the controller is only
mounted from the camera page once Phase 5 cuts over. The test backend
already exercises the full controller surface.
This commit is contained in:
299
lib/src/camera/camera.dart
Normal file
299
lib/src/camera/camera.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show Size;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show Widget;
|
||||
|
||||
import '../file.dart' show UxFile;
|
||||
import '../sensor.dart' show UxSensor;
|
||||
import 'camera_backend.dart';
|
||||
import 'camera_preview.dart' show UxCameraPreview;
|
||||
|
||||
/// Describes a camera device on the system. Returned by
|
||||
/// [uxAvailableCameras]; passed to [UxCameraController] to bind a
|
||||
/// specific lens.
|
||||
class UxCameraDescription {
|
||||
const UxCameraDescription({
|
||||
required this.id,
|
||||
required this.lens,
|
||||
required this.sensorOrientation,
|
||||
});
|
||||
|
||||
/// Opaque platform handle — `AVCaptureDevice.uniqueID` on iOS,
|
||||
/// CameraX camera id on Android. Treat as a blob; only the plugin
|
||||
/// interprets it.
|
||||
final String id;
|
||||
|
||||
final UxCameraLens lens;
|
||||
|
||||
/// Clockwise rotation in degrees (0/90/180/270) from the camera
|
||||
/// sensor's natural orientation to the device's portrait-up
|
||||
/// orientation. Used by post-capture transforms outside this
|
||||
/// module (e.g. `normalizeCameraCapture`).
|
||||
final int sensorOrientation;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is UxCameraDescription &&
|
||||
other.id == id &&
|
||||
other.lens == lens &&
|
||||
other.sensorOrientation == sensorOrientation;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, lens, sensorOrientation);
|
||||
}
|
||||
|
||||
enum UxCameraLens { front, back }
|
||||
|
||||
/// Resolution preset for both photo and video. Single value today
|
||||
/// (`high`); the enum exists so future presets can land without
|
||||
/// breaking call sites.
|
||||
enum UxResolutionPreset { high }
|
||||
|
||||
/// Flash mode applied to the next still capture. Only the two values
|
||||
/// the chat composer actually uses today.
|
||||
enum UxFlashMode { off, always }
|
||||
|
||||
/// Immutable snapshot of a [UxCameraController]'s state, broadcast to
|
||||
/// listeners. Updated on lifecycle transitions and on device-
|
||||
/// orientation events from the native side.
|
||||
class UxCameraValue {
|
||||
const UxCameraValue({
|
||||
required this.description,
|
||||
this.previewSize,
|
||||
this.isInitialized = false,
|
||||
this.isRecordingVideo = false,
|
||||
this.deviceOrientation = DeviceOrientation.portraitUp,
|
||||
this.enableAudio = false,
|
||||
this.errorDescription,
|
||||
});
|
||||
|
||||
factory UxCameraValue.uninitialized(UxCameraDescription d) =>
|
||||
UxCameraValue(description: d);
|
||||
|
||||
final UxCameraDescription description;
|
||||
|
||||
/// Pixel dimensions of the active video format, in the camera
|
||||
/// sensor's natural orientation (so for typical phone sensors this
|
||||
/// is landscape — `1920×1080` etc.). Null until [isInitialized].
|
||||
final Size? previewSize;
|
||||
|
||||
final bool isInitialized;
|
||||
final bool isRecordingVideo;
|
||||
|
||||
/// Physical orientation reported by the native side's orientation
|
||||
/// listener. Independent of any UI lock; used by [_Rotating] widgets
|
||||
/// in `camera_page.dart` so icons flip while the preview stays
|
||||
/// portrait.
|
||||
final DeviceOrientation deviceOrientation;
|
||||
|
||||
final bool enableAudio;
|
||||
|
||||
/// Set to the last native session error's message when one fires.
|
||||
/// Cleared on the next successful state transition.
|
||||
final String? errorDescription;
|
||||
|
||||
bool get hasError => errorDescription != null;
|
||||
|
||||
UxCameraValue copyWith({
|
||||
UxCameraDescription? description,
|
||||
Size? previewSize,
|
||||
bool? isInitialized,
|
||||
bool? isRecordingVideo,
|
||||
DeviceOrientation? deviceOrientation,
|
||||
bool? enableAudio,
|
||||
Object? errorDescription = _unset,
|
||||
}) =>
|
||||
UxCameraValue(
|
||||
description: description ?? this.description,
|
||||
previewSize: previewSize ?? this.previewSize,
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
isRecordingVideo: isRecordingVideo ?? this.isRecordingVideo,
|
||||
deviceOrientation: deviceOrientation ?? this.deviceOrientation,
|
||||
enableAudio: enableAudio ?? this.enableAudio,
|
||||
errorDescription: identical(errorDescription, _unset)
|
||||
? this.errorDescription
|
||||
: errorDescription as String?,
|
||||
);
|
||||
|
||||
static const _unset = Object();
|
||||
}
|
||||
|
||||
/// Throws when a [UxCameraController] call fails on the platform side.
|
||||
/// Maps from native `FlutterError` codes; see the table in
|
||||
/// `~/banlu/plans/ux_camera.md` §error-model for the full set.
|
||||
class UxCameraException implements Exception {
|
||||
const UxCameraException(this.code, [this.description]);
|
||||
|
||||
final String code;
|
||||
final String? description;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'UxCameraException($code${description == null ? '' : ': $description'})';
|
||||
}
|
||||
|
||||
/// Enumerate the cameras the platform exposes. Stable for the lifetime
|
||||
/// of the process; safe to cache the result.
|
||||
Future<List<UxCameraDescription>> uxAvailableCameras() =>
|
||||
UxCameraBackend.instance.availableCameras();
|
||||
|
||||
/// Owns one native camera session. Mirrors the surface
|
||||
/// `package:camera`'s `CameraController` exposes today — every method
|
||||
/// signature the chat composer touches has a 1:1 here, plus the
|
||||
/// orientation snapshot is passed explicitly on capture calls.
|
||||
///
|
||||
/// Lifecycle: [initialize] → use → [dispose]. After [dispose] every
|
||||
/// other method throws `UxCameraException("disposed")`.
|
||||
class UxCameraController extends ValueNotifier<UxCameraValue> {
|
||||
UxCameraController(
|
||||
UxCameraDescription description,
|
||||
this.resolutionPreset, {
|
||||
required bool enableAudio,
|
||||
}) : super(UxCameraValue(
|
||||
description: description,
|
||||
enableAudio: enableAudio,
|
||||
));
|
||||
|
||||
final UxResolutionPreset resolutionPreset;
|
||||
|
||||
int? _handle;
|
||||
int? _textureId;
|
||||
StreamSubscription<UxCameraEvent>? _eventsSub;
|
||||
bool _disposed = false;
|
||||
|
||||
UxCameraDescription get description => value.description;
|
||||
bool get enableAudio => value.enableAudio;
|
||||
|
||||
/// Texture id once the session has been created (during [initialize]).
|
||||
/// Read by [UxCameraPreview]; null before init or after dispose.
|
||||
int? get textureId => _textureId;
|
||||
|
||||
/// Configure the native session and begin streaming preview frames.
|
||||
///
|
||||
/// Throws if camera or microphone permission is denied, or if another
|
||||
/// instance already holds the requested device or the audio session.
|
||||
Future<void> initialize() async {
|
||||
_throwIfDisposed('initialize');
|
||||
final result = await UxCameraBackend.instance.create(
|
||||
cameraId: description.id,
|
||||
enableAudio: value.enableAudio,
|
||||
preset: resolutionPreset,
|
||||
);
|
||||
_handle = result.handle;
|
||||
_textureId = result.textureId;
|
||||
_eventsSub = UxCameraBackend.instance.events(result.handle).listen(
|
||||
_onEvent,
|
||||
onError: (Object error, StackTrace? stack) {
|
||||
value = value.copyWith(errorDescription: error.toString());
|
||||
},
|
||||
);
|
||||
await UxCameraBackend.instance.initialize(result.handle);
|
||||
value = value.copyWith(
|
||||
isInitialized: true,
|
||||
previewSize: result.previewSize,
|
||||
);
|
||||
}
|
||||
|
||||
void _onEvent(UxCameraEvent event) {
|
||||
switch (event) {
|
||||
case UxCameraDeviceOrientationChanged(:final orientation):
|
||||
value = value.copyWith(deviceOrientation: orientation);
|
||||
case UxCameraSessionError(:final code, :final description):
|
||||
value = value.copyWith(errorDescription: description ?? code);
|
||||
case UxCameraSessionInterrupted():
|
||||
case UxCameraSessionResumed():
|
||||
// Lifecycle pings; recovery is automatic on the native side.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
final handle = _handle;
|
||||
_handle = null;
|
||||
final sub = _eventsSub;
|
||||
_eventsSub = null;
|
||||
if (handle != null) {
|
||||
await UxCameraBackend.instance.disposeInstance(handle);
|
||||
}
|
||||
await sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Switch lenses without tearing down the controller. Blocked while a
|
||||
/// recording is in flight — call [stopVideoRecording] first.
|
||||
Future<void> setDescription(UxCameraDescription description) async {
|
||||
final handle = _requireHandle('setDescription');
|
||||
final size = await UxCameraBackend.instance.setDescription(handle, description.id);
|
||||
value = value.copyWith(description: description, previewSize: size);
|
||||
}
|
||||
|
||||
Future<void> setFlashMode(UxFlashMode mode) async {
|
||||
final handle = _requireHandle('setFlashMode');
|
||||
await UxCameraBackend.instance.setFlashMode(handle, mode);
|
||||
}
|
||||
|
||||
Future<void> lockCaptureOrientation(DeviceOrientation orientation) async {
|
||||
final handle = _requireHandle('lockCaptureOrientation');
|
||||
await UxCameraBackend.instance.lockCaptureOrientation(handle, orientation);
|
||||
}
|
||||
|
||||
Future<void> unlockCaptureOrientation() async {
|
||||
final handle = _requireHandle('unlockCaptureOrientation');
|
||||
await UxCameraBackend.instance.unlockCaptureOrientation(handle);
|
||||
}
|
||||
|
||||
/// Capture a still. [captureOrientation] is the orientation to embed
|
||||
/// in the resulting JPEG; defaults to [UxSensor.orientation] read at
|
||||
/// call time. Tests pass an explicit value to keep the assertion
|
||||
/// deterministic.
|
||||
Future<UxFile> takePicture({DeviceOrientation? captureOrientation}) async {
|
||||
final handle = _requireHandle('takePicture');
|
||||
final orientation = captureOrientation ?? UxSensor.orientation;
|
||||
return UxCameraBackend.instance.takePicture(handle, orientation);
|
||||
}
|
||||
|
||||
/// Start recording. [captureOrientation] becomes the video file's
|
||||
/// rotation transform (iOS: AVAssetWriterInput.transform; Android:
|
||||
/// CameraX targetRotation). Defaults to [UxSensor.orientation] —
|
||||
/// pass explicitly from tests.
|
||||
Future<void> startVideoRecording({
|
||||
DeviceOrientation? captureOrientation,
|
||||
}) async {
|
||||
final handle = _requireHandle('startVideoRecording');
|
||||
final orientation = captureOrientation ?? UxSensor.orientation;
|
||||
await UxCameraBackend.instance.startVideoRecording(handle, orientation);
|
||||
value = value.copyWith(isRecordingVideo: true);
|
||||
}
|
||||
|
||||
Future<UxFile> stopVideoRecording() async {
|
||||
final handle = _requireHandle('stopVideoRecording');
|
||||
final file = await UxCameraBackend.instance.stopVideoRecording(handle);
|
||||
value = value.copyWith(isRecordingVideo: false);
|
||||
return file;
|
||||
}
|
||||
|
||||
/// Texture-backed widget that renders the live preview at its parent's
|
||||
/// size. Hero-flightable.
|
||||
Widget buildPreview() => UxCameraPreview(controller: this);
|
||||
|
||||
int _requireHandle(String op) {
|
||||
_throwIfDisposed(op);
|
||||
final h = _handle;
|
||||
if (h == null) {
|
||||
throw const UxCameraException('not_initialized');
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
void _throwIfDisposed(String op) {
|
||||
if (_disposed) {
|
||||
throw UxCameraException('disposed', '$op called on a disposed controller');
|
||||
}
|
||||
}
|
||||
}
|
||||
130
lib/src/camera/camera_backend.dart
Normal file
130
lib/src/camera/camera_backend.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../file.dart' show UxFile;
|
||||
import 'camera.dart' show UxCameraDescription, UxFlashMode, UxResolutionPreset;
|
||||
import 'camera_channel.dart' show MethodChannelUxCameraBackend;
|
||||
|
||||
/// Backend contract that [UxCameraController] dispatches into. The default
|
||||
/// implementation calls into native code via the `ux/camera` /
|
||||
/// `ux/camera/events` channels; tests substitute their own (see
|
||||
/// `package:ux/testing.dart`'s `FakeUxCameraBackend`).
|
||||
///
|
||||
/// Every per-instance call carries a `handle` returned by [create] so
|
||||
/// the plugin can route to the right native session. Multiple
|
||||
/// controllers can hold simultaneous handles.
|
||||
abstract class UxCameraBackend {
|
||||
/// Swap to inject a fake before any UI code mounts a controller.
|
||||
static UxCameraBackend instance = MethodChannelUxCameraBackend();
|
||||
|
||||
/// Enumerate camera devices. The result is stable for the lifetime
|
||||
/// of the process.
|
||||
Future<List<UxCameraDescription>> availableCameras();
|
||||
|
||||
/// Allocate a native camera instance bound to [cameraId]. Returns the
|
||||
/// handle (for subsequent calls), the FlutterTexture id (for the
|
||||
/// preview widget), and the sensor-natural-orientation preview size.
|
||||
///
|
||||
/// Throws [UxCameraException("device_busy")] if another instance
|
||||
/// already holds the device, or [UxCameraException("audio_busy")]
|
||||
/// when [enableAudio] is true and another instance holds the
|
||||
/// app-global audio session.
|
||||
Future<UxCameraCreateResult> create({
|
||||
required String cameraId,
|
||||
required bool enableAudio,
|
||||
required UxResolutionPreset preset,
|
||||
});
|
||||
|
||||
/// Start the session. Native side resolves camera + audio permissions
|
||||
/// before the future completes. Throws
|
||||
/// [UxCameraException("permission_denied")] on denial.
|
||||
Future<void> initialize(int handle);
|
||||
|
||||
/// Tear down the session. Cancels any in-flight recording, releases
|
||||
/// the camera device and the audio claim. Safe to call repeatedly;
|
||||
/// no-op once disposed.
|
||||
Future<void> disposeInstance(int handle);
|
||||
|
||||
/// Swap to a different camera mid-session. Resets the lock and clears
|
||||
/// any pending recording. Returns the new preview size.
|
||||
Future<Size> setDescription(int handle, String cameraId);
|
||||
|
||||
/// Set the flash mode used for the next [takePicture]. On front cameras
|
||||
/// without a screen-flash fallback the backend silently no-ops; the
|
||||
/// caller is responsible for not offering flash UI there.
|
||||
Future<void> setFlashMode(int handle, UxFlashMode mode);
|
||||
|
||||
/// Pin the preview's connection orientation. Used today only to lock
|
||||
/// the preview to portrait so it never appears stretched/rotated.
|
||||
Future<void> lockCaptureOrientation(int handle, DeviceOrientation orientation);
|
||||
|
||||
/// Release the orientation lock — the preview falls back to following
|
||||
/// physical device orientation. Unused by the chat composer; kept for
|
||||
/// API symmetry.
|
||||
Future<void> unlockCaptureOrientation(int handle);
|
||||
|
||||
/// Take a still photo. [snapshotOrientation] is applied to the photo
|
||||
/// connection just before capture so the file's EXIF orientation
|
||||
/// matches how the user was holding the device.
|
||||
Future<UxFile> takePicture(int handle, DeviceOrientation snapshotOrientation);
|
||||
|
||||
/// Begin recording video. [snapshotOrientation] is baked into the
|
||||
/// writer track's transform — the file plays back rotated even if
|
||||
/// the device returns to portrait mid-recording.
|
||||
Future<void> startVideoRecording(
|
||||
int handle,
|
||||
DeviceOrientation snapshotOrientation,
|
||||
);
|
||||
|
||||
/// Stop recording and return the resulting MP4 on disk.
|
||||
Future<UxFile> stopVideoRecording(int handle);
|
||||
|
||||
/// Live event stream for [handle]: device-orientation changes,
|
||||
/// session errors, interrupted/resumed lifecycle pings. The
|
||||
/// controller subscribes during [initialize] and unsubscribes on
|
||||
/// [disposeInstance].
|
||||
Stream<UxCameraEvent> events(int handle);
|
||||
}
|
||||
|
||||
/// The tuple returned by [UxCameraBackend.create] — everything the
|
||||
/// controller needs to start serving the preview widget and routing
|
||||
/// subsequent calls.
|
||||
class UxCameraCreateResult {
|
||||
const UxCameraCreateResult({
|
||||
required this.handle,
|
||||
required this.textureId,
|
||||
required this.previewSize,
|
||||
});
|
||||
|
||||
final int handle;
|
||||
final int textureId;
|
||||
final Size previewSize;
|
||||
}
|
||||
|
||||
/// Events pushed by the native side over `ux/camera/events`. Sealed —
|
||||
/// new variants land here as the contract grows.
|
||||
sealed class UxCameraEvent {
|
||||
const UxCameraEvent(this.handle);
|
||||
final int handle;
|
||||
}
|
||||
|
||||
class UxCameraDeviceOrientationChanged extends UxCameraEvent {
|
||||
const UxCameraDeviceOrientationChanged(super.handle, this.orientation);
|
||||
final DeviceOrientation orientation;
|
||||
}
|
||||
|
||||
class UxCameraSessionError extends UxCameraEvent {
|
||||
const UxCameraSessionError(super.handle, this.code, this.description);
|
||||
final String code;
|
||||
final String? description;
|
||||
}
|
||||
|
||||
class UxCameraSessionInterrupted extends UxCameraEvent {
|
||||
const UxCameraSessionInterrupted(super.handle, this.reason);
|
||||
final String reason;
|
||||
}
|
||||
|
||||
class UxCameraSessionResumed extends UxCameraEvent {
|
||||
const UxCameraSessionResumed(super.handle);
|
||||
}
|
||||
224
lib/src/camera/camera_channel.dart
Normal file
224
lib/src/camera/camera_channel.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../file.dart' show UxFile;
|
||||
import 'camera.dart'
|
||||
show
|
||||
UxCameraDescription,
|
||||
UxCameraException,
|
||||
UxCameraLens,
|
||||
UxFlashMode,
|
||||
UxResolutionPreset;
|
||||
import 'camera_backend.dart';
|
||||
|
||||
/// Production [UxCameraBackend]. Hand-rolled MethodChannel +
|
||||
/// EventChannel — matches the rest of `package:ux`, no pigeon.
|
||||
class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
MethodChannelUxCameraBackend();
|
||||
|
||||
static const _channel = MethodChannel('ux/camera');
|
||||
static const _eventsChannel = EventChannel('ux/camera/events');
|
||||
|
||||
/// Single broadcast stream over the native event channel. We
|
||||
/// demultiplex per-handle inside [events] so a Stream subscription
|
||||
/// is cheap (no per-instance EventChannel hop).
|
||||
late final Stream<Object?> _rawEvents =
|
||||
_eventsChannel.receiveBroadcastStream();
|
||||
|
||||
@override
|
||||
Future<List<UxCameraDescription>> availableCameras() async {
|
||||
final raw = await _invoke<List<Object?>>('availableCameras');
|
||||
return raw.map((e) {
|
||||
final m = (e as Map).cast<Object?, Object?>();
|
||||
return UxCameraDescription(
|
||||
id: m['id'] as String,
|
||||
lens: _parseLens(m['lens'] as String?),
|
||||
sensorOrientation: (m['sensorOrientation'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxCameraCreateResult> create({
|
||||
required String cameraId,
|
||||
required bool enableAudio,
|
||||
required UxResolutionPreset preset,
|
||||
}) async {
|
||||
final m = await _invokeMap('create', {
|
||||
'cameraId': cameraId,
|
||||
'enableAudio': enableAudio,
|
||||
'preset': _presetArg(preset),
|
||||
});
|
||||
final size = (m['previewSize'] as Map).cast<Object?, Object?>();
|
||||
return UxCameraCreateResult(
|
||||
handle: (m['handle'] as num).toInt(),
|
||||
textureId: (m['textureId'] as num).toInt(),
|
||||
previewSize: Size(
|
||||
(size['width'] as num).toDouble(),
|
||||
(size['height'] as num).toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> initialize(int handle) =>
|
||||
_invokeVoid('initialize', {'handle': handle});
|
||||
|
||||
@override
|
||||
Future<void> disposeInstance(int handle) =>
|
||||
_invokeVoid('dispose', {'handle': handle});
|
||||
|
||||
@override
|
||||
Future<Size> setDescription(int handle, String cameraId) async {
|
||||
final m = await _invokeMap('setDescription', {
|
||||
'handle': handle,
|
||||
'cameraId': cameraId,
|
||||
});
|
||||
final s = (m['previewSize'] as Map).cast<Object?, Object?>();
|
||||
return Size(
|
||||
(s['width'] as num).toDouble(),
|
||||
(s['height'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setFlashMode(int handle, UxFlashMode mode) =>
|
||||
_invokeVoid('setFlashMode', {
|
||||
'handle': handle,
|
||||
'mode': _flashArg(mode),
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> lockCaptureOrientation(
|
||||
int handle,
|
||||
DeviceOrientation orientation,
|
||||
) =>
|
||||
_invokeVoid('lockCaptureOrientation', {
|
||||
'handle': handle,
|
||||
'orientation': _orientationArg(orientation),
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> unlockCaptureOrientation(int handle) =>
|
||||
_invokeVoid('unlockCaptureOrientation', {'handle': handle});
|
||||
|
||||
@override
|
||||
Future<UxFile> takePicture(
|
||||
int handle,
|
||||
DeviceOrientation snapshotOrientation,
|
||||
) async {
|
||||
final m = await _invokeMap('takePicture', {
|
||||
'handle': handle,
|
||||
'snapshotOrientation': _orientationArg(snapshotOrientation),
|
||||
});
|
||||
return UxFile(m['path'] as String);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startVideoRecording(
|
||||
int handle,
|
||||
DeviceOrientation snapshotOrientation,
|
||||
) =>
|
||||
_invokeVoid('startVideoRecording', {
|
||||
'handle': handle,
|
||||
'snapshotOrientation': _orientationArg(snapshotOrientation),
|
||||
});
|
||||
|
||||
@override
|
||||
Future<UxFile> stopVideoRecording(int handle) async {
|
||||
final m = await _invokeMap('stopVideoRecording', {'handle': handle});
|
||||
return UxFile(m['path'] as String);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<UxCameraEvent> events(int handle) {
|
||||
return _rawEvents
|
||||
.map((e) => (e as Map).cast<Object?, Object?>())
|
||||
.where((m) => (m['handle'] as num).toInt() == handle)
|
||||
.map(_decodeEvent);
|
||||
}
|
||||
|
||||
// ---- parsers / arg encoders -------------------------------------
|
||||
|
||||
static UxCameraEvent _decodeEvent(Map<Object?, Object?> m) {
|
||||
final handle = (m['handle'] as num).toInt();
|
||||
switch (m['event'] as String?) {
|
||||
case 'deviceOrientationChanged':
|
||||
return UxCameraDeviceOrientationChanged(
|
||||
handle,
|
||||
_parseOrientation(m['orientation'] as String?),
|
||||
);
|
||||
case 'sessionError':
|
||||
return UxCameraSessionError(
|
||||
handle,
|
||||
m['code'] as String? ?? 'session_runtime_error',
|
||||
m['description'] as String?,
|
||||
);
|
||||
case 'sessionInterrupted':
|
||||
return UxCameraSessionInterrupted(
|
||||
handle,
|
||||
m['reason'] as String? ?? '',
|
||||
);
|
||||
case 'sessionResumed':
|
||||
return UxCameraSessionResumed(handle);
|
||||
default:
|
||||
return UxCameraSessionError(handle, 'unknown_event', null);
|
||||
}
|
||||
}
|
||||
|
||||
static UxCameraLens _parseLens(String? raw) => switch (raw) {
|
||||
'front' => UxCameraLens.front,
|
||||
_ => UxCameraLens.back,
|
||||
};
|
||||
|
||||
static DeviceOrientation _parseOrientation(String? raw) => switch (raw) {
|
||||
'landscapeLeft' => DeviceOrientation.landscapeLeft,
|
||||
'landscapeRight' => DeviceOrientation.landscapeRight,
|
||||
'portraitDown' => DeviceOrientation.portraitDown,
|
||||
_ => DeviceOrientation.portraitUp,
|
||||
};
|
||||
|
||||
static String _orientationArg(DeviceOrientation o) => switch (o) {
|
||||
DeviceOrientation.portraitUp => 'portraitUp',
|
||||
DeviceOrientation.landscapeLeft => 'landscapeLeft',
|
||||
DeviceOrientation.portraitDown => 'portraitDown',
|
||||
DeviceOrientation.landscapeRight => 'landscapeRight',
|
||||
};
|
||||
|
||||
static String _flashArg(UxFlashMode m) => switch (m) {
|
||||
UxFlashMode.off => 'off',
|
||||
UxFlashMode.always => 'always',
|
||||
};
|
||||
|
||||
static String _presetArg(UxResolutionPreset p) => switch (p) {
|
||||
UxResolutionPreset.high => 'high',
|
||||
};
|
||||
|
||||
// ---- channel adapter --------------------------------------------
|
||||
|
||||
Future<T> _invoke<T>(String method, [Map<String, Object?>? args]) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod<Object?>(method, args);
|
||||
return result as T;
|
||||
} on PlatformException catch (e) {
|
||||
throw UxCameraException(e.code, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<Object?, Object?>> _invokeMap(
|
||||
String method, [
|
||||
Map<String, Object?>? args,
|
||||
]) async {
|
||||
final result = await _invoke<Map<Object?, Object?>>(method, args);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _invokeVoid(String method, [Map<String, Object?>? args]) async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>(method, args);
|
||||
} on PlatformException catch (e) {
|
||||
throw UxCameraException(e.code, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
lib/src/camera/camera_preview.dart
Normal file
29
lib/src/camera/camera_preview.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'camera.dart' show UxCameraController, UxCameraValue;
|
||||
|
||||
/// Renders the live preview for [controller] into a [Texture]. Sizes
|
||||
/// itself to the parent — wrap in `AspectRatio` / `FittedBox` / `Hero`
|
||||
/// to control framing.
|
||||
///
|
||||
/// While the controller is not yet initialized, this falls back to a
|
||||
/// transparent placeholder. The widget rebuilds on every
|
||||
/// `UxCameraValue` change, so once the native session starts
|
||||
/// producing frames the texture appears automatically.
|
||||
class UxCameraPreview extends StatelessWidget {
|
||||
const UxCameraPreview({super.key, required this.controller});
|
||||
|
||||
final UxCameraController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<UxCameraValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, _, __) {
|
||||
final id = controller.textureId;
|
||||
if (id == null) return const SizedBox.expand();
|
||||
return Texture(textureId: id);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
198
lib/src/testing/fake_camera.dart
Normal file
198
lib/src/testing/fake_camera.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show Size;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ux/src/camera/camera.dart';
|
||||
import 'package:ux/src/camera/camera_backend.dart';
|
||||
import 'package:ux/src/file.dart' show UxFile;
|
||||
|
||||
/// In-memory backend for [UxCameraController] tests. Swap in via
|
||||
/// `UxCameraBackend.instance = FakeUxCameraBackend(...)` before any UI
|
||||
/// mounts; restore with `UxCameraBackend.instance =
|
||||
/// MethodChannelUxCameraBackend()` in `tearDown`.
|
||||
///
|
||||
/// Captures every call into per-method lists for assertions, and lets
|
||||
/// tests drive events deterministically via [emitOrientationChanged]
|
||||
/// and friends.
|
||||
class FakeUxCameraBackend implements UxCameraBackend {
|
||||
FakeUxCameraBackend({
|
||||
List<UxCameraDescription> cameras = const [],
|
||||
this.previewSize = const Size(1920, 1080),
|
||||
this.picturePath = '/tmp/fake_picture.jpg',
|
||||
this.videoPath = '/tmp/fake_video.mp4',
|
||||
}) : cameras = List.unmodifiable(cameras);
|
||||
|
||||
// ---- captured calls ---------------------------------------------
|
||||
|
||||
final List<({String cameraId, bool enableAudio, UxResolutionPreset preset})>
|
||||
createCalls = [];
|
||||
final List<int> initializeCalls = [];
|
||||
final List<int> disposeCalls = [];
|
||||
final List<({int handle, String cameraId})> setDescriptionCalls = [];
|
||||
final List<({int handle, UxFlashMode mode})> setFlashModeCalls = [];
|
||||
final List<({int handle, DeviceOrientation orientation})>
|
||||
lockCaptureOrientationCalls = [];
|
||||
final List<int> unlockCaptureOrientationCalls = [];
|
||||
final List<({int handle, DeviceOrientation snapshotOrientation})>
|
||||
takePictureCalls = [];
|
||||
final List<({int handle, DeviceOrientation snapshotOrientation})>
|
||||
startVideoRecordingCalls = [];
|
||||
final List<int> stopVideoRecordingCalls = [];
|
||||
int availableCamerasCalls = 0;
|
||||
|
||||
// ---- configurable returns ---------------------------------------
|
||||
|
||||
/// Cameras returned by [availableCameras]. Mutable so tests can swap
|
||||
/// the set between assertions.
|
||||
List<UxCameraDescription> cameras;
|
||||
Size previewSize;
|
||||
String picturePath;
|
||||
String videoPath;
|
||||
|
||||
/// If non-null, [create] throws this exception instead of returning
|
||||
/// a result — exercises permission denial / device busy paths.
|
||||
UxCameraException? createError;
|
||||
|
||||
/// Optional override; set to throw on any particular method to
|
||||
/// drive failure paths.
|
||||
UxCameraException? initializeError;
|
||||
UxCameraException? takePictureError;
|
||||
UxCameraException? startVideoRecordingError;
|
||||
UxCameraException? stopVideoRecordingError;
|
||||
|
||||
// ---- internal ---------------------------------------------------
|
||||
|
||||
int _nextHandle = 1;
|
||||
int _nextTextureId = 100;
|
||||
final Map<int, StreamController<UxCameraEvent>> _eventControllers = {};
|
||||
|
||||
StreamController<UxCameraEvent> _controllerFor(int handle) {
|
||||
return _eventControllers.putIfAbsent(
|
||||
handle,
|
||||
() => StreamController<UxCameraEvent>.broadcast(),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- event injection --------------------------------------------
|
||||
|
||||
/// Push a `deviceOrientationChanged` event for [handle]. The
|
||||
/// subscribed controller will update its `value.deviceOrientation`.
|
||||
void emitOrientationChanged(int handle, DeviceOrientation orientation) {
|
||||
_controllerFor(handle)
|
||||
.add(UxCameraDeviceOrientationChanged(handle, orientation));
|
||||
}
|
||||
|
||||
void emitSessionError(int handle, String code, [String? description]) {
|
||||
_controllerFor(handle).add(UxCameraSessionError(handle, code, description));
|
||||
}
|
||||
|
||||
void emitSessionInterrupted(int handle, String reason) {
|
||||
_controllerFor(handle).add(UxCameraSessionInterrupted(handle, reason));
|
||||
}
|
||||
|
||||
void emitSessionResumed(int handle) {
|
||||
_controllerFor(handle).add(UxCameraSessionResumed(handle));
|
||||
}
|
||||
|
||||
// ---- UxCameraBackend impl --------------------------------------
|
||||
|
||||
@override
|
||||
Future<List<UxCameraDescription>> availableCameras() async {
|
||||
availableCamerasCalls += 1;
|
||||
return cameras;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxCameraCreateResult> create({
|
||||
required String cameraId,
|
||||
required bool enableAudio,
|
||||
required UxResolutionPreset preset,
|
||||
}) async {
|
||||
createCalls.add((
|
||||
cameraId: cameraId,
|
||||
enableAudio: enableAudio,
|
||||
preset: preset,
|
||||
));
|
||||
final err = createError;
|
||||
if (err != null) throw err;
|
||||
final handle = _nextHandle++;
|
||||
final textureId = _nextTextureId++;
|
||||
return UxCameraCreateResult(
|
||||
handle: handle,
|
||||
textureId: textureId,
|
||||
previewSize: previewSize,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> initialize(int handle) async {
|
||||
initializeCalls.add(handle);
|
||||
final err = initializeError;
|
||||
if (err != null) throw err;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disposeInstance(int handle) async {
|
||||
disposeCalls.add(handle);
|
||||
await _eventControllers.remove(handle)?.close();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Size> setDescription(int handle, String cameraId) async {
|
||||
setDescriptionCalls.add((handle: handle, cameraId: cameraId));
|
||||
return previewSize;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setFlashMode(int handle, UxFlashMode mode) async {
|
||||
setFlashModeCalls.add((handle: handle, mode: mode));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> lockCaptureOrientation(
|
||||
int handle,
|
||||
DeviceOrientation orientation,
|
||||
) async {
|
||||
lockCaptureOrientationCalls
|
||||
.add((handle: handle, orientation: orientation));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unlockCaptureOrientation(int handle) async {
|
||||
unlockCaptureOrientationCalls.add(handle);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxFile> takePicture(
|
||||
int handle,
|
||||
DeviceOrientation snapshotOrientation,
|
||||
) async {
|
||||
takePictureCalls
|
||||
.add((handle: handle, snapshotOrientation: snapshotOrientation));
|
||||
final err = takePictureError;
|
||||
if (err != null) throw err;
|
||||
return UxFile(picturePath);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startVideoRecording(
|
||||
int handle,
|
||||
DeviceOrientation snapshotOrientation,
|
||||
) async {
|
||||
startVideoRecordingCalls
|
||||
.add((handle: handle, snapshotOrientation: snapshotOrientation));
|
||||
final err = startVideoRecordingError;
|
||||
if (err != null) throw err;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxFile> stopVideoRecording(int handle) async {
|
||||
stopVideoRecordingCalls.add(handle);
|
||||
final err = stopVideoRecordingError;
|
||||
if (err != null) throw err;
|
||||
return UxFile(videoPath);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<UxCameraEvent> events(int handle) => _controllerFor(handle).stream;
|
||||
}
|
||||
@@ -5,5 +5,6 @@
|
||||
/// ```
|
||||
library;
|
||||
|
||||
export 'src/testing/fake_camera.dart';
|
||||
export 'src/testing/fake_gallery.dart';
|
||||
export 'src/testing/text_golden.dart';
|
||||
|
||||
@@ -8,6 +8,10 @@ export 'src/app_info.dart';
|
||||
export 'src/bend_box.dart';
|
||||
export 'src/json_extension.dart';
|
||||
export 'src/bezier.dart';
|
||||
export 'src/camera/camera.dart';
|
||||
export 'src/camera/camera_backend.dart' show UxCameraBackend, UxCameraCreateResult, UxCameraEvent, UxCameraDeviceOrientationChanged, UxCameraSessionError, UxCameraSessionInterrupted, UxCameraSessionResumed;
|
||||
export 'src/camera/camera_channel.dart' show MethodChannelUxCameraBackend;
|
||||
export 'src/camera/camera_preview.dart';
|
||||
export 'src/clipboard.dart';
|
||||
export 'src/file.dart';
|
||||
export 'src/gallery.dart';
|
||||
|
||||
170
test/camera/camera_channel_test.dart
Normal file
170
test/camera/camera_channel_test.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/ux.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('MethodChannelUxCameraBackend — arg/return parsing', () {
|
||||
const channel = MethodChannel('ux/camera');
|
||||
|
||||
late MethodChannelUxCameraBackend backend;
|
||||
late List<MethodCall> calls;
|
||||
|
||||
setUp(() {
|
||||
backend = MethodChannelUxCameraBackend();
|
||||
calls = [];
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
void handle(Object? Function(MethodCall) reply) {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return reply(call);
|
||||
});
|
||||
}
|
||||
|
||||
test('availableCameras decodes the platform list', () async {
|
||||
handle((_) => [
|
||||
{'id': 'a', 'lens': 'front', 'sensorOrientation': 270},
|
||||
{'id': 'b', 'lens': 'back', 'sensorOrientation': 90},
|
||||
]);
|
||||
|
||||
final result = await backend.availableCameras();
|
||||
|
||||
expect(calls.single.method, 'availableCameras');
|
||||
expect(result, [
|
||||
const UxCameraDescription(
|
||||
id: 'a', lens: UxCameraLens.front, sensorOrientation: 270),
|
||||
const UxCameraDescription(
|
||||
id: 'b', lens: UxCameraLens.back, sensorOrientation: 90),
|
||||
]);
|
||||
});
|
||||
|
||||
test('create sends cameraId/enableAudio/preset and decodes the tuple',
|
||||
() async {
|
||||
handle((_) => {
|
||||
'handle': 42,
|
||||
'textureId': 7,
|
||||
'previewSize': {'width': 1920, 'height': 1080},
|
||||
});
|
||||
|
||||
final result = await backend.create(
|
||||
cameraId: 'cam-1',
|
||||
enableAudio: true,
|
||||
preset: UxResolutionPreset.high,
|
||||
);
|
||||
|
||||
expect(calls.single.method, 'create');
|
||||
expect(calls.single.arguments, {
|
||||
'cameraId': 'cam-1',
|
||||
'enableAudio': true,
|
||||
'preset': 'high',
|
||||
});
|
||||
expect(result.handle, 42);
|
||||
expect(result.textureId, 7);
|
||||
expect(result.previewSize, const Size(1920, 1080));
|
||||
});
|
||||
|
||||
test('initialize / dispose send only the handle', () async {
|
||||
handle((_) => null);
|
||||
|
||||
await backend.initialize(11);
|
||||
await backend.disposeInstance(11);
|
||||
|
||||
expect(calls.map((c) => c.method).toList(), ['initialize', 'dispose']);
|
||||
expect(calls.every((c) => (c.arguments as Map)['handle'] == 11), isTrue);
|
||||
});
|
||||
|
||||
test('setDescription returns the new previewSize', () async {
|
||||
handle((_) => {
|
||||
'previewSize': {'width': 1280, 'height': 720},
|
||||
});
|
||||
|
||||
final size = await backend.setDescription(7, 'next');
|
||||
|
||||
expect(calls.single.method, 'setDescription');
|
||||
expect(calls.single.arguments, {'handle': 7, 'cameraId': 'next'});
|
||||
expect(size, const Size(1280, 720));
|
||||
});
|
||||
|
||||
test('setFlashMode encodes the enum', () async {
|
||||
handle((_) => null);
|
||||
|
||||
await backend.setFlashMode(3, UxFlashMode.always);
|
||||
await backend.setFlashMode(3, UxFlashMode.off);
|
||||
|
||||
expect(calls.map((c) => (c.arguments as Map)['mode']).toList(),
|
||||
['always', 'off']);
|
||||
});
|
||||
|
||||
test('lock/unlockCaptureOrientation encode the orientation string',
|
||||
() async {
|
||||
handle((_) => null);
|
||||
|
||||
await backend.lockCaptureOrientation(5, DeviceOrientation.portraitUp);
|
||||
await backend.lockCaptureOrientation(5, DeviceOrientation.landscapeLeft);
|
||||
await backend.lockCaptureOrientation(5, DeviceOrientation.landscapeRight);
|
||||
await backend.lockCaptureOrientation(5, DeviceOrientation.portraitDown);
|
||||
await backend.unlockCaptureOrientation(5);
|
||||
|
||||
expect(
|
||||
calls.take(4).map((c) => (c.arguments as Map)['orientation']).toList(),
|
||||
['portraitUp', 'landscapeLeft', 'landscapeRight', 'portraitDown'],
|
||||
);
|
||||
expect(calls.last.method, 'unlockCaptureOrientation');
|
||||
});
|
||||
|
||||
test('takePicture sends snapshotOrientation and decodes the file path',
|
||||
() async {
|
||||
handle((_) => {'path': '/tmp/a.jpg'});
|
||||
|
||||
final file = await backend.takePicture(
|
||||
9, DeviceOrientation.landscapeLeft);
|
||||
|
||||
expect(calls.single.method, 'takePicture');
|
||||
expect(calls.single.arguments,
|
||||
{'handle': 9, 'snapshotOrientation': 'landscapeLeft'});
|
||||
expect(file.path, '/tmp/a.jpg');
|
||||
});
|
||||
|
||||
test('startVideoRecording sends snapshotOrientation', () async {
|
||||
handle((_) => null);
|
||||
|
||||
await backend.startVideoRecording(9, DeviceOrientation.landscapeRight);
|
||||
|
||||
expect(calls.single.method, 'startVideoRecording');
|
||||
expect(calls.single.arguments,
|
||||
{'handle': 9, 'snapshotOrientation': 'landscapeRight'});
|
||||
});
|
||||
|
||||
test('stopVideoRecording decodes the file path', () async {
|
||||
handle((_) => {'path': '/tmp/v.mp4'});
|
||||
|
||||
final file = await backend.stopVideoRecording(9);
|
||||
|
||||
expect(calls.single.method, 'stopVideoRecording');
|
||||
expect(file.path, '/tmp/v.mp4');
|
||||
});
|
||||
|
||||
test('PlatformException maps to UxCameraException carrying code/message',
|
||||
() async {
|
||||
handle((_) => throw PlatformException(
|
||||
code: 'device_busy',
|
||||
message: 'front camera in use',
|
||||
));
|
||||
|
||||
await expectLater(
|
||||
backend.initialize(1),
|
||||
throwsA(isA<UxCameraException>()
|
||||
.having((e) => e.code, 'code', 'device_busy')
|
||||
.having((e) => e.description, 'description', 'front camera in use')),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
227
test/camera/camera_controller_test.dart
Normal file
227
test/camera/camera_controller_test.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/testing.dart';
|
||||
import 'package:ux/ux.dart';
|
||||
|
||||
const _front = UxCameraDescription(
|
||||
id: 'front',
|
||||
lens: UxCameraLens.front,
|
||||
sensorOrientation: 270,
|
||||
);
|
||||
const _back = UxCameraDescription(
|
||||
id: 'back',
|
||||
lens: UxCameraLens.back,
|
||||
sensorOrientation: 90,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late FakeUxCameraBackend fake;
|
||||
|
||||
setUp(() {
|
||||
fake = FakeUxCameraBackend(cameras: const [_front, _back]);
|
||||
UxCameraBackend.instance = fake;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
UxCameraBackend.instance = MethodChannelUxCameraBackend();
|
||||
});
|
||||
|
||||
test('uxAvailableCameras dispatches through the backend', () async {
|
||||
final cameras = await uxAvailableCameras();
|
||||
expect(cameras, [_front, _back]);
|
||||
expect(fake.availableCamerasCalls, 1);
|
||||
});
|
||||
|
||||
test('initialize creates the native instance, subscribes to events, '
|
||||
'and reports previewSize / isInitialized', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
|
||||
expect(ctrl.value.isInitialized, isFalse);
|
||||
expect(ctrl.value.previewSize, isNull);
|
||||
|
||||
await ctrl.initialize();
|
||||
|
||||
expect(fake.createCalls.single,
|
||||
(cameraId: 'front', enableAudio: false, preset: UxResolutionPreset.high));
|
||||
expect(fake.initializeCalls.single, 1);
|
||||
expect(ctrl.textureId, 100);
|
||||
expect(ctrl.value.isInitialized, isTrue);
|
||||
expect(ctrl.value.previewSize, const Size(1920, 1080));
|
||||
});
|
||||
|
||||
test('deviceOrientationChanged events update value.deviceOrientation', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
expect(ctrl.value.deviceOrientation, DeviceOrientation.portraitUp);
|
||||
|
||||
fake.emitOrientationChanged(1, DeviceOrientation.landscapeLeft);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(ctrl.value.deviceOrientation, DeviceOrientation.landscapeLeft);
|
||||
|
||||
fake.emitOrientationChanged(1, DeviceOrientation.portraitDown);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(ctrl.value.deviceOrientation, DeviceOrientation.portraitDown);
|
||||
});
|
||||
|
||||
test('sessionError events surface as value.errorDescription', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
fake.emitSessionError(1, 'recorder_failed', 'writer ended');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(ctrl.value.errorDescription, 'writer ended');
|
||||
expect(ctrl.value.hasError, isTrue);
|
||||
});
|
||||
|
||||
test('takePicture passes the explicit captureOrientation through', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
final file = await ctrl.takePicture(captureOrientation: DeviceOrientation.landscapeRight);
|
||||
|
||||
expect(file.path, '/tmp/fake_picture.jpg');
|
||||
expect(fake.takePictureCalls.single,
|
||||
(handle: 1, snapshotOrientation: DeviceOrientation.landscapeRight));
|
||||
});
|
||||
|
||||
test('takePicture without captureOrientation falls back to UxSensor', () async {
|
||||
// UxSensor.orientation returns portraitUp under flutter_test (no native sensor).
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
await ctrl.takePicture();
|
||||
|
||||
expect(fake.takePictureCalls.single,
|
||||
(handle: 1, snapshotOrientation: DeviceOrientation.portraitUp));
|
||||
});
|
||||
|
||||
test('startVideoRecording flips isRecordingVideo and passes orientation', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
await ctrl.startVideoRecording(captureOrientation: DeviceOrientation.landscapeLeft);
|
||||
|
||||
expect(ctrl.value.isRecordingVideo, isTrue);
|
||||
expect(fake.startVideoRecordingCalls.single,
|
||||
(handle: 1, snapshotOrientation: DeviceOrientation.landscapeLeft));
|
||||
});
|
||||
|
||||
test('stopVideoRecording resets isRecordingVideo and returns the file', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
await ctrl.startVideoRecording(captureOrientation: DeviceOrientation.landscapeLeft);
|
||||
|
||||
final file = await ctrl.stopVideoRecording();
|
||||
|
||||
expect(ctrl.value.isRecordingVideo, isFalse);
|
||||
expect(file.path, '/tmp/fake_video.mp4');
|
||||
expect(fake.stopVideoRecordingCalls.single, 1);
|
||||
});
|
||||
|
||||
test('setDescription updates value.description and previewSize', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
fake.previewSize = const Size(1280, 720);
|
||||
await ctrl.setDescription(_back);
|
||||
|
||||
expect(ctrl.value.description, _back);
|
||||
expect(ctrl.value.previewSize, const Size(1280, 720));
|
||||
expect(fake.setDescriptionCalls.single, (handle: 1, cameraId: 'back'));
|
||||
});
|
||||
|
||||
test('setFlashMode forwards to the backend', () async {
|
||||
final ctrl = UxCameraController(_back, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
await ctrl.setFlashMode(UxFlashMode.always);
|
||||
|
||||
expect(fake.setFlashModeCalls.single, (handle: 1, mode: UxFlashMode.always));
|
||||
});
|
||||
|
||||
test('lockCaptureOrientation / unlockCaptureOrientation forward to backend',
|
||||
() async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
await ctrl.lockCaptureOrientation(DeviceOrientation.portraitUp);
|
||||
expect(fake.lockCaptureOrientationCalls.single,
|
||||
(handle: 1, orientation: DeviceOrientation.portraitUp));
|
||||
|
||||
await ctrl.unlockCaptureOrientation();
|
||||
expect(fake.unlockCaptureOrientationCalls.single, 1);
|
||||
});
|
||||
|
||||
test('dispose tears down the native instance and is idempotent', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
await ctrl.initialize();
|
||||
|
||||
await ctrl.dispose();
|
||||
expect(fake.disposeCalls.single, 1);
|
||||
|
||||
// Second dispose is a no-op (does not call backend again, does not throw).
|
||||
await ctrl.dispose();
|
||||
expect(fake.disposeCalls, [1]);
|
||||
});
|
||||
|
||||
test('calls against a disposed controller throw UxCameraException', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
await ctrl.initialize();
|
||||
await ctrl.dispose();
|
||||
|
||||
expect(() => ctrl.takePicture(captureOrientation: DeviceOrientation.portraitUp),
|
||||
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'disposed')));
|
||||
});
|
||||
|
||||
test('calls before initialize throw UxCameraException("not_initialized")',
|
||||
() async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
|
||||
expect(() => ctrl.setFlashMode(UxFlashMode.off),
|
||||
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'not_initialized')));
|
||||
});
|
||||
|
||||
test('multi-instance: two controllers get distinct handles + textures',
|
||||
() async {
|
||||
final a = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
final b = UxCameraController(_back, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(a.dispose);
|
||||
addTearDown(b.dispose);
|
||||
|
||||
await a.initialize();
|
||||
await b.initialize();
|
||||
|
||||
expect(a.textureId, isNot(b.textureId));
|
||||
expect(fake.createCalls.map((c) => c.cameraId), ['front', 'back']);
|
||||
|
||||
// Events route per-handle: orientation update to A doesn't touch B.
|
||||
fake.emitOrientationChanged(1, DeviceOrientation.landscapeLeft);
|
||||
fake.emitOrientationChanged(2, DeviceOrientation.landscapeRight);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(a.value.deviceOrientation, DeviceOrientation.landscapeLeft);
|
||||
expect(b.value.deviceOrientation, DeviceOrientation.landscapeRight);
|
||||
});
|
||||
|
||||
test('initialize propagates UxCameraException("permission_denied")', () async {
|
||||
fake.createError = const UxCameraException('permission_denied', 'camera');
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
|
||||
expect(() => ctrl.initialize(),
|
||||
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'permission_denied')));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user