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:
130
lib/src/camera/camera_backend.dart
Normal file
130
lib/src/camera/camera_backend.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../file.dart' show UxFile;
|
||||
import 'camera.dart' show UxCameraDescription, UxFlashMode, UxResolutionPreset;
|
||||
import 'camera_channel.dart' show MethodChannelUxCameraBackend;
|
||||
|
||||
/// Backend contract that [UxCameraController] dispatches into. The default
|
||||
/// implementation calls into native code via the `ux/camera` /
|
||||
/// `ux/camera/events` channels; tests substitute their own (see
|
||||
/// `package:ux/testing.dart`'s `FakeUxCameraBackend`).
|
||||
///
|
||||
/// Every per-instance call carries a `handle` returned by [create] so
|
||||
/// the plugin can route to the right native session. Multiple
|
||||
/// controllers can hold simultaneous handles.
|
||||
abstract class UxCameraBackend {
|
||||
/// Swap to inject a fake before any UI code mounts a controller.
|
||||
static UxCameraBackend instance = MethodChannelUxCameraBackend();
|
||||
|
||||
/// Enumerate camera devices. The result is stable for the lifetime
|
||||
/// of the process.
|
||||
Future<List<UxCameraDescription>> availableCameras();
|
||||
|
||||
/// Allocate a native camera instance bound to [cameraId]. Returns the
|
||||
/// handle (for subsequent calls), the FlutterTexture id (for the
|
||||
/// preview widget), and the sensor-natural-orientation preview size.
|
||||
///
|
||||
/// Throws [UxCameraException("device_busy")] if another instance
|
||||
/// already holds the device, or [UxCameraException("audio_busy")]
|
||||
/// when [enableAudio] is true and another instance holds the
|
||||
/// app-global audio session.
|
||||
Future<UxCameraCreateResult> create({
|
||||
required String cameraId,
|
||||
required bool enableAudio,
|
||||
required UxResolutionPreset preset,
|
||||
});
|
||||
|
||||
/// Start the session. Native side resolves camera + audio permissions
|
||||
/// before the future completes. Throws
|
||||
/// [UxCameraException("permission_denied")] on denial.
|
||||
Future<void> initialize(int handle);
|
||||
|
||||
/// Tear down the session. Cancels any in-flight recording, releases
|
||||
/// the camera device and the audio claim. Safe to call repeatedly;
|
||||
/// no-op once disposed.
|
||||
Future<void> disposeInstance(int handle);
|
||||
|
||||
/// Swap to a different camera mid-session. Resets the lock and clears
|
||||
/// any pending recording. Returns the new preview size.
|
||||
Future<Size> setDescription(int handle, String cameraId);
|
||||
|
||||
/// Set the flash mode used for the next [takePicture]. On front cameras
|
||||
/// without a screen-flash fallback the backend silently no-ops; the
|
||||
/// caller is responsible for not offering flash UI there.
|
||||
Future<void> setFlashMode(int handle, UxFlashMode mode);
|
||||
|
||||
/// Pin the preview's connection orientation. Used today only to lock
|
||||
/// the preview to portrait so it never appears stretched/rotated.
|
||||
Future<void> lockCaptureOrientation(int handle, DeviceOrientation orientation);
|
||||
|
||||
/// Release the orientation lock — the preview falls back to following
|
||||
/// physical device orientation. Unused by the chat composer; kept for
|
||||
/// API symmetry.
|
||||
Future<void> unlockCaptureOrientation(int handle);
|
||||
|
||||
/// Take a still photo. [snapshotOrientation] is applied to the photo
|
||||
/// connection just before capture so the file's EXIF orientation
|
||||
/// matches how the user was holding the device.
|
||||
Future<UxFile> takePicture(int handle, DeviceOrientation snapshotOrientation);
|
||||
|
||||
/// Begin recording video. [snapshotOrientation] is baked into the
|
||||
/// writer track's transform — the file plays back rotated even if
|
||||
/// the device returns to portrait mid-recording.
|
||||
Future<void> startVideoRecording(
|
||||
int handle,
|
||||
DeviceOrientation snapshotOrientation,
|
||||
);
|
||||
|
||||
/// Stop recording and return the resulting MP4 on disk.
|
||||
Future<UxFile> stopVideoRecording(int handle);
|
||||
|
||||
/// Live event stream for [handle]: device-orientation changes,
|
||||
/// session errors, interrupted/resumed lifecycle pings. The
|
||||
/// controller subscribes during [initialize] and unsubscribes on
|
||||
/// [disposeInstance].
|
||||
Stream<UxCameraEvent> events(int handle);
|
||||
}
|
||||
|
||||
/// The tuple returned by [UxCameraBackend.create] — everything the
|
||||
/// controller needs to start serving the preview widget and routing
|
||||
/// subsequent calls.
|
||||
class UxCameraCreateResult {
|
||||
const UxCameraCreateResult({
|
||||
required this.handle,
|
||||
required this.textureId,
|
||||
required this.previewSize,
|
||||
});
|
||||
|
||||
final int handle;
|
||||
final int textureId;
|
||||
final Size previewSize;
|
||||
}
|
||||
|
||||
/// Events pushed by the native side over `ux/camera/events`. Sealed —
|
||||
/// new variants land here as the contract grows.
|
||||
sealed class UxCameraEvent {
|
||||
const UxCameraEvent(this.handle);
|
||||
final int handle;
|
||||
}
|
||||
|
||||
class UxCameraDeviceOrientationChanged extends UxCameraEvent {
|
||||
const UxCameraDeviceOrientationChanged(super.handle, this.orientation);
|
||||
final DeviceOrientation orientation;
|
||||
}
|
||||
|
||||
class UxCameraSessionError extends UxCameraEvent {
|
||||
const UxCameraSessionError(super.handle, this.code, this.description);
|
||||
final String code;
|
||||
final String? description;
|
||||
}
|
||||
|
||||
class UxCameraSessionInterrupted extends UxCameraEvent {
|
||||
const UxCameraSessionInterrupted(super.handle, this.reason);
|
||||
final String reason;
|
||||
}
|
||||
|
||||
class UxCameraSessionResumed extends UxCameraEvent {
|
||||
const UxCameraSessionResumed(super.handle);
|
||||
}
|
||||
Reference in New Issue
Block a user