From cbe2338adf01396e6c4352c7ff696150d9581ca9 Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Thu, 18 Jun 2026 15:53:01 +0200 Subject: [PATCH 1/3] feat: add read-only getDevices() to list paired glasses Adds MetaWearablesDat.getDevices() returning List so apps can enumerate paired Ray-Ban Meta / Oakley devices, tell two pairs of the same model apart (by name/id), and see which pair is actually streaming (isStreamingDevice) vs the auto-selector's current pick (isActive). - Dart: WearableDevice model + WearableDeviceType / WearableLinkState / WearableCompatibility enums (tolerant fromMap, non-empty id required); facade, platform interface, and method-channel decode. - iOS: getDevices over Wearables.shared.devices + deviceForIdentifier (complete fallback on nil), canonical codes (@unknown default on the non-frozen DeviceType/Compatibility); isStreamingDevice gated on Stream.state == .streaming, not a stale reference. - Android: getDevices over Wearables.devices / devicesMetadata with a NOT_INITIALIZED guard; sessionDeviceId captured before createSession and cleared on teardown; isStreamingDevice gated on Stream.state == STREAMING. - Example: PairedDevicesSheet + Devices FAB (visible while streaming); provider refreshDevices() with coalescing, name/id sort, and refresh on active-device and session-state transitions. - Tests: first Dart unit tests (model fromMap, enum fallbacks, channel decode incl. null->[] and PlatformException, facade delegation). - Docs: CHANGELOG 0.6.0, README, AGENTS; pubspec bumped to 0.6.0. Read-only; device pinning (SpecificDeviceSelector via id) to follow. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 5 + CHANGELOG.md | 5 + README.md | 9 + .../MetaWearablesDatPlugin.kt | 133 ++++++++- example/lib/main.dart | 126 +++++---- example/lib/providers/stream_provider.dart | 126 ++++++--- .../screens/stream/paired_devices_sheet.dart | 262 ++++++++++++++++++ .../MetaWearablesDatPlugin.swift | 85 ++++++ lib/flutter_meta_wearables_dat.dart | 219 +++++++++++++++ lib/meta_wearables_dat_method_channel.dart | 16 ++ ...meta_wearables_dat_platform_interface.dart | 4 + pubspec.yaml | 2 +- test/method_channel_get_devices_test.dart | 111 ++++++++ test/wearable_device_test.dart | 168 +++++++++++ 14 files changed, 1187 insertions(+), 84 deletions(-) create mode 100644 example/lib/screens/stream/paired_devices_sheet.dart create mode 100644 test/method_channel_get_devices_test.dart create mode 100644 test/wearable_device_test.dart diff --git a/AGENTS.md b/AGENTS.md index b4cb055..f0afea0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,9 @@ Communication: | `PhotoCaptureFormat` | `heic('heic')`, `jpeg('jpeg')` | | `FrameFormat` | `rawRgba`, `rawStraightRgba`, `png` | | `ThermalLevel` | `unknown(0)`, `none(1)`, `light(2)`, `moderate(3)`, `severe(4)`, `critical(5)`, `emergency(6)`, `shutdown(7)` | +| `WearableDeviceType` | `rayBanMeta`, `oakleyMetaHSTN`, `oakleyMetaVanguard`, `metaRayBanDisplay`, `rayBanMetaOptics`, `unknown` | +| `WearableLinkState` | `disconnected`, `connecting`, `connected`, `unknown` | +| `WearableCompatibility` | `undefined`, `compatible`, `deviceUpdateRequired`, `sdkUpdateRequired` | ### Classes @@ -59,6 +62,7 @@ Communication: | `VideoFrame` | `codec` (VideoCodec), `bytes` (Uint8List), `width`, `height`, `presentationTimestampUs`, `isKeyframe` | | `BackgroundNotification` | `title`, `text`, `channelId`, `channelName`, `iconResourceName?` (Android only) | | `DeviceState` | `thermalLevel` (ThermalLevel) | +| `WearableDevice` | `id`, `name`, `type` (WearableDeviceType), `linkState` (WearableLinkState), `compatibility` (WearableCompatibility), `supportsDisplay`, `isActive`, `isStreamingDevice`, `firmwareInfo?` | ### Error codes (StreamSessionError) @@ -96,6 +100,7 @@ static Future getCameraPermissionStatus() // Device monitoring static Stream activeDeviceStream() +static Future> getDevices() // paired glasses; isStreamingDevice/isActive flag the streaming/selected pair. Android throws NOT_INITIALIZED before BT permission static Future restartActiveDeviceMonitoring() // No-op on iOS // Streaming diff --git a/CHANGELOG.md b/CHANGELOG.md index 85fda65..bfc6719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.6.0 +* New `getDevices()` returning `List` — enumerate the paired glasses with their `name`, model (`WearableDeviceType`), link state, compatibility, and display capability. `isStreamingDevice` flags the pair the current stream is actually using; `isActive` flags the pair the shared auto-selector would bind a *new* stream to (the two differ when more than one pair is connected). Use it to tell two pairs of the same model apart and see which one you're streaming from. Read-only — *selecting* (pinning) a specific pair is planned for a follow-up. +* New types: `WearableDevice`, `WearableDeviceType`, `WearableLinkState`, `WearableCompatibility`. Non-breaking, drop-in addition. +* Example app: a "Paired devices" sheet (devices button, top-right) lists every paired pair and marks the streaming / selected one. + ## 0.5.3 * iOS: Fix first-registration device discovery and a `startStreamSession` hang after a failed start; surface genuine session errors. diff --git a/README.md b/README.md index ca0c7ed..f3357d0 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ The plugin follows Meta's integration lifecycle as documented in the [Meta Weara - Handle the callback URL with `MetaWearablesDat.handleUrl(url)` to complete registration - Monitor registration state via `MetaWearablesDat.registrationStateStream()` - Monitor active device availability via `MetaWearablesDat.activeDeviceStream()` +- List the paired glasses (name, model, and which pair is streaming) via `MetaWearablesDat.getDevices()` — useful when more than one pair is paired ### 2. Permissions (First-time camera access) - When your app first attempts to access the camera, request permission @@ -302,6 +303,14 @@ MetaWearablesDat.deviceStateStream().listen((state) { print('Thermal: ${state.thermalLevel}'); }); +// (Optional) List the paired glasses — names, models, and which pair is +// streaming. Handy when two pairs of the same model are paired. +final devices = await MetaWearablesDat.getDevices(); +for (final d in devices) { + print('${d.name} (${d.type.code})' + '${d.isStreamingDevice ? ' — streaming' : ''}'); +} + // Capture a photo during streaming final photo = await MetaWearablesDat.capturePhoto( deviceUUID, diff --git a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt index 5abcb75..72e91c4 100644 --- a/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt +++ b/android/src/main/kotlin/io/rodcone/flutter_meta_wearables_dat/MetaWearablesDatPlugin.kt @@ -135,6 +135,13 @@ class MetaWearablesDatPlugin : private var session: DeviceSession? = null private var stream: Stream? = null private var sessionKey: String? = null + + // Device the current (reusable) DeviceSession is bound to. Captured at + // session creation — from the auto-selector's pick *before* createSession, + // so a later A→B switch can't misattribute it — and cleared on session + // teardown. Combined with a live STREAMING check in `getDevices`, it tells + // which device is actually streaming. + @Volatile private var sessionDeviceId: String? = null private var videoJob: Job? = null private var streamErrorJob: Job? = null private var deviceAvailabilityJob: Job? = null @@ -235,6 +242,7 @@ class MetaWearablesDatPlugin : "startRegistration" -> startRegistration(result) "disconnect" -> disconnect(result) "getRegistrationState" -> getRegistrationState(result) + "getDevices" -> getDevices(result) "getCameraPermissionStatus" -> getCameraPermissionStatus(result) "requestCameraPermission" -> requestCameraPermission(result) "handleUrl" -> handleUrl(call, result) @@ -605,6 +613,121 @@ class MetaWearablesDatPlugin : } } + /** + * Returns a snapshot of all paired devices as a list of maps, decoded by + * `WearableDevice.fromMap` on the Dart side. Requires Bluetooth permissions + * (the SDK can't enumerate devices until initialized) — returns a + * `NOT_INITIALIZED` error otherwise rather than a misleading empty list. + * + * `isActive` = the shared auto-selector's current pick (what a new stream + * would bind to). `isStreamingDevice` = the device the *live* stream uses, + * gated on the stream's actual STREAMING state so a stale reference after + * an SDK-driven stop doesn't report a false positive. + */ + private fun getDevices(result: Result) { + if (!btPermissionsGranted || application == null) { + result.error( + "NOT_INITIALIZED", + "Bluetooth permissions not granted; call requestAndroidPermissions() first.", + null, + ) + return + } + scope.launch { + try { + ensureWearablesInitialized() + val active = deviceSelector.activeDevice()?.identifier + val streaming = + stream?.state?.value == + com.meta.wearable.dat.camera.types.StreamState.STREAMING + val sessionId = sessionDeviceId + val devices = + Wearables.devices.value.mapNotNull { id -> + val idStr = id.identifier + if (idStr.isEmpty()) return@mapNotNull null + val device = Wearables.devicesMetadata[id]?.value + val isActive = idStr == active + val isStreamingDevice = streaming && idStr == sessionId + if (device != null) { + mapOf( + "id" to idStr, + "name" to device.name, + "deviceType" to deviceTypeCode(device.deviceType), + "linkState" to linkStateCode(device.linkState), + "compatibility" to + compatibilityCode(device.compatibility), + "supportsDisplay" to device.isDisplayCapable(), + "isActive" to isActive, + "isStreamingDevice" to isStreamingDevice, + "firmwareInfo" to device.firmwareInfo, + ) + } else { + // Metadata unavailable — complete fallback so the + // count still matches Wearables.devices. + mapOf( + "id" to idStr, + "name" to idStr, + "deviceType" to "unknown", + "linkState" to "unknown", + "compatibility" to "undefined", + "supportsDisplay" to false, + "isActive" to isActive, + "isStreamingDevice" to isStreamingDevice, + "firmwareInfo" to null, + ) + } + } + result.success(devices) + } catch (e: Exception) { + result.error( + "GET_DEVICES_ERROR", + e.message ?: "Failed to list devices.", + null, + ) + } + } + } + + /** Canonical device-type code, kept identical to the iOS side. */ + private fun deviceTypeCode(type: com.meta.wearable.dat.core.types.DeviceType): String { + return when (type) { + com.meta.wearable.dat.core.types.DeviceType.RAYBAN_META -> "rayBanMeta" + com.meta.wearable.dat.core.types.DeviceType.OAKLEY_META_HSTN -> "oakleyMetaHSTN" + com.meta.wearable.dat.core.types.DeviceType.OAKLEY_META_VANGUARD -> + "oakleyMetaVanguard" + com.meta.wearable.dat.core.types.DeviceType.META_RAYBAN_DISPLAY -> + "metaRayBanDisplay" + com.meta.wearable.dat.core.types.DeviceType.RAYBAN_META_OPTICS -> "rayBanMetaOptics" + com.meta.wearable.dat.core.types.DeviceType.UNKNOWN -> "unknown" + else -> "unknown" + } + } + + /** Canonical link-state code, kept identical to the iOS side. */ + private fun linkStateCode(state: com.meta.wearable.dat.core.types.LinkState): String { + return when (state) { + com.meta.wearable.dat.core.types.LinkState.DISCONNECTED -> "disconnected" + com.meta.wearable.dat.core.types.LinkState.CONNECTING -> "connecting" + com.meta.wearable.dat.core.types.LinkState.CONNECTED -> "connected" + else -> "unknown" + } + } + + /** Canonical compatibility code, kept identical to the iOS side. */ + private fun compatibilityCode( + compatibility: com.meta.wearable.dat.core.types.DeviceCompatibility + ): String { + return when (compatibility) { + com.meta.wearable.dat.core.types.DeviceCompatibility.UNDEFINED -> "undefined" + com.meta.wearable.dat.core.types.DeviceCompatibility.COMPATIBLE -> "compatible" + com.meta.wearable.dat.core.types.DeviceCompatibility.DEVICE_UPDATE_REQUIRED -> + "deviceUpdateRequired" + com.meta.wearable.dat.core.types.DeviceCompatibility.SDK_UPDATE_REQUIRED -> + "sdkUpdateRequired" + else -> "undefined" + } + } + private fun getRegistrationState(result: Result) { try { ensureWearablesInitialized() @@ -964,10 +1087,17 @@ class MetaWearablesDatPlugin : } } + // Capture the auto-selector's current pick *before* createSession so a + // later A→B switch can't misattribute which device this session bound. + val candidate = deviceSelector.activeDevice()?.identifier var created: DeviceSession? = null Wearables.createSession(deviceSelector) - .onSuccess { created = it } + .onSuccess { + created = it + sessionDeviceId = candidate + } .onFailure { error, _ -> + sessionDeviceId = null val identifier = error.description.lowercase() val code = when { @@ -1179,6 +1309,7 @@ class MetaWearablesDatPlugin : Log.w(TAG, "Error stopping session: ${e.message}") } session = null + sessionDeviceId = null } // endregion diff --git a/example/lib/main.dart b/example/lib/main.dart index 6b072fd..006708d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,6 +10,7 @@ import 'package:flutter_meta_wearables_dat_example/providers/stream_provider.dar import 'package:flutter_meta_wearables_dat_example/screens/home/home_screen.dart'; import 'package:flutter_meta_wearables_dat_example/screens/mock_device/mock_device_sheet.dart'; import 'package:flutter_meta_wearables_dat_example/screens/settings/settings_sheet.dart'; +import 'package:flutter_meta_wearables_dat_example/screens/stream/paired_devices_sheet.dart'; import 'package:flutter_meta_wearables_dat_example/screens/stream/stream_screen.dart'; import 'package:provider/provider.dart'; @@ -114,60 +115,83 @@ class _MyAppState extends State with WidgetsBindingObserver { child: MaterialApp( navigatorKey: navigatorKey, home: Builder( - builder: (context) => - Consumer2( + builder: (context) => Consumer2( builder: (context, deviceProvider, streamProvider, _) => Scaffold( floatingActionButtonLocation: FloatingActionButtonLocation.endTop, - floatingActionButton: streamProvider.isStreaming - ? null - : Padding( - padding: const EdgeInsets.only(top: 8, right: 5), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (deviceProvider.isRegistered) - Padding( - padding: const EdgeInsets.only(right: 5), - child: FloatingActionButton.small( - heroTag: 'settings', - backgroundColor: Colors.blueAccent, - tooltip: 'Settings', - onPressed: () { - HapticFeedback.lightImpact(); - showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (ctx) => const SettingsSheet(), - ); - }, - child: const Icon( - Icons.settings, - color: Colors.white, - ), - ), - ), - if (!deviceProvider.isRegistered) - FloatingActionButton.small( - heroTag: 'mock_device', - backgroundColor: Colors.blueAccent, - tooltip: 'Mock device', - onPressed: () { - HapticFeedback.lightImpact(); - showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (ctx) => const MockDeviceSheet(), - ); - }, - child: const Icon( - Icons.bug_report, - color: Colors.white, - ), - ), - ], + floatingActionButton: Padding( + padding: const EdgeInsets.only(top: 8, right: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Paired devices — visible whenever registered, including + // while streaming, so the "Streaming" badge stays reachable. + if (deviceProvider.isRegistered) + Padding( + padding: const EdgeInsets.only(right: 5), + child: FloatingActionButton.small( + heroTag: 'devices', + backgroundColor: Colors.blueAccent, + tooltip: 'Paired devices', + onPressed: () { + HapticFeedback.lightImpact(); + showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (ctx) => const PairedDevicesSheet(), + ); + }, + child: const Icon( + Icons.devices, + color: Colors.white, + ), + ), ), - ), + // Settings — hidden while streaming to keep the video clear. + if (!streamProvider.isStreaming && + deviceProvider.isRegistered) + Padding( + padding: const EdgeInsets.only(right: 5), + child: FloatingActionButton.small( + heroTag: 'settings', + backgroundColor: Colors.blueAccent, + tooltip: 'Settings', + onPressed: () { + HapticFeedback.lightImpact(); + showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (ctx) => const SettingsSheet(), + ); + }, + child: const Icon( + Icons.settings, + color: Colors.white, + ), + ), + ), + if (!streamProvider.isStreaming && + !deviceProvider.isRegistered) + FloatingActionButton.small( + heroTag: 'mock_device', + backgroundColor: Colors.blueAccent, + tooltip: 'Mock device', + onPressed: () { + HapticFeedback.lightImpact(); + showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (ctx) => const MockDeviceSheet(), + ); + }, + child: const Icon( + Icons.bug_report, + color: Colors.white, + ), + ), + ], + ), + ), body: Consumer3< DeviceProvider, diff --git a/example/lib/providers/stream_provider.dart b/example/lib/providers/stream_provider.dart index 16e2d83..30f763c 100644 --- a/example/lib/providers/stream_provider.dart +++ b/example/lib/providers/stream_provider.dart @@ -35,6 +35,12 @@ class StreamSessionProvider extends ChangeNotifier { int? _textureId; bool _backgroundStreamingEnabled = false; + List _devices = []; + bool _devicesLoading = false; + String? _devicesError; + bool _refreshingDevices = false; + bool _pendingDevicesRefresh = false; + StreamSessionProvider(this.deviceProvider, this.mockDeviceProvider) { _initializeActiveDeviceMonitoring(); _initializeDeviceStateMonitoring(); @@ -56,6 +62,15 @@ class StreamSessionProvider extends ChangeNotifier { bool get supportsHvc1 => Platform.isIOS; bool get backgroundStreamingEnabled => _backgroundStreamingEnabled; + /// Snapshot of paired devices from the last [refreshDevices] call. + List get devices => _devices; + + /// True while a [refreshDevices] call is in flight. + bool get devicesLoading => _devicesLoading; + + /// Human-readable error from the last [refreshDevices] call, or null. + String? get devicesError => _devicesError; + /// Current thermal level of the active device, or `null` if no device is /// active or the SDK hasn't reported a level yet. Updated live via /// [MetaWearablesDat.deviceStateStream]. @@ -71,6 +86,9 @@ class StreamSessionProvider extends ChangeNotifier { _thermalLevel = null; } notifyListeners(); + // Refresh on both attach and detach so an open paired-devices sheet + // reflects connection / active-device changes. + unawaited(refreshDevices()); }, onError: (dynamic error) { debugPrint('[MetaWearablesDAT] Error in active device stream: $error'); @@ -95,6 +113,45 @@ class StreamSessionProvider extends ChangeNotifier { ); } + /// Fetches the current paired-device list via [MetaWearablesDat.getDevices] + /// and notifies listeners. Safe to call repeatedly: while a call is in + /// flight, a concurrent call is coalesced into a single follow-up run so the + /// latest transition is never dropped. + Future refreshDevices() async { + if (_refreshingDevices) { + _pendingDevicesRefresh = true; + return; + } + _refreshingDevices = true; + _devicesLoading = true; + _devicesError = null; + notifyListeners(); + try { + final list = await MetaWearablesDat.getDevices(); + // Android returns an unordered Set, so sort for stable UI. + list.sort((a, b) { + final byName = a.name.toLowerCase().compareTo(b.name.toLowerCase()); + return byName != 0 ? byName : a.id.compareTo(b.id); + }); + _devices = list; + _devicesError = null; + } on PlatformException catch (e) { + _devicesError = e.code == 'NOT_INITIALIZED' + ? 'Grant Bluetooth permission and register first.' + : (e.message ?? 'Failed to load devices.'); + } catch (e) { + _devicesError = 'Failed to load devices.'; + } finally { + _devicesLoading = false; + _refreshingDevices = false; + notifyListeners(); + if (_pendingDevicesRefresh) { + _pendingDevicesRefresh = false; + unawaited(refreshDevices()); + } + } + } + /// Opens the Meta AI app to the DAT-app-update screen on the connected /// glasses when the SDK reports `datAppOnTheGlassesUpdateRequired`. Future openDATGlassesAppUpdate() async { @@ -196,42 +253,49 @@ class StreamSessionProvider extends ChangeNotifier { // Subscribe to session state and error streams unawaited(_sessionStateSubscription?.cancel()); - _sessionStateSubscription = - MetaWearablesDat.streamSessionStateStream().listen( - (state) { - _sessionState = state; - if (state == StreamSessionState.stopped) { - _isStreaming = false; - _textureId = null; - } - notifyListeners(); - }, - onError: (dynamic error) { - debugPrint('[MetaWearablesDAT] Session state stream error: $error'); - }, - ); + _sessionStateSubscription = MetaWearablesDat.streamSessionStateStream() + .listen( + (state) { + _sessionState = state; + if (state == StreamSessionState.stopped) { + _isStreaming = false; + _textureId = null; + } + notifyListeners(); + // Keep an open paired-devices sheet's "Streaming" badge in sync as + // the session moves through streaming / paused / stopped. + unawaited(refreshDevices()); + }, + onError: (dynamic error) { + debugPrint( + '[MetaWearablesDAT] Session state stream error: $error', + ); + }, + ); unawaited(_sessionErrorSubscription?.cancel()); - _sessionErrorSubscription = - MetaWearablesDat.streamSessionErrorStream().listen( - _setError, - onError: (dynamic error) { - debugPrint('[MetaWearablesDAT] Session error stream error: $error'); - }, - ); + _sessionErrorSubscription = MetaWearablesDat.streamSessionErrorStream() + .listen( + _setError, + onError: (dynamic error) { + debugPrint( + '[MetaWearablesDAT] Session error stream error: $error', + ); + }, + ); unawaited(_videoStreamSizeSubscription?.cancel()); _videoStreamSize = null; - _videoStreamSizeSubscription = - MetaWearablesDat.videoStreamSizeStream().listen( - (size) { - _videoStreamSize = size; - notifyListeners(); - }, - onError: (dynamic error) { - debugPrint('[MetaWearablesDAT] Video size stream error: $error'); - }, - ); + _videoStreamSizeSubscription = MetaWearablesDat.videoStreamSizeStream() + .listen( + (size) { + _videoStreamSize = size; + notifyListeners(); + }, + onError: (dynamic error) { + debugPrint('[MetaWearablesDAT] Video size stream error: $error'); + }, + ); // Start the stream session - deviceUUID is optional (uses AutoDeviceSelector if null). // Returns a texture ID for zero-copy rendering via the Flutter Texture widget. diff --git a/example/lib/screens/stream/paired_devices_sheet.dart b/example/lib/screens/stream/paired_devices_sheet.dart new file mode 100644 index 0000000..28026dc --- /dev/null +++ b/example/lib/screens/stream/paired_devices_sheet.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_meta_wearables_dat/flutter_meta_wearables_dat.dart'; +import 'package:flutter_meta_wearables_dat_example/providers/stream_provider.dart'; +import 'package:flutter_meta_wearables_dat_example/shared/widgets/sheet_handle_bar.dart'; +import 'package:provider/provider.dart'; + +/// Bottom sheet listing the paired Meta wearables reported by +/// [MetaWearablesDat.getDevices]. +/// +/// Demonstrates the read-only device API: each pair's name + model, which one +/// is actively streaming ([WearableDevice.isStreamingDevice]) vs the +/// auto-selector's current pick ([WearableDevice.isActive]), and its +/// connection state. With two pairs of the same model this is how you tell +/// them apart and see which one you're streaming from. +class PairedDevicesSheet extends StatefulWidget { + const PairedDevicesSheet({super.key}); + + @override + State createState() => _PairedDevicesSheetState(); +} + +class _PairedDevicesSheetState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(context.read().refreshDevices()); + }); + } + + @override + Widget build(BuildContext context) { + return FractionallySizedBox( + heightFactor: 0.85, + widthFactor: 1, + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(left: 25, right: 25, bottom: 100), + child: Consumer( + builder: (context, streamProvider, child) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SheetHandleBar(), + Row( + children: [ + const Expanded( + child: Text( + 'Paired devices', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + tooltip: 'Refresh', + onPressed: streamProvider.devicesLoading + ? null + : () { + unawaited(streamProvider.refreshDevices()); + }, + icon: const Icon(Icons.refresh), + ), + ], + ), + const SizedBox(height: 8), + Flexible(child: _buildBody(streamProvider)), + ], + ); + }, + ), + ), + ); + } + + Widget _buildBody(StreamSessionProvider provider) { + if (provider.devicesLoading && provider.devices.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center(child: CircularProgressIndicator()), + ); + } + if (provider.devicesError != null && provider.devices.isEmpty) { + return _MessageCard( + icon: Icons.error_outline, + color: Colors.redAccent, + message: provider.devicesError!, + ); + } + if (provider.devices.isEmpty) { + return const _MessageCard( + icon: Icons.search_off, + color: Colors.grey, + message: + 'No paired glasses found. Make sure a pair is powered on and ' + 'connected in the Meta AI app.', + ); + } + return ListView.separated( + shrinkWrap: true, + itemCount: provider.devices.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) => + _DeviceTile(device: provider.devices[index]), + ); + } +} + +class _DeviceTile extends StatelessWidget { + const _DeviceTile({required this.device}); + + final WearableDevice device; + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + device.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + if (device.isStreamingDevice) + const _Badge(label: 'Streaming', color: Colors.green) + else if (device.isActive) + const _Badge(label: 'Selected', color: Colors.blueAccent), + ], + ), + const SizedBox(height: 4), + Text( + _modelLabel(device.type), + style: TextStyle(fontSize: 13, color: Colors.grey.shade700), + ), + const SizedBox(height: 8), + Row( + children: [ + _Badge( + label: _linkLabel(device.linkState), + color: device.linkState == WearableLinkState.connected + ? Colors.green + : Colors.grey, + outlined: true, + ), + if (device.supportsDisplay) ...[ + const SizedBox(width: 6), + const _Badge( + label: 'Display', + color: Colors.purple, + outlined: true, + ), + ], + ], + ), + const SizedBox(height: 8), + Text( + 'ID: ${device.id}', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + if (device.firmwareInfo != null && device.firmwareInfo!.isNotEmpty) + Text( + 'Firmware: ${device.firmwareInfo}', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + ], + ), + ), + ); + } +} + +class _Badge extends StatelessWidget { + const _Badge({ + required this.label, + required this.color, + this.outlined = false, + }); + + final String label; + final Color color; + final bool outlined; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: outlined ? Colors.transparent : color.withOpacity(0.15), + border: Border.all(color: color), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} + +class _MessageCard extends StatelessWidget { + const _MessageCard({ + required this.icon, + required this.color, + required this.message, + }); + + final IconData icon; + final Color color; + final String message; + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, color: color), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + ), + ); + } +} + +String _modelLabel(WearableDeviceType type) => switch (type) { + WearableDeviceType.rayBanMeta => 'Ray-Ban Meta', + WearableDeviceType.oakleyMetaHSTN => 'Oakley Meta HSTN', + WearableDeviceType.oakleyMetaVanguard => 'Oakley Meta Vanguard', + WearableDeviceType.metaRayBanDisplay => 'Meta Ray-Ban Display', + WearableDeviceType.rayBanMetaOptics => 'Ray-Ban Meta Optics', + WearableDeviceType.unknown => 'Unknown model', +}; + +String _linkLabel(WearableLinkState state) => switch (state) { + WearableLinkState.connected => 'Connected', + WearableLinkState.connecting => 'Connecting', + WearableLinkState.disconnected => 'Disconnected', + WearableLinkState.unknown => 'Unknown', +}; diff --git a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift index dca606f..529127c 100644 --- a/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift +++ b/ios/flutter_meta_wearables_dat/Sources/flutter_meta_wearables_dat/MetaWearablesDatPlugin.swift @@ -159,6 +159,8 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { capturePhoto(call: call, result: result) case "getRegistrationState": getRegistrationState(result: result) + case "getDevices": + getDevices(result: result) case "enableBackgroundStreaming": enableBackgroundStreaming(result: result) case "disableBackgroundStreaming": @@ -955,6 +957,89 @@ public class MetaWearablesDatPlugin: NSObject, FlutterPlugin { } } + /// Returns a snapshot of all paired devices as an array of maps, decoded by + /// `WearableDevice.fromMap` on the Dart side. `isActive` reflects the shared + /// auto-selector's current pick (what a new stream would bind to); + /// `isStreamingDevice` reflects what the *live* stream is using, gated on the + /// stream's actual `.streaming` state (not just a non-nil reference, which + /// can be stale after an SDK-driven stop). + func getDevices(result: @escaping FlutterResult) { + Task { @MainActor in + let active = deviceSelector.activeDevice + let sessionId = deviceSession?.deviceId + let streaming = streamSession?.state == .streaming + var devices: [[String: Any]] = [] + for id in Wearables.shared.devices { + guard !id.isEmpty else { continue } + let isActive = (id == active) + let isStreamingDevice = streaming && (id == sessionId) + if let device = Wearables.shared.deviceForIdentifier(id) { + devices.append([ + "id": id, + "name": device.name, + "deviceType": Self.deviceTypeCode(device.deviceType()), + "linkState": Self.linkStateCode(device.linkState), + "compatibility": Self.compatibilityCode(device.compatibility()), + "supportsDisplay": device.supportsDisplay(), + "isActive": isActive, + "isStreamingDevice": isStreamingDevice, + "firmwareInfo": NSNull(), + ]) + } else { + // Metadata unavailable — emit a complete fallback so the device + // count still matches `Wearables.shared.devices`. + devices.append([ + "id": id, + "name": id, + "deviceType": "unknown", + "linkState": "unknown", + "compatibility": "undefined", + "supportsDisplay": false, + "isActive": isActive, + "isStreamingDevice": isStreamingDevice, + "firmwareInfo": NSNull(), + ]) + } + } + result(devices) + } + } + + /// Canonical device-type code, kept identical to the Android side. + private static func deviceTypeCode(_ type: MWDATCore.DeviceType) -> String { + switch type { + case .rayBanMeta: return "rayBanMeta" + case .oakleyMetaHSTN: return "oakleyMetaHSTN" + case .oakleyMetaVanguard: return "oakleyMetaVanguard" + case .metaRayBanDisplay: return "metaRayBanDisplay" + case .rayBanMetaOptics: return "rayBanMetaOptics" + case .unknown: return "unknown" + @unknown default: return "unknown" + } + } + + /// Canonical link-state code, kept identical to the Android side. + private static func linkStateCode(_ state: MWDATCore.LinkState) -> String { + switch state { + case .disconnected: return "disconnected" + case .connecting: return "connecting" + case .connected: return "connected" + } + } + + /// Canonical compatibility code, kept identical to the Android side. + private static func compatibilityCode( + _ compatibility: MWDATCore.Compatibility + ) -> String { + switch compatibility { + case .undefined: return "undefined" + case .compatible: return "compatible" + case .deviceUpdateRequired: return "deviceUpdateRequired" + case .sdkUpdateRequired: return "sdkUpdateRequired" + @unknown default: return "undefined" + } + } + private static func parseStreamQuality(_ value: String?) -> StreamQuality { switch value?.lowercased() { case "high": diff --git a/lib/flutter_meta_wearables_dat.dart b/lib/flutter_meta_wearables_dat.dart index 24d3266..0f18838 100644 --- a/lib/flutter_meta_wearables_dat.dart +++ b/lib/flutter_meta_wearables_dat.dart @@ -221,6 +221,208 @@ class DeviceState { int get hashCode => thermalLevel.hashCode; } +/// Hardware model of a paired Meta wearable, as reported by the DAT SDK. +/// +/// Note that two pairs of the *same* model both report the same value here +/// (e.g. two Ray-Ban Meta both report [rayBanMeta]) — use +/// [WearableDevice.name] or [WearableDevice.id] to tell them apart. +enum WearableDeviceType { + rayBanMeta('rayBanMeta'), + oakleyMetaHSTN('oakleyMetaHSTN'), + oakleyMetaVanguard('oakleyMetaVanguard'), + metaRayBanDisplay('metaRayBanDisplay'), + rayBanMetaOptics('rayBanMetaOptics'), + + /// Model not reported, or a newer model this plugin version predates. + unknown('unknown'); + + const WearableDeviceType(this.code); + + /// Canonical code sent over the platform channel (identical on iOS/Android). + final String code; + + /// Converts a platform code to a [WearableDeviceType], defaulting to + /// [unknown] for missing/unrecognized values. + static WearableDeviceType fromCode(String? code) { + return WearableDeviceType.values.firstWhere( + (t) => t.code == code, + orElse: () => WearableDeviceType.unknown, + ); + } +} + +/// BLE link state of a paired wearable. +enum WearableLinkState { + disconnected('disconnected'), + connecting('connecting'), + connected('connected'), + + /// Link state not reported (e.g. SDK metadata not yet available). + unknown('unknown'); + + const WearableLinkState(this.code); + + /// Canonical code sent over the platform channel. + final String code; + + /// Converts a platform code to a [WearableLinkState], defaulting to + /// [unknown] for missing/unrecognized values. + static WearableLinkState fromCode(String? code) { + return WearableLinkState.values.firstWhere( + (s) => s.code == code, + orElse: () => WearableLinkState.unknown, + ); + } +} + +/// SDK/device compatibility for a paired wearable. +enum WearableCompatibility { + /// Compatibility not yet determined. + undefined('undefined'), + compatible('compatible'), + + /// The on-device DAT app/firmware must be updated before use. + deviceUpdateRequired('deviceUpdateRequired'), + + /// The host app's bundled DAT SDK must be updated before use. + sdkUpdateRequired('sdkUpdateRequired'); + + const WearableCompatibility(this.code); + + /// Canonical code sent over the platform channel. + final String code; + + /// Converts a platform code to a [WearableCompatibility], defaulting to + /// [undefined] for missing/unrecognized values. + static WearableCompatibility fromCode(String? code) { + return WearableCompatibility.values.firstWhere( + (c) => c.code == code, + orElse: () => WearableCompatibility.undefined, + ); + } +} + +/// A paired Meta wearable (glasses) known to the DAT SDK, returned by +/// [MetaWearablesDat.getDevices]. +/// +/// Distinguishing two pairs of the same model: the model ([type]) is identical +/// for both, so rely on [name] (the user-assigned name from the Meta AI app) +/// or [id] (the stable identifier). [isStreamingDevice] tells you which pair +/// the *current* stream is actually using; [isActive] tells you which pair the +/// shared auto-selector would bind a *new* stream to — these can differ when +/// more than one pair is connected. +@immutable +class WearableDevice { + const WearableDevice({ + required this.id, + required this.name, + required this.type, + required this.linkState, + required this.compatibility, + required this.supportsDisplay, + required this.isActive, + required this.isStreamingDevice, + this.firmwareInfo, + }); + + /// Builds a [WearableDevice] from a platform-channel map. + /// + /// Throws [ArgumentError] when `id` is missing or blank — identity is never + /// fabricated. All other fields tolerate missing/null values and fall back + /// to sensible defaults ([name] → [id], enums → their `unknown`/`undefined` + /// case, bools → `false`). + factory WearableDevice.fromMap(Map map) { + final id = (map['id'] as String?)?.trim() ?? ''; + if (id.isEmpty) { + throw ArgumentError.value( + map['id'], + 'id', + 'WearableDevice requires a non-empty id', + ); + } + final name = map['name'] as String?; + return WearableDevice( + id: id, + name: (name == null || name.isEmpty) ? id : name, + type: WearableDeviceType.fromCode(map['deviceType'] as String?), + linkState: WearableLinkState.fromCode(map['linkState'] as String?), + compatibility: WearableCompatibility.fromCode( + map['compatibility'] as String?, + ), + supportsDisplay: (map['supportsDisplay'] as bool?) ?? false, + isActive: (map['isActive'] as bool?) ?? false, + isStreamingDevice: (map['isStreamingDevice'] as bool?) ?? false, + firmwareInfo: map['firmwareInfo'] as String?, + ); + } + + /// Stable identifier (`DeviceIdentifier`). Tells two pairs of the same model + /// apart, and is the value a future release will use to pin streaming to a + /// specific pair. + final String id; + + /// User-assigned name from the Meta AI app (e.g. "Gautier's glasses"). + /// Falls back to [id] when the SDK hasn't surfaced a name. + final String name; + + /// Hardware model. + final WearableDeviceType type; + + /// Current BLE link state. + final WearableLinkState linkState; + + /// SDK/device compatibility. + final WearableCompatibility compatibility; + + /// Whether this device exposes a display (e.g. Meta Ray-Ban Display). + final bool supportsDisplay; + + /// Whether this is the device the shared auto-selector currently treats as + /// active — i.e. the pair a *new* stream session would bind to. Can differ + /// from [isStreamingDevice] when more than one pair is connected. + final bool isActive; + + /// Whether this device is the one the *current* stream session is actively + /// streaming from. `false` for every device when nothing is streaming. + final bool isStreamingDevice; + + /// Firmware version string. Currently Android-only; `null` on iOS. + final String? firmwareInfo; + + @override + String toString() => + 'WearableDevice(id: $id, name: $name, type: ${type.code}, ' + 'linkState: ${linkState.code}, isActive: $isActive, ' + 'isStreamingDevice: $isStreamingDevice)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WearableDevice && + other.id == id && + other.name == name && + other.type == type && + other.linkState == linkState && + other.compatibility == compatibility && + other.supportsDisplay == supportsDisplay && + other.isActive == isActive && + other.isStreamingDevice == isStreamingDevice && + other.firmwareInfo == firmwareInfo; + + @override + int get hashCode => Object.hash( + id, + name, + type, + linkState, + compatibility, + supportsDisplay, + isActive, + isStreamingDevice, + firmwareInfo, + ); +} + /// Dimensions of the active video stream, reported from the native layer /// when a new stream starts or the underlying frame size changes. /// @@ -624,6 +826,23 @@ class MetaWearablesDat { return MetaWearablesDatPlatform.instance.activeDeviceStream(); } + /// Returns the paired Meta wearables currently known to the DAT SDK, with + /// their names, models, link state, and which pair (if any) is actively + /// streaming ([WearableDevice.isStreamingDevice]) or auto-selected + /// ([WearableDevice.isActive]). + /// + /// This is a one-shot snapshot; call it again to refresh. Devices only + /// appear after registration completes and camera permission is granted — + /// before that the list is empty. + /// + /// **Android:** throws a `PlatformException` with code `NOT_INITIALIZED` if + /// called before [requestAndroidPermissions] has granted Bluetooth + /// permissions (the SDK can't enumerate devices until then). iOS returns an + /// empty list instead. + static Future> getDevices() { + return MetaWearablesDatPlatform.instance.getDevices(); + } + /// Stream of video frame dimensions for the active stream session. /// /// Emits once shortly after `startStreamSession` and again if the diff --git a/lib/meta_wearables_dat_method_channel.dart b/lib/meta_wearables_dat_method_channel.dart index 46fd6a4..c0f305a 100644 --- a/lib/meta_wearables_dat_method_channel.dart +++ b/lib/meta_wearables_dat_method_channel.dart @@ -255,6 +255,22 @@ class MethodChannelMetaWearablesDat extends MetaWearablesDatPlatform { }); } + @override + Future> getDevices() async { + final raw = await methodChannel.invokeListMethod('getDevices'); + if (raw == null) return []; + final devices = raw + .map( + (dynamic e) => + WearableDevice.fromMap(Map.from(e as Map)), + ) + .toList(); + if (kDebugMode) { + debugPrint('[MetaWearablesDAT] getDevices → ${devices.length} device(s)'); + } + return devices; + } + @override Stream videoStreamSizeStream() { return videoStreamSizeEventChannel.receiveBroadcastStream().map(( diff --git a/lib/meta_wearables_dat_platform_interface.dart b/lib/meta_wearables_dat_platform_interface.dart index 8533e16..3485801 100644 --- a/lib/meta_wearables_dat_platform_interface.dart +++ b/lib/meta_wearables_dat_platform_interface.dart @@ -109,6 +109,10 @@ abstract class MetaWearablesDatPlatform extends PlatformInterface { ); } + Future> getDevices() { + throw UnimplementedError('getDevices() has not been implemented.'); + } + Stream videoStreamSizeStream() { throw UnimplementedError( 'videoStreamSizeStream() has not been implemented.', diff --git a/pubspec.yaml b/pubspec.yaml index 99ce55b..3b08a57 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_meta_wearables_dat description: "Flutter bridge to Meta's Wearables DAT for iOS and Android." -version: 0.5.3 +version: 0.6.0 repository: https://github.com/rodcone/flutter_meta_wearables_dat environment: diff --git a/test/method_channel_get_devices_test.dart b/test/method_channel_get_devices_test.dart new file mode 100644 index 0000000..06582bb --- /dev/null +++ b/test/method_channel_get_devices_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_meta_wearables_dat/flutter_meta_wearables_dat.dart'; +import 'package:flutter_meta_wearables_dat/meta_wearables_dat_method_channel.dart'; +import 'package:flutter_meta_wearables_dat/meta_wearables_dat_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('flutter_meta_wearables_dat'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final platform = MethodChannelMetaWearablesDat(); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + test('getDevices decodes a list of device maps', () async { + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method != 'getDevices') return null; + return [ + { + 'id': 'a', + 'name': 'Glasses A', + 'deviceType': 'rayBanMeta', + 'linkState': 'connected', + 'compatibility': 'compatible', + 'supportsDisplay': false, + 'isActive': true, + 'isStreamingDevice': true, + 'firmwareInfo': '1.0', + }, + { + 'id': 'b', + 'name': 'Glasses B', + 'deviceType': 'oakleyMetaVanguard', + 'linkState': 'disconnected', + 'compatibility': 'deviceUpdateRequired', + 'supportsDisplay': true, + 'isActive': false, + 'isStreamingDevice': false, + 'firmwareInfo': null, + }, + ]; + }); + + final devices = await platform.getDevices(); + expect(devices, hasLength(2)); + expect(devices[0].id, 'a'); + expect(devices[0].isStreamingDevice, isTrue); + expect(devices[0].isActive, isTrue); + expect(devices[1].type, WearableDeviceType.oakleyMetaVanguard); + expect(devices[1].linkState, WearableLinkState.disconnected); + expect(devices[1].supportsDisplay, isTrue); + expect(devices[1].firmwareInfo, isNull); + }); + + test('getDevices returns an empty list when native returns null', () async { + messenger.setMockMethodCallHandler(channel, (_) async => null); + expect(await platform.getDevices(), isEmpty); + }); + + test('getDevices propagates a PlatformException', () async { + messenger.setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'NOT_INITIALIZED', message: 'nope'); + }); + expect( + platform.getDevices(), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'NOT_INITIALIZED', + ), + ), + ); + }); + + test('facade delegates getDevices to the platform instance', () async { + final fake = _FakePlatform(); + MetaWearablesDatPlatform.instance = fake; + + final result = await MetaWearablesDat.getDevices(); + + expect(fake.called, isTrue); + expect(result, hasLength(1)); + expect(result.first.id, 'fake-1'); + }); +} + +class _FakePlatform extends MetaWearablesDatPlatform { + bool called = false; + + @override + Future> getDevices() async { + called = true; + return const [ + WearableDevice( + id: 'fake-1', + name: 'Fake', + type: WearableDeviceType.rayBanMeta, + linkState: WearableLinkState.connected, + compatibility: WearableCompatibility.compatible, + supportsDisplay: false, + isActive: false, + isStreamingDevice: false, + ), + ]; + } +} diff --git a/test/wearable_device_test.dart b/test/wearable_device_test.dart new file mode 100644 index 0000000..9583569 --- /dev/null +++ b/test/wearable_device_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter_meta_wearables_dat/flutter_meta_wearables_dat.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('WearableDeviceType.fromCode', () { + test('maps known codes', () { + expect( + WearableDeviceType.fromCode('rayBanMeta'), + WearableDeviceType.rayBanMeta, + ); + expect( + WearableDeviceType.fromCode('oakleyMetaHSTN'), + WearableDeviceType.oakleyMetaHSTN, + ); + expect( + WearableDeviceType.fromCode('metaRayBanDisplay'), + WearableDeviceType.metaRayBanDisplay, + ); + }); + + test('falls back to unknown for null/unrecognized', () { + expect(WearableDeviceType.fromCode(null), WearableDeviceType.unknown); + expect(WearableDeviceType.fromCode('nope'), WearableDeviceType.unknown); + }); + }); + + group('WearableLinkState.fromCode', () { + test('maps known codes', () { + expect( + WearableLinkState.fromCode('connected'), + WearableLinkState.connected, + ); + expect( + WearableLinkState.fromCode('connecting'), + WearableLinkState.connecting, + ); + expect( + WearableLinkState.fromCode('disconnected'), + WearableLinkState.disconnected, + ); + }); + + test('falls back to unknown for null/unrecognized', () { + expect(WearableLinkState.fromCode(null), WearableLinkState.unknown); + expect(WearableLinkState.fromCode('bogus'), WearableLinkState.unknown); + }); + }); + + group('WearableCompatibility.fromCode', () { + test('maps known codes', () { + expect( + WearableCompatibility.fromCode('compatible'), + WearableCompatibility.compatible, + ); + expect( + WearableCompatibility.fromCode('deviceUpdateRequired'), + WearableCompatibility.deviceUpdateRequired, + ); + expect( + WearableCompatibility.fromCode('sdkUpdateRequired'), + WearableCompatibility.sdkUpdateRequired, + ); + }); + + test('falls back to undefined (not unknown) for null/unrecognized', () { + expect( + WearableCompatibility.fromCode(null), + WearableCompatibility.undefined, + ); + expect( + WearableCompatibility.fromCode('???'), + WearableCompatibility.undefined, + ); + }); + }); + + group('WearableDevice.fromMap', () { + test('parses a complete map', () { + final device = WearableDevice.fromMap(const { + 'id': 'dev-1', + 'name': "Gautier's glasses", + 'deviceType': 'rayBanMeta', + 'linkState': 'connected', + 'compatibility': 'compatible', + 'supportsDisplay': false, + 'isActive': true, + 'isStreamingDevice': true, + 'firmwareInfo': '1.2.3', + }); + expect(device.id, 'dev-1'); + expect(device.name, "Gautier's glasses"); + expect(device.type, WearableDeviceType.rayBanMeta); + expect(device.linkState, WearableLinkState.connected); + expect(device.compatibility, WearableCompatibility.compatible); + expect(device.supportsDisplay, false); + expect(device.isActive, true); + expect(device.isStreamingDevice, true); + expect(device.firmwareInfo, '1.2.3'); + }); + + test('tolerates a minimal map (only id) with sensible defaults', () { + final device = WearableDevice.fromMap( + const {'id': 'dev-2'}, + ); + expect(device.id, 'dev-2'); + expect(device.name, 'dev-2', reason: 'name falls back to id'); + expect(device.type, WearableDeviceType.unknown); + expect(device.linkState, WearableLinkState.unknown); + expect(device.compatibility, WearableCompatibility.undefined); + expect(device.supportsDisplay, false); + expect(device.isActive, false); + expect(device.isStreamingDevice, false); + expect(device.firmwareInfo, isNull); + }); + + test('tolerates explicit null fields', () { + final device = WearableDevice.fromMap(const { + 'id': 'dev-3', + 'name': null, + 'deviceType': null, + 'linkState': null, + 'compatibility': null, + 'supportsDisplay': null, + 'isActive': null, + 'isStreamingDevice': null, + 'firmwareInfo': null, + }); + expect(device.name, 'dev-3'); + expect(device.type, WearableDeviceType.unknown); + expect(device.firmwareInfo, isNull); + }); + + test('throws ArgumentError when id is missing', () { + expect( + () => WearableDevice.fromMap(const {'name': 'x'}), + throwsArgumentError, + ); + }); + + test('throws ArgumentError when id is blank', () { + expect( + () => WearableDevice.fromMap(const {'id': ' '}), + throwsArgumentError, + ); + }); + + test('equality and hashCode are value-based', () { + Map sample() => { + 'id': 'dev-4', + 'name': 'A', + 'deviceType': 'rayBanMeta', + 'linkState': 'connected', + 'compatibility': 'compatible', + 'supportsDisplay': false, + 'isActive': false, + 'isStreamingDevice': false, + }; + expect( + WearableDevice.fromMap(sample()), + WearableDevice.fromMap(sample()), + ); + expect( + WearableDevice.fromMap(sample()).hashCode, + WearableDevice.fromMap(sample()).hashCode, + ); + }); + }); +} From 0ef639c0070972abbc9369ca5ad20f21496adce4 Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Thu, 18 Jun 2026 17:23:02 +0200 Subject: [PATCH 2/3] style: match PairedDevicesSheet to the Settings sheet UI - Adopt the Settings-sheet chrome: fixed-height SizedBox (90% screen), centered headlineSmall title, theme colorScheme colors instead of hardcoded white cards. - Tiles now show the model (device.type) as the title and the device name as the subtitle, per request. Co-Authored-By: Claude Opus 4.8 --- .../screens/stream/paired_devices_sheet.dart | 219 ++++++++++-------- 1 file changed, 120 insertions(+), 99 deletions(-) diff --git a/example/lib/screens/stream/paired_devices_sheet.dart b/example/lib/screens/stream/paired_devices_sheet.dart index 28026dc..526941b 100644 --- a/example/lib/screens/stream/paired_devices_sheet.dart +++ b/example/lib/screens/stream/paired_devices_sheet.dart @@ -9,7 +9,7 @@ import 'package:provider/provider.dart'; /// Bottom sheet listing the paired Meta wearables reported by /// [MetaWearablesDat.getDevices]. /// -/// Demonstrates the read-only device API: each pair's name + model, which one +/// Demonstrates the read-only device API: each pair's model + name, which one /// is actively streaming ([WearableDevice.isStreamingDevice]) vs the /// auto-selector's current pick ([WearableDevice.isActive]), and its /// connection state. With two pairs of the same model this is how you tell @@ -33,43 +33,43 @@ class _PairedDevicesSheetState extends State { @override Widget build(BuildContext context) { - return FractionallySizedBox( - heightFactor: 0.85, - widthFactor: 1, - alignment: Alignment.topCenter, + final sheetHeight = + MediaQuery.of(context).size.height * 0.9 - + MediaQuery.of(context).padding.bottom; + + return SizedBox( + height: sheetHeight, child: Padding( - padding: const EdgeInsets.only(left: 25, right: 25, bottom: 100), + padding: const EdgeInsets.only(left: 25, right: 25), child: Consumer( - builder: (context, streamProvider, child) { + builder: (context, sp, _) { return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SheetHandleBar(), - Row( + const SizedBox(height: 8), + Stack( + alignment: Alignment.center, children: [ - const Expanded( - child: Text( - 'Paired devices', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), + Text( + 'Paired glasses', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w700), ), - IconButton( - tooltip: 'Refresh', - onPressed: streamProvider.devicesLoading - ? null - : () { - unawaited(streamProvider.refreshDevices()); - }, - icon: const Icon(Icons.refresh), + Align( + alignment: Alignment.centerRight, + child: IconButton( + tooltip: 'Refresh', + onPressed: sp.devicesLoading + ? null + : () => unawaited(sp.refreshDevices()), + icon: const Icon(Icons.refresh), + ), ), ], ), - const SizedBox(height: 8), - Flexible(child: _buildBody(streamProvider)), + const SizedBox(height: 18), + Expanded(child: _buildBody(context, sp)), + SizedBox(height: MediaQuery.of(context).padding.bottom + 16), ], ); }, @@ -78,33 +78,31 @@ class _PairedDevicesSheetState extends State { ); } - Widget _buildBody(StreamSessionProvider provider) { + Widget _buildBody(BuildContext context, StreamSessionProvider provider) { + final theme = Theme.of(context); if (provider.devicesLoading && provider.devices.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 40), - child: Center(child: CircularProgressIndicator()), - ); + return const Center(child: CircularProgressIndicator()); } if (provider.devicesError != null && provider.devices.isEmpty) { - return _MessageCard( + return _MessageView( icon: Icons.error_outline, color: Colors.redAccent, message: provider.devicesError!, ); } if (provider.devices.isEmpty) { - return const _MessageCard( + return _MessageView( icon: Icons.search_off, - color: Colors.grey, + color: theme.colorScheme.onSurface.withOpacity(0.4), message: 'No paired glasses found. Make sure a pair is powered on and ' 'connected in the Meta AI app.', ); } return ListView.separated( - shrinkWrap: true, + padding: EdgeInsets.zero, itemCount: provider.devices.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), + separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (context, index) => _DeviceTile(device: provider.devices[index]), ); @@ -118,67 +116,83 @@ class _DeviceTile extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - color: Colors.white, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - device.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: onSurface.withOpacity(0.04), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + _modelLabel(device.type), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: onSurface.withOpacity(0.9), ), ), - if (device.isStreamingDevice) - const _Badge(label: 'Streaming', color: Colors.green) - else if (device.isActive) - const _Badge(label: 'Selected', color: Colors.blueAccent), - ], - ), - const SizedBox(height: 4), - Text( - _modelLabel(device.type), - style: TextStyle(fontSize: 13, color: Colors.grey.shade700), + ), + if (device.isStreamingDevice) + const _Badge(label: 'Streaming', color: Colors.green) + else if (device.isActive) + _Badge(label: 'Selected', color: theme.colorScheme.primary), + ], + ), + const SizedBox(height: 2), + Text( + device.name, + style: theme.textTheme.bodySmall?.copyWith( + color: onSurface.withOpacity(0.55), ), - const SizedBox(height: 8), - Row( - children: [ + ), + const SizedBox(height: 12), + Row( + children: [ + _Badge( + label: _linkLabel(device.linkState), + color: device.linkState == WearableLinkState.connected + ? Colors.green + : onSurface.withOpacity(0.45), + outlined: true, + ), + if (device.supportsDisplay) ...[ + const SizedBox(width: 6), _Badge( - label: _linkLabel(device.linkState), - color: device.linkState == WearableLinkState.connected - ? Colors.green - : Colors.grey, + label: 'Display', + color: theme.colorScheme.primary, outlined: true, ), - if (device.supportsDisplay) ...[ - const SizedBox(width: 6), - const _Badge( - label: 'Display', - color: Colors.purple, - outlined: true, - ), - ], ], + ], + ), + const SizedBox(height: 10), + Text( + 'ID: ${device.id}', + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 11, + color: onSurface.withOpacity(0.4), ), - const SizedBox(height: 8), - Text( - 'ID: ${device.id}', - style: TextStyle(fontSize: 11, color: Colors.grey.shade500), - ), - if (device.firmwareInfo != null && device.firmwareInfo!.isNotEmpty) - Text( + ), + if (device.firmwareInfo != null && device.firmwareInfo!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( 'Firmware: ${device.firmwareInfo}', - style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 11, + color: onSurface.withOpacity(0.4), + ), ), - ], - ), + ), + ], ), ); } @@ -202,7 +216,7 @@ class _Badge extends StatelessWidget { decoration: BoxDecoration( color: outlined ? Colors.transparent : color.withOpacity(0.15), border: Border.all(color: color), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), ), child: Text( label, @@ -216,8 +230,8 @@ class _Badge extends StatelessWidget { } } -class _MessageCard extends StatelessWidget { - const _MessageCard({ +class _MessageView extends StatelessWidget { + const _MessageView({ required this.icon, required this.color, required this.message, @@ -229,15 +243,22 @@ class _MessageCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - color: Colors.white, + final theme = Theme.of(context); + return Center( child: Padding( - padding: const EdgeInsets.all(16), - child: Row( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: color), - const SizedBox(width: 12), - Expanded(child: Text(message)), + Icon(icon, color: color, size: 40), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), ], ), ), From 205b63e3e6a95b51da934e82a2fadebff3b91d8b Mon Sep 17 00:00:00 2001 From: Gautier de Lataillade Date: Fri, 19 Jun 2026 13:53:12 +0200 Subject: [PATCH 3/3] chore: bump mock add-on to 0.6.0 to match core (versions-in-sync) The core and mock-device packages release in lockstep; core was bumped to 0.6.0 for the read-only getDevices() addition, so align the mock add-on to satisfy the versions-in-sync CI gate. Co-Authored-By: Claude Opus 4.8 --- flutter_meta_wearables_dat_mock_device/CHANGELOG.md | 4 ++++ flutter_meta_wearables_dat_mock_device/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/flutter_meta_wearables_dat_mock_device/CHANGELOG.md b/flutter_meta_wearables_dat_mock_device/CHANGELOG.md index 7721ba5..c5806b1 100644 --- a/flutter_meta_wearables_dat_mock_device/CHANGELOG.md +++ b/flutter_meta_wearables_dat_mock_device/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.0 + +* Version aligned with the core package's 0.6.0 release (read-only `getDevices()`). No mock-add-on API changes. + ## 0.5.3 * Docs: clarify that `setPermission` / `setPermissionRequestResult` are iOS only — the Android mock SDK exposes no permission-injection hook, so they no-op there. Version aligned with the core package. diff --git a/flutter_meta_wearables_dat_mock_device/pubspec.yaml b/flutter_meta_wearables_dat_mock_device/pubspec.yaml index 4692f5d..6938336 100644 --- a/flutter_meta_wearables_dat_mock_device/pubspec.yaml +++ b/flutter_meta_wearables_dat_mock_device/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_meta_wearables_dat_mock_device description: "Optional MockDeviceKit add-on for flutter_meta_wearables_dat — simulates a Ray-Ban Meta device using the phone's camera. Pull this in only for development and testing; production apps should omit it to avoid linking AVFoundation/Camera symbols." -version: 0.5.3 +version: 0.6.0 repository: https://github.com/rodcone/flutter_meta_wearables_dat environment: