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:
agra
2026-05-13 14:27:52 +03:00
parent 1e7ffde127
commit 45aac312a8
9 changed files with 1282 additions and 0 deletions

299
lib/src/camera/camera.dart Normal file
View 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');
}
}
}

View 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);
}

View 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);
}
}
}

View 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);
},
);
}
}

View 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;
}

View File

@@ -5,5 +5,6 @@
/// ```
library;
export 'src/testing/fake_camera.dart';
export 'src/testing/fake_gallery.dart';
export 'src/testing/text_golden.dart';

View File

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

View 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')),
);
});
});
}

View 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')));
});
}