diff --git a/lib/src/camera/camera.dart b/lib/src/camera/camera.dart new file mode 100644 index 0000000..8abebff --- /dev/null +++ b/lib/src/camera/camera.dart @@ -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> 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 { + UxCameraController( + UxCameraDescription description, + this.resolutionPreset, { + required bool enableAudio, + }) : super(UxCameraValue( + description: description, + enableAudio: enableAudio, + )); + + final UxResolutionPreset resolutionPreset; + + int? _handle; + int? _textureId; + StreamSubscription? _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 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 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 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 setFlashMode(UxFlashMode mode) async { + final handle = _requireHandle('setFlashMode'); + await UxCameraBackend.instance.setFlashMode(handle, mode); + } + + Future lockCaptureOrientation(DeviceOrientation orientation) async { + final handle = _requireHandle('lockCaptureOrientation'); + await UxCameraBackend.instance.lockCaptureOrientation(handle, orientation); + } + + Future 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 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 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 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'); + } + } +} diff --git a/lib/src/camera/camera_backend.dart b/lib/src/camera/camera_backend.dart new file mode 100644 index 0000000..e425d05 --- /dev/null +++ b/lib/src/camera/camera_backend.dart @@ -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> 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 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 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 disposeInstance(int handle); + + /// Swap to a different camera mid-session. Resets the lock and clears + /// any pending recording. Returns the new preview size. + Future 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 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 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 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 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 startVideoRecording( + int handle, + DeviceOrientation snapshotOrientation, + ); + + /// Stop recording and return the resulting MP4 on disk. + Future 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 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); +} diff --git a/lib/src/camera/camera_channel.dart b/lib/src/camera/camera_channel.dart new file mode 100644 index 0000000..239169d --- /dev/null +++ b/lib/src/camera/camera_channel.dart @@ -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 _rawEvents = + _eventsChannel.receiveBroadcastStream(); + + @override + Future> availableCameras() async { + final raw = await _invoke>('availableCameras'); + return raw.map((e) { + final m = (e as Map).cast(); + 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 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(); + 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 initialize(int handle) => + _invokeVoid('initialize', {'handle': handle}); + + @override + Future disposeInstance(int handle) => + _invokeVoid('dispose', {'handle': handle}); + + @override + Future setDescription(int handle, String cameraId) async { + final m = await _invokeMap('setDescription', { + 'handle': handle, + 'cameraId': cameraId, + }); + final s = (m['previewSize'] as Map).cast(); + return Size( + (s['width'] as num).toDouble(), + (s['height'] as num).toDouble(), + ); + } + + @override + Future setFlashMode(int handle, UxFlashMode mode) => + _invokeVoid('setFlashMode', { + 'handle': handle, + 'mode': _flashArg(mode), + }); + + @override + Future lockCaptureOrientation( + int handle, + DeviceOrientation orientation, + ) => + _invokeVoid('lockCaptureOrientation', { + 'handle': handle, + 'orientation': _orientationArg(orientation), + }); + + @override + Future unlockCaptureOrientation(int handle) => + _invokeVoid('unlockCaptureOrientation', {'handle': handle}); + + @override + Future takePicture( + int handle, + DeviceOrientation snapshotOrientation, + ) async { + final m = await _invokeMap('takePicture', { + 'handle': handle, + 'snapshotOrientation': _orientationArg(snapshotOrientation), + }); + return UxFile(m['path'] as String); + } + + @override + Future startVideoRecording( + int handle, + DeviceOrientation snapshotOrientation, + ) => + _invokeVoid('startVideoRecording', { + 'handle': handle, + 'snapshotOrientation': _orientationArg(snapshotOrientation), + }); + + @override + Future stopVideoRecording(int handle) async { + final m = await _invokeMap('stopVideoRecording', {'handle': handle}); + return UxFile(m['path'] as String); + } + + @override + Stream events(int handle) { + return _rawEvents + .map((e) => (e as Map).cast()) + .where((m) => (m['handle'] as num).toInt() == handle) + .map(_decodeEvent); + } + + // ---- parsers / arg encoders ------------------------------------- + + static UxCameraEvent _decodeEvent(Map 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 _invoke(String method, [Map? args]) async { + try { + final result = await _channel.invokeMethod(method, args); + return result as T; + } on PlatformException catch (e) { + throw UxCameraException(e.code, e.message); + } + } + + Future> _invokeMap( + String method, [ + Map? args, + ]) async { + final result = await _invoke>(method, args); + return result; + } + + Future _invokeVoid(String method, [Map? args]) async { + try { + await _channel.invokeMethod(method, args); + } on PlatformException catch (e) { + throw UxCameraException(e.code, e.message); + } + } +} diff --git a/lib/src/camera/camera_preview.dart b/lib/src/camera/camera_preview.dart new file mode 100644 index 0000000..66b69d9 --- /dev/null +++ b/lib/src/camera/camera_preview.dart @@ -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( + valueListenable: controller, + builder: (context, _, __) { + final id = controller.textureId; + if (id == null) return const SizedBox.expand(); + return Texture(textureId: id); + }, + ); + } +} diff --git a/lib/src/testing/fake_camera.dart b/lib/src/testing/fake_camera.dart new file mode 100644 index 0000000..eca9f77 --- /dev/null +++ b/lib/src/testing/fake_camera.dart @@ -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 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 initializeCalls = []; + final List disposeCalls = []; + final List<({int handle, String cameraId})> setDescriptionCalls = []; + final List<({int handle, UxFlashMode mode})> setFlashModeCalls = []; + final List<({int handle, DeviceOrientation orientation})> + lockCaptureOrientationCalls = []; + final List unlockCaptureOrientationCalls = []; + final List<({int handle, DeviceOrientation snapshotOrientation})> + takePictureCalls = []; + final List<({int handle, DeviceOrientation snapshotOrientation})> + startVideoRecordingCalls = []; + final List stopVideoRecordingCalls = []; + int availableCamerasCalls = 0; + + // ---- configurable returns --------------------------------------- + + /// Cameras returned by [availableCameras]. Mutable so tests can swap + /// the set between assertions. + List 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> _eventControllers = {}; + + StreamController _controllerFor(int handle) { + return _eventControllers.putIfAbsent( + handle, + () => StreamController.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> availableCameras() async { + availableCamerasCalls += 1; + return cameras; + } + + @override + Future 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 initialize(int handle) async { + initializeCalls.add(handle); + final err = initializeError; + if (err != null) throw err; + } + + @override + Future disposeInstance(int handle) async { + disposeCalls.add(handle); + await _eventControllers.remove(handle)?.close(); + } + + @override + Future setDescription(int handle, String cameraId) async { + setDescriptionCalls.add((handle: handle, cameraId: cameraId)); + return previewSize; + } + + @override + Future setFlashMode(int handle, UxFlashMode mode) async { + setFlashModeCalls.add((handle: handle, mode: mode)); + } + + @override + Future lockCaptureOrientation( + int handle, + DeviceOrientation orientation, + ) async { + lockCaptureOrientationCalls + .add((handle: handle, orientation: orientation)); + } + + @override + Future unlockCaptureOrientation(int handle) async { + unlockCaptureOrientationCalls.add(handle); + } + + @override + Future 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 startVideoRecording( + int handle, + DeviceOrientation snapshotOrientation, + ) async { + startVideoRecordingCalls + .add((handle: handle, snapshotOrientation: snapshotOrientation)); + final err = startVideoRecordingError; + if (err != null) throw err; + } + + @override + Future stopVideoRecording(int handle) async { + stopVideoRecordingCalls.add(handle); + final err = stopVideoRecordingError; + if (err != null) throw err; + return UxFile(videoPath); + } + + @override + Stream events(int handle) => _controllerFor(handle).stream; +} diff --git a/lib/testing.dart b/lib/testing.dart index 29f0b80..9a3b38f 100644 --- a/lib/testing.dart +++ b/lib/testing.dart @@ -5,5 +5,6 @@ /// ``` library; +export 'src/testing/fake_camera.dart'; export 'src/testing/fake_gallery.dart'; export 'src/testing/text_golden.dart'; diff --git a/lib/ux.dart b/lib/ux.dart index f7ca018..191991c 100644 --- a/lib/ux.dart +++ b/lib/ux.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'; diff --git a/test/camera/camera_channel_test.dart b/test/camera/camera_channel_test.dart new file mode 100644 index 0000000..6a83b9d --- /dev/null +++ b/test/camera/camera_channel_test.dart @@ -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 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() + .having((e) => e.code, 'code', 'device_busy') + .having((e) => e.description, 'description', 'front camera in use')), + ); + }); + }); +} diff --git a/test/camera/camera_controller_test.dart b/test/camera/camera_controller_test.dart new file mode 100644 index 0000000..f8650e2 --- /dev/null +++ b/test/camera/camera_controller_test.dart @@ -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.delayed(Duration.zero); + expect(ctrl.value.deviceOrientation, DeviceOrientation.landscapeLeft); + + fake.emitOrientationChanged(1, DeviceOrientation.portraitDown); + await Future.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.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().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().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.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().having((e) => e.code, 'code', 'permission_denied'))); + }); +}