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