diff --git a/open_wearable/android/app/src/main/AndroidManifest.xml b/open_wearable/android/app/src/main/AndroidManifest.xml
index 8cb9f463..eb49e63d 100644
--- a/open_wearable/android/app/src/main/AndroidManifest.xml
+++ b/open_wearable/android/app/src/main/AndroidManifest.xml
@@ -40,6 +40,8 @@
+
+
diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock
index 1e2cdfa2..82b7fdd3 100644
--- a/open_wearable/ios/Podfile.lock
+++ b/open_wearable/ios/Podfile.lock
@@ -44,6 +44,8 @@ PODS:
- flutter_archive (0.0.1):
- Flutter
- ZIPFoundation (= 0.9.19)
+ - flutter_headset_detector (3.1.0):
+ - Flutter
- iOSMcuManagerLibrary (1.10.1):
- SwiftCBOR (= 0.4.7)
- ZIPFoundation (= 0.9.19)
@@ -86,6 +88,7 @@ DEPENDENCIES:
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- Flutter (from `Flutter`)
- flutter_archive (from `.symlinks/plugins/flutter_archive/ios`)
+ - flutter_headset_detector (from `.symlinks/plugins/flutter_headset_detector/ios`)
- mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`)
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
@@ -121,6 +124,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_archive:
:path: ".symlinks/plugins/flutter_archive/ios"
+ flutter_headset_detector:
+ :path: ".symlinks/plugins/flutter_headset_detector/ios"
mcumgr_flutter:
:path: ".symlinks/plugins/mcumgr_flutter/ios"
open_file_ios:
@@ -151,6 +156,7 @@ SPEC CHECKSUMS:
file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe
+ flutter_headset_detector: 37d2407c6c59aa6e8a9daecf732854862ff6dd4a
iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58
mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart
index 0c9e6108..1b42839c 100644
--- a/open_wearable/lib/main.dart
+++ b/open_wearable/lib/main.dart
@@ -18,7 +18,7 @@ import 'package:open_wearable/models/wearable_connector.dart'
hide WearableEvent;
import 'package:open_wearable/router.dart';
import 'package:open_wearable/theme/app_theme.dart';
-import 'package:open_wearable/view_models/sensor_recorder_provider.dart';
+import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart';
import 'package:open_wearable/widgets/app_banner.dart';
import 'package:open_wearable/widgets/global_app_banner_overlay.dart';
import 'package:open_wearable/widgets/app_toast.dart';
diff --git a/open_wearable/lib/models/bluetooth_auto_connector.dart b/open_wearable/lib/models/bluetooth_auto_connector.dart
index 594a52c8..e312416f 100644
--- a/open_wearable/lib/models/bluetooth_auto_connector.dart
+++ b/open_wearable/lib/models/bluetooth_auto_connector.dart
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
@@ -60,6 +61,14 @@ class BluetoothAutoConnector {
});
void start() async {
+ if (kIsWeb) {
+ logger.i(
+ 'Bluetooth auto-connect is disabled on web because Web Bluetooth requires a direct user gesture.',
+ );
+ _stopInternal();
+ return;
+ }
+
final token = ++_sessionToken;
_stopInternal();
_connectedDeviceIds.clear();
@@ -271,7 +280,7 @@ class BluetoothAutoConnector {
}
Future _applyIosScanCooldownIfNeeded() async {
- if (!Platform.isIOS) {
+ if (kIsWeb || !Platform.isIOS) {
return;
}
final stoppedAt = _lastScanStoppedAt;
@@ -296,14 +305,17 @@ class BluetoothAutoConnector {
}
_isAttemptingConnection = true;
- if (!Platform.isIOS) {
+
+ if (!kIsWeb && Platform.isAndroid) {
final hasPerm = await wearableManager.hasPermissions();
if (activeToken != _sessionToken) {
_isAttemptingConnection = false;
return;
}
if (!hasPerm) {
- logger.w('Bluetooth permissions not granted. Showing permissions dialog.');
+ logger.w(
+ 'Bluetooth permissions not granted. Showing permissions dialog.',
+ );
if (!_askedPermissionsThisSession) {
_askedPermissionsThisSession = true;
_showPermissionsDialog();
diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart
index d79e2627..19fd68eb 100644
--- a/open_wearable/lib/view_models/sensor_recorder_provider.dart
+++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart
@@ -1,317 +1,2 @@
-import 'dart:async';
-import 'dart:io';
-
-import 'package:flutter/foundation.dart';
-import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
-
-import '../models/logger.dart';
-import '../models/sensor_streams.dart';
-
-/// Runtime recorder state for connected wearables and sensors.
-///
-/// Needs:
-/// - Connected wearables (optionally with `SensorManager` capability).
-/// - Writable target directory for CSV output.
-///
-/// Does:
-/// - Builds/owns per-wearable recorder maps.
-/// - Starts/stops all active recorder streams.
-/// - Keeps recording behavior consistent across wearable reconnects.
-/// - Synchronizes recorder registration with the connected wearable set.
-///
-/// Provides:
-/// - Recording status (`isRecording`, `recordingStart`, etc.).
-/// - Recorder access used by recorder UI pages.
-class SensorRecorderProvider with ChangeNotifier {
- final Map> _recorders = {};
- final Map _recordingFilepathsBySensorIdentity = {};
- Future _pendingSynchronization = Future.value();
- bool _disposed = false;
-
- bool _isRecording = false;
- bool _hasSensorsConnected = false;
- String? _currentDirectory;
- DateTime? _recordingStart;
-
- bool get isRecording => _isRecording;
- bool get hasSensorsConnected => _hasSensorsConnected;
- String? get currentDirectory => _currentDirectory;
- DateTime? get recordingStart => _recordingStart;
-
- Future startRecording(String dirname) async {
- if (_isRecording) {
- return;
- }
-
- _recordingFilepathsBySensorIdentity.clear();
- _currentDirectory = dirname;
- _recordingStart = DateTime.now();
-
- try {
- for (Wearable wearable in _recorders.keys) {
- await _startRecorderForWearable(wearable, dirname);
- }
- _isRecording = true;
- notifyListeners();
- } catch (e, st) {
- logger.e('Failed to start recording: $e\n$st');
- _stopAllRecorderStreams();
- _recordingFilepathsBySensorIdentity.clear();
- _currentDirectory = null;
- _recordingStart = null;
- _isRecording = false;
- notifyListeners();
- rethrow;
- }
- }
-
- void stopRecording() {
- _isRecording = false;
- _recordingStart = null;
- _recordingFilepathsBySensorIdentity.clear();
- _stopAllRecorderStreams();
- notifyListeners();
- }
-
- Recorder? getRecorder(Wearable wearable, Sensor sensor) {
- if (!_recorders.containsKey(wearable)) {
- return null;
- }
- return _recorders[wearable]?[sensor];
- }
-
- Map getRecorders(Wearable wearable) {
- return _recorders[wearable] ?? {};
- }
-
- Future addWearable(Wearable wearable) async {
- final Wearable? existing = _findWearableByDeviceId(wearable.deviceId);
-
- if (existing != null) {
- _disposeWearable(existing);
- _recorders.remove(existing);
- }
-
- _recorders[wearable] = {};
-
- wearable.addDisconnectListener(() {
- removeWearable(wearable);
- });
-
- if (wearable.hasCapability()) {
- for (Sensor sensor
- in wearable.requireCapability().sensors) {
- if (!_recorders[wearable]!.containsKey(sensor)) {
- _recorders[wearable]![sensor] = Recorder(columns: sensor.axisNames);
- }
- }
- }
-
- if (_isRecording && _currentDirectory != null) {
- unawaited(
- _startRecorderForWearable(
- wearable,
- _currentDirectory!,
- resumed: true,
- ),
- );
- }
-
- _updateConnected();
- }
-
- /// Reconciles recorder state with the current connected wearable set.
- ///
- /// This keeps recorder registration derived from the authoritative
- /// [WearablesProvider] connection state instead of relying on each caller to
- /// remember a second side effect.
- void synchronizeConnectedWearables(Iterable wearables) {
- final desiredById = {
- for (final wearable in wearables) wearable.deviceId: wearable,
- };
-
- _pendingSynchronization = _pendingSynchronization.then((_) async {
- if (_disposed) {
- return;
- }
-
- final existingById = {
- for (final wearable in _recorders.keys) wearable.deviceId: wearable,
- };
-
- for (final entry in existingById.entries) {
- if (!desiredById.containsKey(entry.key)) {
- removeWearable(entry.value);
- }
- }
-
- for (final entry in desiredById.entries) {
- final existing = existingById[entry.key];
- if (existing == null || !identical(existing, entry.value)) {
- await addWearable(entry.value);
- }
- }
- });
- }
-
- void removeWearable(Wearable wearable) {
- _disposeWearable(wearable);
- _recorders.remove(wearable);
- _updateConnected();
- }
-
- void _updateConnected() {
- _hasSensorsConnected = !(_recorders.isEmpty ||
- _recorders.values.every((sensors) => sensors.isEmpty));
- logger.i('Has sensors connected: $_hasSensorsConnected');
- notifyListeners();
- }
-
- Wearable? _findWearableByDeviceId(String deviceId) {
- for (final wearable in _recorders.keys) {
- if (wearable.deviceId == deviceId) {
- return wearable;
- }
- }
- return null;
- }
-
- void _disposeWearable(Wearable wearable) {
- final recorderMap = _recorders[wearable];
- if (recorderMap == null) return;
- for (final recorder in recorderMap.values) {
- recorder.stop();
- }
- }
-
- Future _startRecorderForWearable(
- Wearable wearable,
- String dirname, {
- bool resumed = false,
- }) async {
- for (Sensor sensor in _recorders[wearable]!.keys) {
- Recorder? recorder = _recorders[wearable]?[sensor];
- if (recorder == null) continue;
-
- final sensorIdentity = _sensorRecordingIdentity(
- wearable: wearable,
- sensor: sensor,
- );
- final existingFilepath =
- _recordingFilepathsBySensorIdentity[sensorIdentity];
- final append = resumed && existingFilepath != null;
- final filepath = existingFilepath ??
- await _createRecordingFilepath(
- wearable: wearable,
- sensor: sensor,
- dirname: dirname,
- );
- _recordingFilepathsBySensorIdentity[sensorIdentity] = filepath;
-
- File file = await recorder.start(
- filepath: filepath,
- inputStream: SensorStreams.shared(
- wearable: wearable,
- sensor: sensor,
- ),
- append: append,
- );
-
- logger.i(
- '${resumed ? 'Resumed' : 'Started'} recording for '
- '${wearable.name} - ${sensor.sensorName} to ${file.path}',
- );
- }
- }
-
- /// Builds a stable per-device/per-sensor identity for the current session.
- ///
- /// Reconnects replace the [Wearable] and [Sensor] object instances, so file
- /// reuse must be keyed by semantic sensor identity instead of object
- /// identity.
- String _sensorRecordingIdentity({
- required Wearable wearable,
- required Sensor sensor,
- }) {
- final axisNames = sensor.axisNames.join(',');
- final axisUnits = sensor.axisUnits.join(',');
- return '${wearable.deviceId}|${sensor.runtimeType}|${sensor.sensorName}|$axisNames|$axisUnits';
- }
-
- /// Resolves a new file path for a sensor without overwriting prior exports.
- Future _createRecordingFilepath({
- required Wearable wearable,
- required Sensor sensor,
- required String dirname,
- }) async {
- final base = await _recordingFilenameStem(
- wearable: wearable,
- sensor: sensor,
- );
- var name = base;
- var counter = 1;
-
- while (await File('$dirname/$name.csv').exists()) {
- name = '${base}_$counter';
- counter++;
- }
-
- return '$dirname/$name.csv';
- }
-
- /// Builds the exported filename stem for a wearable sensor recording.
- ///
- /// Stereo-capable devices include their side marker so left/right files stay
- /// distinguishable in shared recording folders.
- Future _recordingFilenameStem({
- required Wearable wearable,
- required Sensor sensor,
- }) async {
- if (!wearable.hasCapability()) {
- return '${wearable.name}_${sensor.sensorName}';
- }
- final stereoPositionLabel = await _stereoPositionLabel(
- wearable.requireCapability(),
- );
- if (stereoPositionLabel != null) {
- return '${wearable.name}-$stereoPositionLabel-${sensor.sensorName}';
- }
- return '${wearable.name}_${sensor.sensorName}';
- }
-
- /// Returns the short stereo side label used in exported filenames.
- Future _stereoPositionLabel(StereoDevice wearable) async {
- final position = await wearable.position;
- return switch (position) {
- DevicePosition.left => 'L',
- DevicePosition.right => 'R',
- _ => null,
- };
- }
-
- void _stopAllRecorderStreams() {
- for (Wearable wearable in _recorders.keys) {
- for (Sensor sensor in _recorders[wearable]!.keys) {
- final recorder = _recorders[wearable]?[sensor];
- if (recorder == null) {
- continue;
- }
- recorder.stop();
- logger.i(
- 'Stopped recording for ${wearable.name} - ${sensor.sensorName}',
- );
- }
- }
- }
-
- @override
- void dispose() {
- _disposed = true;
- for (final wearable in _recorders.keys.toList()) {
- _disposeWearable(wearable);
- }
- _recordingFilepathsBySensorIdentity.clear();
- _recorders.clear();
- super.dispose();
- }
-}
+export 'sensor_recorder_provider_io.dart'
+ if (dart.library.html) 'sensor_recorder_provider_web.dart';
diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_facade.dart b/open_wearable/lib/view_models/sensor_recorder_provider_facade.dart
new file mode 100644
index 00000000..19fd68eb
--- /dev/null
+++ b/open_wearable/lib/view_models/sensor_recorder_provider_facade.dart
@@ -0,0 +1,2 @@
+export 'sensor_recorder_provider_io.dart'
+ if (dart.library.html) 'sensor_recorder_provider_web.dart';
diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart
new file mode 100644
index 00000000..bf573ea3
--- /dev/null
+++ b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart
@@ -0,0 +1,593 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
+import 'package:path_provider/path_provider.dart';
+import 'package:record/record.dart';
+
+import '../models/logger.dart';
+import '../models/sensor_streams.dart';
+
+/// Runtime recorder state for connected wearables and sensors.
+///
+/// Needs:
+/// - Connected wearables (optionally with `SensorManager` capability).
+/// - Writable target directory for CSV output.
+///
+/// Does:
+/// - Builds/owns per-wearable recorder maps.
+/// - Starts/stops all active recorder streams.
+/// - Keeps recording behavior consistent across wearable reconnects.
+/// - Synchronizes recorder registration with the connected wearable set.
+///
+/// Provides:
+/// - Recording status (`isRecording`, `recordingStart`, etc.).
+/// - Recorder access used by recorder UI pages.
+class SensorRecorderProvider with ChangeNotifier {
+ final Map> _recorders = {};
+ final Map _recordingFilepathsBySensorIdentity = {};
+ Future _pendingSynchronization = Future.value();
+ bool _disposed = false;
+
+ bool _isRecording = false;
+ bool _hasSensorsConnected = false;
+ String? _currentDirectory;
+ DateTime? _recordingStart;
+ final AudioRecorder _audioRecorder = AudioRecorder();
+ bool _isAudioRecording = false;
+ String? _currentAudioPath;
+ StreamSubscription? _amplitudeSub;
+
+ bool get isRecording => _isRecording;
+ bool get hasSensorsConnected => _hasSensorsConnected;
+ String? get currentDirectory => _currentDirectory;
+ DateTime? get recordingStart => _recordingStart;
+
+ final List _waveformData = [];
+ final int _waveformRevision = 0;
+ int get waveformRevision => _waveformRevision;
+ List get waveformData => List.unmodifiable(_waveformData);
+
+ InputDevice? _selectedBLEDevice;
+
+ bool _isBLEMicrophoneStreamingEnabled = false;
+ bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled;
+
+ // Path for temporary streaming file
+ String? _streamingPath;
+ bool _isStreamingActive = false;
+
+ Future _selectBLEDevice() async {
+ try {
+ final devices = await _audioRecorder.listInputDevices();
+
+ try {
+ _selectedBLEDevice = devices.firstWhere(
+ (device) =>
+ device.label.toLowerCase().contains('bluetooth') ||
+ device.label.toLowerCase().contains('ble') ||
+ device.label.toLowerCase().contains('headset') ||
+ device.label.toLowerCase().contains('openearable'),
+ );
+ logger.i("Selected audio input device: ${_selectedBLEDevice!.label}");
+ } catch (e) {
+ _selectedBLEDevice = null;
+ logger.w("No BLE headset found");
+ }
+ } catch (e) {
+ logger.e("Error selecting BLE device: $e");
+ _selectedBLEDevice = null;
+ }
+ }
+
+ Future startBLEMicrophoneStream() async {
+ if (!kIsWeb && !Platform.isAndroid) {
+ logger.w("BLE microphone streaming only supported on Android");
+ return false;
+ }
+
+ if (_isStreamingActive) {
+ logger.i("BLE microphone streaming already active");
+ return true;
+ }
+
+ try {
+ if (!await _audioRecorder.hasPermission()) {
+ logger.w("No microphone permission for streaming");
+ return false;
+ }
+
+ await _selectBLEDevice();
+
+ if (_selectedBLEDevice == null) {
+ logger.w("No BLE headset detected, cannot start streaming");
+ return false;
+ }
+
+ const encoder = AudioEncoder.wav;
+ if (!await _audioRecorder.isEncoderSupported(encoder)) {
+ logger.w("WAV encoder not supported");
+ return false;
+ }
+
+ final tempDir = await getTemporaryDirectory();
+ _streamingPath =
+ '${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav';
+
+ final config = RecordConfig(
+ encoder: encoder,
+ sampleRate: 48000,
+ bitRate: 768000,
+ numChannels: 1,
+ device: _selectedBLEDevice,
+ );
+
+ await _audioRecorder.start(config, path: _streamingPath!);
+ _isStreamingActive = true;
+ _isBLEMicrophoneStreamingEnabled = true;
+
+ // Set up amplitude monitoring for waveform display
+ _amplitudeSub?.cancel();
+ _amplitudeSub = _audioRecorder
+ .onAmplitudeChanged(const Duration(milliseconds: 100))
+ .listen((amp) {
+ final normalized = (amp.current + 50) / 50;
+ _waveformData.add(normalized.clamp(0.0, 2.0));
+
+ if (_waveformData.length > 100) {
+ _waveformData.removeAt(0);
+ }
+
+ notifyListeners();
+ });
+
+ logger.i(
+ "BLE microphone streaming started with device: ${_selectedBLEDevice!.label}",
+ );
+ notifyListeners();
+ return true;
+ } catch (e) {
+ logger.e("Failed to start BLE microphone streaming: $e");
+ _isStreamingActive = false;
+ _isBLEMicrophoneStreamingEnabled = false;
+ _streamingPath = null;
+ notifyListeners();
+ return false;
+ }
+ }
+
+ Future stopBLEMicrophoneStream() async {
+ if (!_isStreamingActive) {
+ return;
+ }
+
+ try {
+ await _audioRecorder.stop();
+ _amplitudeSub?.cancel();
+ _amplitudeSub = null;
+ _isStreamingActive = false;
+ _isBLEMicrophoneStreamingEnabled = false;
+ _waveformData.clear();
+
+ // Clean up temporary streaming file
+ if (_streamingPath != null) {
+ try {
+ final file = File(_streamingPath!);
+ if (await file.exists()) {
+ await file.delete();
+ }
+ } catch (e) {
+ // Ignore cleanup errors
+ }
+ _streamingPath = null;
+ }
+
+ logger.i("BLE microphone streaming stopped");
+ notifyListeners();
+ } catch (e) {
+ logger.e("Error stopping BLE microphone streaming: $e");
+ }
+ }
+
+ Future startRecording(String dirname) async {
+ if (_isRecording) {
+ return;
+ }
+ _isRecording = true;
+ _currentDirectory = dirname;
+ _recordingStart = DateTime.now();
+
+ try {
+ for (Wearable wearable in _recorders.keys) {
+ await _startRecorderForWearable(wearable, dirname);
+ }
+ _isRecording = true;
+ notifyListeners();
+ } catch (e, st) {
+ logger.e('Failed to start recording: $e\n$st');
+ _stopAllRecorderStreams();
+ _recordingFilepathsBySensorIdentity.clear();
+ _currentDirectory = null;
+ _recordingStart = null;
+ _isRecording = false;
+ notifyListeners();
+ rethrow;
+ }
+
+ await _startAudioRecording(
+ dirname,
+ );
+
+ notifyListeners();
+ }
+
+ Future _startAudioRecording(String recordingFolderPath) async {
+ if (!kIsWeb && !Platform.isAndroid) return;
+
+ // Only start recording if BLE microphone streaming is enabled
+ if (!_isBLEMicrophoneStreamingEnabled) {
+ logger
+ .w("BLE microphone streaming not enabled, skipping audio recording");
+ return;
+ }
+
+ // Stop streaming session before starting actual recording
+ if (_isStreamingActive) {
+ await _audioRecorder.stop();
+ _amplitudeSub?.cancel();
+ _amplitudeSub = null;
+ _isStreamingActive = false;
+
+ // Clean up temporary streaming file
+ if (_streamingPath != null) {
+ try {
+ final file = File(_streamingPath!);
+ if (await file.exists()) {
+ await file.delete();
+ }
+ } catch (e) {
+ // Ignore cleanup errors
+ }
+ _streamingPath = null;
+ }
+ }
+
+ try {
+ if (!await _audioRecorder.hasPermission()) {
+ logger.w("No microphone permission for recording");
+ return;
+ }
+
+ await _selectBLEDevice();
+
+ if (_selectedBLEDevice == null) {
+ logger.w("No BLE headset detected, skipping audio recording");
+ return;
+ }
+
+ const encoder = AudioEncoder.wav;
+ if (!await _audioRecorder.isEncoderSupported(encoder)) {
+ logger.w("WAV encoder not supported");
+ return;
+ }
+
+ final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-');
+ final audioPath = '$recordingFolderPath/audio_$timestamp.wav';
+
+ final config = RecordConfig(
+ encoder: encoder,
+ sampleRate: 48000, // Set to 48kHz for BLE audio quality
+ bitRate: 768000, // 16-bit * 48kHz * 1 channel = 768 kbps
+ numChannels: 1,
+ device: _selectedBLEDevice,
+ );
+
+ await _audioRecorder.start(config, path: audioPath);
+ _currentAudioPath = audioPath;
+ _isAudioRecording = true;
+
+ logger.i(
+ "Audio recording started: $_currentAudioPath with device: ${_selectedBLEDevice?.label ?? 'default'}",
+ );
+
+ _amplitudeSub = _audioRecorder
+ .onAmplitudeChanged(const Duration(milliseconds: 100))
+ .listen((amp) {
+ final normalized = (amp.current + 50) / 50;
+ _waveformData.add(normalized.clamp(0.0, 2.0));
+
+ if (_waveformData.length > 100) {
+ _waveformData.removeAt(0);
+ }
+
+ notifyListeners();
+ });
+ } catch (e) {
+ logger.e("Failed to start audio recording: $e");
+ _isAudioRecording = false;
+ }
+ }
+
+ void stopRecording(bool turnOffMic) async {
+ _isRecording = false;
+ _recordingStart = null;
+ _recordingFilepathsBySensorIdentity.clear();
+ _stopAllRecorderStreams();
+ try {
+ if (_isAudioRecording) {
+ final path = await _audioRecorder.stop();
+ _amplitudeSub?.cancel();
+ _amplitudeSub = null;
+ _isAudioRecording = false;
+
+ logger.i("Audio recording saved to: $path");
+ _currentAudioPath = null;
+ }
+ } catch (e) {
+ logger.e("Error stopping audio recording: $e");
+ }
+
+ // Restart streaming if it was enabled before recording
+ if (!turnOffMic &&
+ _isBLEMicrophoneStreamingEnabled &&
+ !_isStreamingActive) {
+ unawaited(startBLEMicrophoneStream());
+ }
+
+ notifyListeners();
+ }
+
+ Recorder? getRecorder(Wearable wearable, Sensor sensor) {
+ if (!_recorders.containsKey(wearable)) {
+ return null;
+ }
+ return _recorders[wearable]?[sensor];
+ }
+
+ Map getRecorders(Wearable wearable) {
+ return _recorders[wearable] ?? {};
+ }
+
+ Future addWearable(Wearable wearable) async {
+ final Wearable? existing = _findWearableByDeviceId(wearable.deviceId);
+
+ if (existing != null) {
+ _disposeWearable(existing);
+ _recorders.remove(existing);
+ }
+
+ _recorders[wearable] = {};
+
+ wearable.addDisconnectListener(() {
+ removeWearable(wearable);
+ });
+
+ if (wearable.hasCapability()) {
+ for (Sensor sensor
+ in wearable.requireCapability().sensors) {
+ if (!_recorders[wearable]!.containsKey(sensor)) {
+ _recorders[wearable]![sensor] = Recorder(columns: sensor.axisNames);
+ }
+ }
+ }
+
+ if (_isRecording && _currentDirectory != null) {
+ unawaited(
+ _startRecorderForWearable(
+ wearable,
+ _currentDirectory!,
+ resumed: true,
+ ),
+ );
+ }
+
+ _updateConnected();
+ }
+
+ /// Reconciles recorder state with the current connected wearable set.
+ ///
+ /// This keeps recorder registration derived from the authoritative
+ /// [WearablesProvider] connection state instead of relying on each caller to
+ /// remember a second side effect.
+ void synchronizeConnectedWearables(Iterable wearables) {
+ final desiredById = {
+ for (final wearable in wearables) wearable.deviceId: wearable,
+ };
+
+ _pendingSynchronization = _pendingSynchronization.then((_) async {
+ if (_disposed) {
+ return;
+ }
+
+ final existingById = {
+ for (final wearable in _recorders.keys) wearable.deviceId: wearable,
+ };
+
+ for (final entry in existingById.entries) {
+ if (!desiredById.containsKey(entry.key)) {
+ removeWearable(entry.value);
+ }
+ }
+
+ for (final entry in desiredById.entries) {
+ final existing = existingById[entry.key];
+ if (existing == null || !identical(existing, entry.value)) {
+ await addWearable(entry.value);
+ }
+ }
+ });
+ }
+
+ void removeWearable(Wearable wearable) {
+ _disposeWearable(wearable);
+ _recorders.remove(wearable);
+ _updateConnected();
+ }
+
+ void _updateConnected() {
+ _hasSensorsConnected = !(_recorders.isEmpty ||
+ _recorders.values.every((sensors) => sensors.isEmpty));
+ logger.i('Has sensors connected: $_hasSensorsConnected');
+ notifyListeners();
+ }
+
+ Wearable? _findWearableByDeviceId(String deviceId) {
+ for (final wearable in _recorders.keys) {
+ if (wearable.deviceId == deviceId) {
+ return wearable;
+ }
+ }
+ return null;
+ }
+
+ void _disposeWearable(Wearable wearable) {
+ final recorderMap = _recorders[wearable];
+ if (recorderMap == null) return;
+ for (final recorder in recorderMap.values) {
+ recorder.stop();
+ }
+ }
+
+ Future _startRecorderForWearable(
+ Wearable wearable,
+ String dirname, {
+ bool resumed = false,
+ }) async {
+ for (Sensor sensor in _recorders[wearable]!.keys) {
+ Recorder? recorder = _recorders[wearable]?[sensor];
+ if (recorder == null) continue;
+
+ final sensorIdentity = _sensorRecordingIdentity(
+ wearable: wearable,
+ sensor: sensor,
+ );
+ final existingFilepath =
+ _recordingFilepathsBySensorIdentity[sensorIdentity];
+ final append = resumed && existingFilepath != null;
+ final filepath = existingFilepath ??
+ await _createRecordingFilepath(
+ wearable: wearable,
+ sensor: sensor,
+ dirname: dirname,
+ );
+ _recordingFilepathsBySensorIdentity[sensorIdentity] = filepath;
+
+ File file = await recorder.start(
+ filepath: filepath,
+ inputStream: SensorStreams.shared(
+ wearable: wearable,
+ sensor: sensor,
+ ),
+ append: append,
+ );
+
+ logger.i(
+ '${resumed ? 'Resumed' : 'Started'} recording for '
+ '${wearable.name} - ${sensor.sensorName} to ${file.path}',
+ );
+ }
+ }
+
+ /// Builds a stable per-device/per-sensor identity for the current session.
+ ///
+ /// Reconnects replace the [Wearable] and [Sensor] object instances, so file
+ /// reuse must be keyed by semantic sensor identity instead of object
+ /// identity.
+ String _sensorRecordingIdentity({
+ required Wearable wearable,
+ required Sensor sensor,
+ }) {
+ final axisNames = sensor.axisNames.join(',');
+ final axisUnits = sensor.axisUnits.join(',');
+ return '${wearable.deviceId}|${sensor.runtimeType}|${sensor.sensorName}|$axisNames|$axisUnits';
+ }
+
+ /// Resolves a new file path for a sensor without overwriting prior exports.
+ Future _createRecordingFilepath({
+ required Wearable wearable,
+ required Sensor sensor,
+ required String dirname,
+ }) async {
+ final base = await _recordingFilenameStem(
+ wearable: wearable,
+ sensor: sensor,
+ );
+ var name = base;
+ var counter = 1;
+
+ while (await File('$dirname/$name.csv').exists()) {
+ name = '${base}_$counter';
+ counter++;
+ }
+
+ return '$dirname/$name.csv';
+ }
+
+ /// Builds the exported filename stem for a wearable sensor recording.
+ ///
+ /// Stereo-capable devices include their side marker so left/right files stay
+ /// distinguishable in shared recording folders.
+ Future _recordingFilenameStem({
+ required Wearable wearable,
+ required Sensor sensor,
+ }) async {
+ if (!wearable.hasCapability()) {
+ return '${wearable.name}_${sensor.sensorName}';
+ }
+ final stereoPositionLabel = await _stereoPositionLabel(
+ wearable.requireCapability(),
+ );
+ if (stereoPositionLabel != null) {
+ return '${wearable.name}-$stereoPositionLabel-${sensor.sensorName}';
+ }
+ return '${wearable.name}_${sensor.sensorName}';
+ }
+
+ /// Returns the short stereo side label used in exported filenames.
+ Future _stereoPositionLabel(StereoDevice wearable) async {
+ final position = await wearable.position;
+ return switch (position) {
+ DevicePosition.left => 'L',
+ DevicePosition.right => 'R',
+ _ => null,
+ };
+ }
+
+ void _stopAllRecorderStreams() {
+ for (Wearable wearable in _recorders.keys) {
+ for (Sensor sensor in _recorders[wearable]!.keys) {
+ final recorder = _recorders[wearable]?[sensor];
+ if (recorder == null) {
+ continue;
+ }
+ recorder.stop();
+ logger.i(
+ 'Stopped recording for ${wearable.name} - ${sensor.sensorName}',
+ );
+ }
+ }
+ }
+
+ @override
+ void dispose() {
+ _disposed = true;
+ // Stop streaming
+ stopBLEMicrophoneStream();
+
+ // Stop recording
+ _audioRecorder.stop().then((_) {
+ _audioRecorder.dispose();
+ }).catchError((e) {
+ logger.e("Error stopping audio in dispose: $e");
+ });
+ _amplitudeSub?.cancel();
+ _waveformData.clear();
+ for (final wearable in _recorders.keys.toList()) {
+ _disposeWearable(wearable);
+ }
+ _recordingFilepathsBySensorIdentity.clear();
+ _recorders.clear();
+ super.dispose();
+ }
+}
diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart
new file mode 100644
index 00000000..40bc27ad
--- /dev/null
+++ b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart
@@ -0,0 +1,285 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
+
+import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart';
+import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_storage_web.dart';
+
+import '../models/logger.dart';
+import '../models/sensor_streams.dart';
+
+class SensorRecorderProvider with ChangeNotifier {
+ final Map _wearablesById = {};
+ final Map> _sensorSubscriptions = {};
+ final Map _sessions = {};
+ bool _isRecording = false;
+ bool _hasSensorsConnected = false;
+ String? _currentDirectory;
+ DateTime? _recordingStart;
+
+ bool get isRecording => _isRecording;
+ bool get hasSensorsConnected => _hasSensorsConnected;
+ String? get currentDirectory => _currentDirectory;
+ DateTime? get recordingStart => _recordingStart;
+
+ final List _waveformData = [];
+ final int _waveformRevision = 0;
+ int get waveformRevision => _waveformRevision;
+ List get waveformData => List.unmodifiable(_waveformData);
+
+ bool _isBLEMicrophoneStreamingEnabled = false;
+ bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled;
+
+ Future startBLEMicrophoneStream() async {
+ logger.w('BLE microphone streaming is not supported on web.');
+ return false;
+ }
+
+ Future stopBLEMicrophoneStream() async {
+ _isBLEMicrophoneStreamingEnabled = false;
+ _waveformData.clear();
+ notifyListeners();
+ }
+
+ Future startRecording(String dirname) async {
+ if (_isRecording) {
+ return;
+ }
+
+ _isRecording = true;
+ _currentDirectory = dirname;
+ _recordingStart = DateTime.now();
+ _sessions.clear();
+
+ for (final wearable in _wearablesById.values) {
+ await _startRecordingForWearable(wearable, dirname);
+ }
+
+ final initialDrafts = _sessions.values.map((session) {
+ return LocalRecorderDraftFile(
+ name: session.fileName,
+ content: session.content.toString(), // Contains the header
+ );
+ }).toList();
+
+ if (initialDrafts.isNotEmpty) {
+ await persistRecordingFolderFiles(dirname, initialDrafts);
+ }
+
+ notifyListeners();
+ }
+
+ void stopRecording(bool turnOffMic) async {
+ if (!_isRecording) {
+ return;
+ }
+
+ _isRecording = false;
+ _recordingStart = null;
+
+ final folderPath = _currentDirectory;
+ final sessions = _sessions.values.toList(growable: false);
+ _sessions.clear();
+ await _cancelSensorSubscriptions(_sensorSubscriptions.keys);
+
+ for (final session in sessions) {
+ await session.dispose();
+ }
+
+ if (folderPath != null) {
+ final draftFiles = sessions
+ .where((session) => session.content.isNotEmpty)
+ .map(
+ (session) => LocalRecorderDraftFile(
+ name: session.fileName,
+ content: session.content.toString(),
+ ),
+ )
+ .toList();
+
+ if (draftFiles.isEmpty) {
+ await deleteRecordingFolder(folderPath);
+ } else {
+ await persistRecordingFolderFiles(folderPath, draftFiles);
+ }
+ }
+
+ _currentDirectory = null;
+
+ if (!turnOffMic && _isBLEMicrophoneStreamingEnabled) {
+ unawaited(startBLEMicrophoneStream());
+ }
+
+ notifyListeners();
+ }
+
+ Future addWearable(Wearable wearable) async {
+ _wearablesById[wearable.deviceId] = wearable;
+
+ wearable.addDisconnectListener(() {
+ removeWearable(wearable);
+ });
+
+ if (_isRecording && _currentDirectory != null) {
+ await _startRecordingForWearable(wearable, _currentDirectory!);
+ }
+
+ _updateConnected();
+ }
+
+ void synchronizeConnectedWearables(Iterable wearables) {
+ final desiredById = {
+ for (final wearable in wearables) wearable.deviceId: wearable,
+ };
+
+ final existingIds = _wearablesById.keys.toList(growable: false);
+ for (final deviceId in existingIds) {
+ if (!desiredById.containsKey(deviceId)) {
+ removeWearable(_wearablesById[deviceId]!);
+ }
+ }
+
+ for (final entry in desiredById.entries) {
+ final existing = _wearablesById[entry.key];
+ if (existing == null || !identical(existing, entry.value)) {
+ unawaited(addWearable(entry.value));
+ }
+ }
+ }
+
+ void removeWearable(Wearable wearable) {
+ _wearablesById.remove(wearable.deviceId);
+ final sessionKeys = _sessions.keys
+ .where((key) => key.startsWith('${wearable.deviceId}|'))
+ .toList(growable: false);
+ for (final key in sessionKeys) {
+ unawaited(_sessions.remove(key)?.dispose());
+ }
+ unawaited(_cancelSensorSubscriptions(sessionKeys));
+ _updateConnected();
+ }
+
+ void _updateConnected() {
+ _hasSensorsConnected = _wearablesById.isNotEmpty;
+ logger.i('Has sensors connected: $_hasSensorsConnected');
+ notifyListeners();
+ }
+
+ Future _startRecordingForWearable(
+ Wearable wearable,
+ String dirname,
+ ) async {
+ if (!wearable.hasCapability()) {
+ return;
+ }
+
+ for (final sensor in wearable.requireCapability().sensors) {
+ final key = _sensorRecordingKey(wearable: wearable, sensor: sensor);
+ if (_sessions.containsKey(key)) {
+ continue;
+ }
+
+ final session = _WebRecordingSession(
+ fileName:
+ '${await _recordingFilenameStem(wearable: wearable, sensor: sensor)}.csv',
+ sensor: sensor,
+ );
+ _sessions[key] = session;
+
+ _sensorSubscriptions[key] = SensorStreams.shared(
+ wearable: wearable,
+ sensor: sensor,
+ ).listen(session.append);
+ }
+ }
+
+ Future _cancelSensorSubscriptions(Iterable keys) async {
+ final subscriptions = >[];
+ for (final key in keys.toList(growable: false)) {
+ final subscription = _sensorSubscriptions.remove(key);
+ if (subscription != null) {
+ subscriptions.add(subscription);
+ }
+ }
+
+ await Future.wait(
+ subscriptions.map((subscription) => subscription.cancel()),
+ );
+ }
+
+ String _sensorRecordingKey({
+ required Wearable wearable,
+ required Sensor sensor,
+ }) {
+ final axisNames = sensor.axisNames.join(',');
+ final axisUnits = sensor.axisUnits.join(',');
+ return '${wearable.deviceId}|${sensor.runtimeType}|${sensor.sensorName}|$axisNames|$axisUnits';
+ }
+
+ Future _recordingFilenameStem({
+ required Wearable wearable,
+ required Sensor sensor,
+ }) async {
+ if (!wearable.hasCapability()) {
+ return '${wearable.name}_${sensor.sensorName}';
+ }
+ final stereoPositionLabel = await _stereoPositionLabel(
+ wearable.requireCapability(),
+ );
+ if (stereoPositionLabel != null) {
+ return '${wearable.name}-$stereoPositionLabel-${sensor.sensorName}';
+ }
+ return '${wearable.name}_${sensor.sensorName}';
+ }
+
+ Future _stereoPositionLabel(StereoDevice wearable) async {
+ final position = await wearable.position;
+ return switch (position) {
+ DevicePosition.left => 'L',
+ DevicePosition.right => 'R',
+ _ => null,
+ };
+ }
+
+ @override
+ void dispose() {
+ unawaited(_cancelSensorSubscriptions(_sensorSubscriptions.keys));
+ for (final session in _sessions.values) {
+ unawaited(session.dispose());
+ }
+ _sessions.clear();
+ _wearablesById.clear();
+ _waveformData.clear();
+ super.dispose();
+ }
+}
+
+class _WebRecordingSession {
+ final String fileName;
+ final Sensor sensor;
+ final StringBuffer content = StringBuffer();
+
+ _WebRecordingSession({required this.fileName, required this.sensor}) {
+ content.writeln(_buildHeader());
+ }
+
+ void append(SensorValue value) {
+ if (value is SensorDoubleValue) {
+ content.writeln(
+ [value.timestamp, ...value.values].join(','),
+ );
+ } else if (value is SensorIntValue) {
+ content.writeln(
+ [value.timestamp, ...value.values].join(','),
+ );
+ }
+ }
+
+ String _buildHeader() {
+ final axisNames = sensor.axisNames.join(',');
+ return axisNames.isEmpty ? 'timestamp' : 'timestamp,$axisNames';
+ }
+
+ Future dispose() async {}
+}
diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart
index bb453e12..2bf53acd 100644
--- a/open_wearable/lib/widgets/devices/connect_devices_page.dart
+++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:typed_data';
+import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
@@ -419,6 +420,9 @@ class _ConnectDevicesPageState extends State {
final connector = context.read();
try {
+ if (_scanSnapshot.isScanning) {
+ await ConnectDevicesScanSession.stopScanning();
+ }
await connector.connect(device);
ConnectDevicesScanSession.removeDiscoveredDevice(device.id);
} catch (e, stackTrace) {
@@ -495,6 +499,11 @@ class _ConnectDevicesPageState extends State {
required DiscoveredDevice device,
required WearableConnector connector,
}) async {
+ // Skip stale connection recovery on web platform
+ if (kIsWeb) {
+ return false;
+ }
+
try {
await UniversalBle.disconnect(device.id);
} catch (error, stackTrace) {
diff --git a/open_wearable/lib/widgets/home_page_overview.dart b/open_wearable/lib/widgets/home_page_overview.dart
index 19edbdb5..0346d8f9 100644
--- a/open_wearable/lib/widgets/home_page_overview.dart
+++ b/open_wearable/lib/widgets/home_page_overview.dart
@@ -3,7 +3,7 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'package:open_wearable/models/device_name_formatter.dart';
-import 'package:open_wearable/view_models/sensor_recorder_provider.dart';
+import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart';
import 'package:open_wearable/view_models/wearables_provider.dart';
import 'package:open_wearable/widgets/connector_activity_indicator.dart';
import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart';
diff --git a/open_wearable/lib/widgets/recording_activity_indicator.dart b/open_wearable/lib/widgets/recording_activity_indicator.dart
index b273fb21..fef3c391 100644
--- a/open_wearable/lib/widgets/recording_activity_indicator.dart
+++ b/open_wearable/lib/widgets/recording_activity_indicator.dart
@@ -3,7 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
-import '../view_models/sensor_recorder_provider.dart';
+import '../view_models/sensor_recorder_provider_facade.dart';
/// Shared pulse ticker so every recording indicator stays in sync.
class _RecordingPulseTicker {
diff --git a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart
new file mode 100644
index 00000000..13dfb3e1
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart
@@ -0,0 +1,55 @@
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
+import 'package:provider/provider.dart';
+import '../../../view_models/sensor_recorder_provider_facade.dart';
+
+class BLEMicrophoneStreamingRow extends StatelessWidget {
+ const BLEMicrophoneStreamingRow({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ if (kIsWeb || !Platform.isAndroid) {
+ return const SizedBox.shrink();
+ }
+
+ return Consumer(
+ builder: (context, recorderProvider, child) {
+ final isStreamingEnabled =
+ recorderProvider.isBLEMicrophoneStreamingEnabled;
+
+ return PlatformListTile(
+ title: PlatformText('BLE Microphone Streaming'),
+ subtitle: PlatformText(
+ isStreamingEnabled
+ ? 'Microphone stream is active'
+ : 'Enable to start microphone streaming',
+ ),
+ trailing: PlatformSwitch(
+ value: isStreamingEnabled,
+ onChanged: (value) async {
+ if (value) {
+ final success =
+ await recorderProvider.startBLEMicrophoneStream();
+ if (!success && context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: PlatformText(
+ 'Failed to start BLE microphone streaming. '
+ 'Make sure a BLE headset is connected and microphone permission is granted.',
+ ),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ } else {
+ await recorderProvider.stopBLEMicrophoneStream();
+ }
+ },
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart
index 0f7d46d1..c46baed5 100644
--- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart
+++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart
@@ -1,4 +1,5 @@
-import 'package:flutter/foundation.dart' show setEquals;
+import 'package:flutter/foundation.dart'
+ show defaultTargetPlatform, kIsWeb, setEquals;
import 'package:flutter/material.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
@@ -38,7 +39,19 @@ class SensorConfigurationDetailView extends StatelessWidget {
final targetOptions = sensorConfiguration is ConfigurableSensorConfiguration
? (sensorConfiguration as ConfigurableSensorConfiguration)
.availableOptions
- .toList(growable: false)
+ .where((option) {
+ // On Android and web, show everything. The iOS-specific stream/mic
+ // restriction only applies to native iOS builds.
+ if (kIsWeb || defaultTargetPlatform == TargetPlatform.android) {
+ return true;
+ }
+
+ // If on iOS, hide 'microphone' + 'stream' combination
+ final isMic =
+ sensorConfiguration.name.toLowerCase().contains('microphone');
+ final isStream = option is StreamSensorConfigOption;
+ return !(isMic && isStream);
+ }).toList(growable: false)
: const [];
return ListView(
@@ -350,9 +363,8 @@ class _OptionToggleTile extends StatelessWidget {
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
- color: selected
- ? accentColor.withValues(alpha: 0.06)
- : Colors.transparent,
+ color:
+ selected ? accentColor.withValues(alpha: 0.06) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: (selected ? accentColor : colorScheme.outlineVariant)
diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart
index cc9f6411..0c60af2a 100644
--- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart
+++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart
@@ -4,6 +4,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
import 'package:open_wearable/models/wearable_display_group.dart';
import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
+import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart';
import 'package:open_wearable/view_models/wearables_provider.dart';
import 'package:open_wearable/widgets/app_toast.dart';
import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
@@ -274,6 +275,10 @@ class SensorConfigurationView extends StatelessWidget {
int actionableCount = 0;
+ final recorderProvider =
+ Provider.of(context, listen: false);
+ bool shouldEnableMicrophoneStreaming = false;
+
for (final target in targets) {
final primaryEntriesToApply = _entriesToApplyForProvider(target.provider);
final mirroredEntriesToApply = _entriesToApplyForMirroredTarget(target);
@@ -286,6 +291,13 @@ class SensorConfigurationView extends StatelessWidget {
for (final entry in primaryEntriesToApply) {
final SensorConfiguration config = entry.$1;
final SensorConfigurationValue value = entry.$2;
+ if (config.name.toLowerCase().contains('microphone')) {
+ final options =
+ target.provider.getSelectedConfigurationOptions(config);
+ if (options.any((opt) => opt is StreamSensorConfigOption)) {
+ shouldEnableMicrophoneStreaming = true;
+ }
+ }
// Always push the selected canonical value to the primary device on
// apply. This also heals primary-side drift/unknown states.
config.setConfiguration(value);
@@ -297,11 +309,23 @@ class SensorConfigurationView extends StatelessWidget {
config.setConfiguration(value);
}
+ if (shouldEnableMicrophoneStreaming &&
+ !recorderProvider.isBLEMicrophoneStreamingEnabled) {
+ await recorderProvider.startBLEMicrophoneStream();
+ } else if (!shouldEnableMicrophoneStreaming &&
+ recorderProvider.isBLEMicrophoneStreamingEnabled) {
+ await recorderProvider.stopBLEMicrophoneStream();
+ }
+
logger.d(
"Applied ${primaryEntriesToApply.length} primary and ${mirroredEntriesToApply.length} mirrored sensor settings for ${target.primaryDevice.name}",
);
}
+ if (!context.mounted) {
+ return;
+ }
+
if (actionableCount == 0) {
AppToast.show(
context,
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart
index 257c7069..47b4c136 100644
--- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart
@@ -1,9 +1,7 @@
-import 'dart:io';
-
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
-import 'package:open_file/open_file.dart';
import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_file_actions.dart';
+import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart';
import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart';
import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_storage.dart';
import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
@@ -25,7 +23,8 @@ class _LocalRecorderAllRecordingsPageState
extends State {
final Set _expandedFolders = {};
final Set _selectedFolderPaths = {};
- List _recordings = [];
+ List _recordings =
+ [];
bool _isLoading = true;
bool _isBusy = false;
bool _isSelectionMode = false;
@@ -39,7 +38,7 @@ class _LocalRecorderAllRecordingsPageState
}
Future _loadRecordings() async {
- final recordings = await listRecordingDirectories();
+ final recordings = await listRecordingFolders();
if (!mounted) return;
setState(() {
_recordings = recordings;
@@ -53,7 +52,7 @@ class _LocalRecorderAllRecordingsPageState
});
}
- Future _shareFolder(Directory folder) async {
+ Future _shareFolder(LocalRecorderRecordingFolder folder) async {
try {
await localRecorderShareFolder(folder);
} catch (e) {
@@ -61,7 +60,7 @@ class _LocalRecorderAllRecordingsPageState
}
}
- Future _shareFile(File file) async {
+ Future _shareFile(LocalRecorderRecordingFile file) async {
try {
await localRecorderShareFile(file);
} catch (e) {
@@ -69,11 +68,8 @@ class _LocalRecorderAllRecordingsPageState
}
}
- Future _openFile(File file) async {
- final result = await localRecorderOpenRecordingFile(file);
- if (result.type != ResultType.done) {
- await _showErrorDialog('Could not open file: ${result.message}');
- }
+ Future _openFile(LocalRecorderRecordingFile file) async {
+ await localRecorderOpenRecordingFile(file);
}
Future _shareSelectedFolders() async {
@@ -81,7 +77,8 @@ class _LocalRecorderAllRecordingsPageState
setState(() => _isBusy = true);
try {
for (final path in _selectedFolderPaths) {
- await localRecorderShareFolder(Directory(path));
+ final folder = _recordings.firstWhere((entry) => entry.path == path);
+ await localRecorderShareFolder(folder);
}
} catch (e) {
await _showErrorDialog('Failed to share selected recordings: $e');
@@ -125,10 +122,7 @@ class _LocalRecorderAllRecordingsPageState
setState(() => _isBusy = true);
try {
for (final path in _selectedFolderPaths.toList()) {
- final folder = Directory(path);
- if (await folder.exists()) {
- await folder.delete(recursive: true);
- }
+ await deleteRecordingFolder(path);
}
_selectedFolderPaths.clear();
_isSelectionMode = false;
@@ -142,8 +136,8 @@ class _LocalRecorderAllRecordingsPageState
}
}
- Future _deleteSingleFolder(Directory folder) async {
- final name = localRecorderBasename(folder.path);
+ Future _deleteSingleFolder(LocalRecorderRecordingFolder folder) async {
+ final name = folder.name;
final shouldDelete = await showPlatformDialog(
context: context,
builder: (_) => PlatformAlertDialog(
@@ -170,9 +164,7 @@ class _LocalRecorderAllRecordingsPageState
if (!shouldDelete) return;
try {
- if (await folder.exists()) {
- await folder.delete(recursive: true);
- }
+ await deleteRecordingFolder(folder.path);
if (!mounted) return;
setState(() {
_expandedFolders.remove(folder.path);
@@ -259,8 +251,8 @@ class _LocalRecorderAllRecordingsPageState
final isCurrent = widget.isRecording && index == 0;
final isExpanded = _expandedFolders.contains(folder.path);
final files = isExpanded
- ? listFilesInRecordingFolder(folder)
- : [];
+ ? folder.files
+ : [];
final isSelected = _selectedFolderPaths.contains(folder.path);
return LocalRecorderRecordingFolderCard(
@@ -269,7 +261,7 @@ class _LocalRecorderAllRecordingsPageState
isExpanded: isExpanded,
files: files,
updatedLabel:
- 'Updated ${localRecorderFormatDateTime(folder.statSync().changed)}',
+ 'Updated ${localRecorderFormatDateTime(folder.updatedAt)}',
selectionMode: _selectionMode,
isSelected: isSelected,
onSelectionToggle: isCurrent
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart
new file mode 100644
index 00000000..ffa6c76e
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart
@@ -0,0 +1,50 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
+
+class LocalRecorderDialogs {
+ static Future askOverwriteConfirmation(
+ BuildContext context,
+ String dirPath,
+ ) async {
+ return await showPlatformDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: PlatformText('Directory not empty'),
+ content: PlatformText(
+ '"$dirPath" already contains files or folders.\n\n'
+ 'New sensor files will be added; existing files with the same '
+ 'names will be overwritten. Continue anyway?'),
+ actions: [
+ PlatformTextButton(
+ onPressed: () => Navigator.pop(ctx, false),
+ child: PlatformText('Cancel'),
+ ),
+ PlatformTextButton(
+ onPressed: () => Navigator.pop(ctx, true),
+ child: PlatformText('Continue'),
+ ),
+ ],
+ ),
+ ) ??
+ false;
+ }
+
+ static Future showErrorDialog(
+ BuildContext context,
+ String message,
+ ) async {
+ await showPlatformDialog(
+ context: context,
+ builder: (_) => PlatformAlertDialog(
+ title: PlatformText('Error'),
+ content: PlatformText(message),
+ actions: [
+ PlatformDialogAction(
+ child: PlatformText('OK'),
+ onPressed: () => Navigator.pop(context),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions.dart
index 5193dc17..24b21b16 100644
--- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions.dart
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions.dart
@@ -1,47 +1,2 @@
-import 'dart:io';
-
-import 'package:flutter_archive/flutter_archive.dart';
-import 'package:open_file/open_file.dart';
-import 'package:path_provider/path_provider.dart';
-import 'package:share_plus/share_plus.dart';
-
-Future localRecorderShareFile(File file) async {
- await SharePlus.instance.share(
- ShareParams(
- files: [XFile(file.path)],
- subject: 'OpenWearable Recording File',
- ),
- );
-}
-
-Future localRecorderShareFolder(Directory folder) async {
- final tempDir = await getTemporaryDirectory();
- final zipPath = '${tempDir.path}/${folder.path.split(RegExp(r"[\\\\/]+")).last}.zip';
- final zipFile = File(zipPath);
-
- await ZipFile.createFromDirectory(
- sourceDir: folder,
- zipFile: zipFile,
- recurseSubDirs: true,
- );
-
- try {
- await SharePlus.instance.share(
- ShareParams(
- files: [XFile(zipFile.path)],
- subject: 'OpenWearable Recording',
- ),
- );
- } finally {
- if (await zipFile.exists()) {
- await zipFile.delete();
- }
- }
-}
-
-Future localRecorderOpenRecordingFile(File file) {
- return OpenFile.open(
- file.path,
- type: 'text/comma-separated-values',
- );
-}
+export 'local_recorder_file_actions_io.dart'
+ if (dart.library.html) 'local_recorder_file_actions_web.dart';
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart
new file mode 100644
index 00000000..99f8184a
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart
@@ -0,0 +1,37 @@
+import 'package:open_file/open_file.dart';
+import 'package:share_plus/share_plus.dart';
+
+import 'local_recorder_models.dart';
+
+Future localRecorderShareFile(LocalRecorderRecordingFile file) async {
+ await SharePlus.instance.share(
+ ShareParams(
+ files: [XFile(file.path)],
+ subject: 'OpenWearable Recording File',
+ ),
+ );
+}
+
+Future localRecorderShareFolder(
+ LocalRecorderRecordingFolder folder,
+) async {
+ await SharePlus.instance.share(
+ ShareParams(
+ files: folder.files.map((entry) => XFile(entry.path)).toList(),
+ subject: 'OpenWearable Recording',
+ ),
+ );
+}
+
+Future localRecorderOpenRecordingFile(
+ LocalRecorderRecordingFile file,
+) async {
+ final result = await OpenFile.open(
+ file.path,
+ type: 'text/comma-separated-values',
+ );
+
+ if (result.type != ResultType.done) {
+ throw StateError(result.message);
+ }
+}
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart
new file mode 100644
index 00000000..7dd4803c
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart
@@ -0,0 +1,58 @@
+import 'dart:js_interop';
+
+import 'package:share_plus/share_plus.dart';
+import 'package:web/web.dart' as web;
+
+import 'local_recorder_models.dart';
+import 'local_recorder_storage.dart';
+
+Future localRecorderShareFile(LocalRecorderRecordingFile file) async {
+ final bytes = await readRecordingFileBytes(file);
+ await SharePlus.instance.share(
+ ShareParams(
+ files: [
+ XFile.fromData(
+ bytes,
+ name: file.name,
+ mimeType: file.mimeType,
+ ),
+ ],
+ subject: 'OpenWearable Recording File',
+ ),
+ );
+}
+
+Future localRecorderShareFolder(
+ LocalRecorderRecordingFolder folder,
+) async {
+ final files = [];
+ for (final file in folder.files) {
+ final bytes = await readRecordingFileBytes(file);
+ files.add(
+ XFile.fromData(
+ bytes,
+ name: file.name,
+ mimeType: file.mimeType,
+ ),
+ );
+ }
+
+ await SharePlus.instance.share(
+ ShareParams(
+ files: files,
+ subject: 'OpenWearable Recording',
+ ),
+ );
+}
+
+Future localRecorderOpenRecordingFile(
+ LocalRecorderRecordingFile file,
+) async {
+ final bytes = await readRecordingFileBytes(file);
+ final blob = web.Blob(
+ [bytes.toJS].toJS,
+ web.BlobPropertyBag(type: file.mimeType),
+ );
+ final url = web.URL.createObjectURL(blob);
+ web.window.open(url, '_blank');
+}
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart
new file mode 100644
index 00000000..d5584d52
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart
@@ -0,0 +1,8 @@
+import 'local_recorder_storage.dart';
+
+class Files {
+ static Future pickDirectory() => pickRecordingDirectory();
+
+ static Future isDirectoryEmpty(String path) =>
+ isRecordingDirectoryEmpty(path);
+}
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart
new file mode 100644
index 00000000..adb923ac
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart
@@ -0,0 +1,56 @@
+class LocalRecorderRecordingFile {
+ final String path;
+ final String name;
+ final int sizeBytes;
+ final DateTime updatedAt;
+ final String mimeType;
+
+ const LocalRecorderRecordingFile({
+ required this.path,
+ required this.name,
+ required this.sizeBytes,
+ required this.updatedAt,
+ this.mimeType = 'text/csv',
+ });
+}
+
+class LocalRecorderRecordingFolder {
+ final String path;
+ final String name;
+ final DateTime updatedAt;
+ final List files;
+
+ const LocalRecorderRecordingFolder({
+ required this.path,
+ required this.name,
+ required this.updatedAt,
+ required this.files,
+ });
+}
+
+class LocalRecorderDraftFile {
+ final String name;
+ final String content;
+ final String mimeType;
+
+ const LocalRecorderDraftFile({
+ required this.name,
+ required this.content,
+ this.mimeType = 'text/csv',
+ });
+}
+
+String localRecorderBasename(String path) => path.split(RegExp(r'[\\/]+')).last;
+
+String localRecorderFormatFileSize(int bytes) {
+ if (bytes < 1024) return '$bytes B';
+ if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
+ return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
+}
+
+String localRecorderFormatDateTime(DateTime value) {
+ final local = value.toLocal();
+ String twoDigits(int n) => n.toString().padLeft(2, '0');
+ return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} '
+ '${twoDigits(local.hour)}:${twoDigits(local.minute)}';
+}
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart
index 849fc190..031205d3 100644
--- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart
@@ -1,20 +1,19 @@
-import 'dart:io';
-
import 'package:flutter/material.dart';
+import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart';
import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
class LocalRecorderRecordingFolderCard extends StatelessWidget {
- final Directory folder;
+ final LocalRecorderRecordingFolder folder;
final bool isCurrentRecording;
final bool isExpanded;
- final List files;
+ final List files;
final String updatedLabel;
final VoidCallback onToggleExpanded;
final VoidCallback onShareFolder;
final VoidCallback onDeleteFolder;
- final void Function(File file) onShareFile;
- final void Function(File file) onOpenFile;
- final String Function(File file) formatFileSize;
+ final void Function(LocalRecorderRecordingFile file) onShareFile;
+ final void Function(LocalRecorderRecordingFile file) onOpenFile;
+ final String Function(int bytes) formatFileSize;
final bool selectionMode;
final bool isSelected;
final VoidCallback? onSelectionToggle;
@@ -37,13 +36,10 @@ class LocalRecorderRecordingFolderCard extends StatelessWidget {
this.onSelectionToggle,
});
- String _basename(String path) => path.split(RegExp(r'[\\/]+')).last;
-
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
- final folderName = _basename(folder.path);
return Card(
margin: const EdgeInsets.only(bottom: SensorPageSpacing.sectionGap),
@@ -57,7 +53,7 @@ class LocalRecorderRecordingFolderCard extends StatelessWidget {
: colorScheme.onSurfaceVariant,
),
title: Text(
- folderName,
+ folder.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
@@ -152,7 +148,7 @@ class LocalRecorderRecordingFolderCard extends StatelessWidget {
...files.map(
(file) => _LocalRecorderRecordingFileTile(
file: file,
- fileSize: formatFileSize(file),
+ fileSize: formatFileSize(file.sizeBytes),
onShare: () => onShareFile(file),
onOpen: () => onOpenFile(file),
),
@@ -164,7 +160,7 @@ class LocalRecorderRecordingFolderCard extends StatelessWidget {
}
class _LocalRecorderRecordingFileTile extends StatelessWidget {
- final File file;
+ final LocalRecorderRecordingFile file;
final String fileSize;
final VoidCallback onShare;
final VoidCallback onOpen;
@@ -176,12 +172,9 @@ class _LocalRecorderRecordingFileTile extends StatelessWidget {
required this.onOpen,
});
- String _basename(String path) => path.split(RegExp(r'[\\/]+')).last;
-
@override
Widget build(BuildContext context) {
- final fileName = _basename(file.path);
- final isCsv = fileName.toLowerCase().endsWith('.csv');
+ final isCsv = file.name.toLowerCase().endsWith('.csv');
return ListTile(
contentPadding: const EdgeInsets.fromLTRB(58, 2, 10, 2),
@@ -191,7 +184,7 @@ class _LocalRecorderRecordingFileTile extends StatelessWidget {
size: 20,
),
title: Text(
- fileName,
+ file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart
index 15d92210..edda661e 100644
--- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart
@@ -1,105 +1,2 @@
-import 'dart:io';
-
-import 'package:flutter/foundation.dart' show kIsWeb;
-import 'package:path_provider/path_provider.dart';
-
-String localRecorderBasename(String path) => path.split(RegExp(r'[\\/]+')).last;
-
-String localRecorderFormatFileSize(File file) {
- final bytes = file.lengthSync();
- if (bytes < 1024) return '$bytes B';
- if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
- return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
-}
-
-String localRecorderFormatDateTime(DateTime value) {
- final local = value.toLocal();
- String twoDigits(int n) => n.toString().padLeft(2, '0');
- return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} '
- '${twoDigits(local.hour)}:${twoDigits(local.minute)}';
-}
-
-Future getIOSDirectory() async {
- final appDocDir = await getApplicationDocumentsDirectory();
- final dirPath = '${appDocDir.path}/Recordings';
- final dir = Directory(dirPath);
-
- if (!await dir.exists()) {
- await dir.create(recursive: true);
- }
-
- return dir;
-}
-
-Future getRecordingsRootDirectory() async {
- if (kIsWeb) return null;
- if (Platform.isAndroid) {
- return getExternalStorageDirectory();
- }
- if (Platform.isIOS) {
- return getIOSDirectory();
- }
- return null;
-}
-
-Future> listRecordingDirectories() async {
- final recordingsDir = await getRecordingsRootDirectory();
- if (recordingsDir == null || !await recordingsDir.exists()) {
- return [];
- }
-
- final entities = recordingsDir.listSync();
- final recordings = entities
- .whereType()
- .where((entity) => entity.path.contains('OpenWearable_Recording'))
- .toList();
-
- recordings.sort((a, b) => b.statSync().changed.compareTo(a.statSync().changed));
- return recordings;
-}
-
-List listFilesInRecordingFolder(Directory folder) {
- try {
- return folder.listSync(recursive: false).whereType().toList()
- ..sort((a, b) => localRecorderBasename(a.path).compareTo(localRecorderBasename(b.path)));
- } catch (_) {
- return [];
- }
-}
-
-Future isDirectoryEmpty(String path) async {
- final dir = Directory(path);
- if (!await dir.exists()) return true;
- return dir.list(followLinks: false).isEmpty;
-}
-
-Future pickRecordingDirectory() async {
- if (kIsWeb) return null;
-
- if (Platform.isAndroid) {
- final recordingName =
- 'OpenWearable_Recording_${DateTime.now().toIso8601String()}';
- final appDir = await getExternalStorageDirectory();
- if (appDir == null) return null;
-
- final dirPath = '${appDir.path}/$recordingName';
- final dir = Directory(dirPath);
- if (!await dir.exists()) {
- await dir.create(recursive: true);
- }
- return dirPath;
- }
-
- if (Platform.isIOS) {
- final recordingName =
- 'OpenWearable_Recording_${DateTime.now().toIso8601String()}';
- final dirPath = '${(await getIOSDirectory()).path}/$recordingName';
- final dir = Directory(dirPath);
- if (!await dir.exists()) {
- await dir.create(recursive: true);
- }
- return dirPath;
- }
-
- return null;
-}
+export 'local_recorder_storage_io.dart'
+ if (dart.library.html) 'local_recorder_storage_web.dart';
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart
new file mode 100644
index 00000000..b755e76d
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart
@@ -0,0 +1,117 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:flutter/foundation.dart' show kIsWeb;
+import 'package:path_provider/path_provider.dart';
+
+import 'local_recorder_models.dart';
+
+/// Helper to get the base name of a file or directory across platforms.
+String localRecorderBasename(String path) => path.split(RegExp(r'[\\/]+')).last;
+
+/// Standardizes access to the recordings root for Apple platforms (iOS & macOS).
+Future _getAppleRecordingsDirectory() async {
+ final appDocDir = await getApplicationDocumentsDirectory();
+ final dirPath = '${appDocDir.path}/Recordings';
+ final dir = Directory(dirPath);
+
+ if (!await dir.exists()) {
+ await dir.create(recursive: true);
+ }
+
+ return dir;
+}
+
+Future _getRecordingsRootDirectory() async {
+ if (kIsWeb) return null;
+
+ if (Platform.isAndroid) {
+ return getExternalStorageDirectory();
+ }
+
+ if (Platform.isIOS || Platform.isMacOS) {
+ return _getAppleRecordingsDirectory();
+ }
+
+ return null;
+}
+
+Future pickRecordingDirectory() async {
+ if (kIsWeb) return null;
+
+ final recordingName =
+ 'OpenWearable_Recording_${DateTime.now().toIso8601String().replaceAll(':', '-')}';
+
+ final rootDir = await _getRecordingsRootDirectory();
+ if (rootDir == null) return null;
+
+ final dirPath = '${rootDir.path}/$recordingName';
+ final dir = Directory(dirPath);
+
+ if (!await dir.exists()) {
+ await dir.create(recursive: true);
+ }
+
+ return dirPath;
+}
+
+Future> listRecordingFolders() async {
+ final root = await _getRecordingsRootDirectory();
+ if (root == null || !await root.exists()) {
+ return [];
+ }
+
+ final folders = root
+ .listSync()
+ .whereType()
+ .where((entity) => entity.path.contains('OpenWearable_Recording'))
+ .map(_directoryToFolder)
+ .toList()
+ ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
+
+ return folders;
+}
+
+Future isRecordingDirectoryEmpty(String path) async {
+ final dir = Directory(path);
+ if (!await dir.exists()) return true;
+ return dir.list(followLinks: false).isEmpty;
+}
+
+Future deleteRecordingFolder(String path) async {
+ final dir = Directory(path);
+ if (await dir.exists()) {
+ await dir.delete(recursive: true);
+ }
+}
+
+Future readRecordingFileBytes(LocalRecorderRecordingFile file) {
+ return File(file.path).readAsBytes();
+}
+
+Future readRecordingFileText(LocalRecorderRecordingFile file) {
+ return File(file.path).readAsString();
+}
+
+LocalRecorderRecordingFolder _directoryToFolder(Directory directory) {
+ final files = directory
+ .listSync(recursive: false)
+ .whereType()
+ .map(
+ (file) => LocalRecorderRecordingFile(
+ path: file.path,
+ name: localRecorderBasename(file.path),
+ sizeBytes: file.lengthSync(),
+ updatedAt: file.statSync().modified,
+ ),
+ )
+ .toList()
+ ..sort((a, b) => a.name.compareTo(b.name));
+
+ return LocalRecorderRecordingFolder(
+ path: directory.path,
+ name: localRecorderBasename(directory.path),
+ updatedAt: directory.statSync().changed,
+ files: files,
+ );
+}
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart
new file mode 100644
index 00000000..30cd9e66
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart
@@ -0,0 +1,216 @@
+import 'dart:convert';
+import 'dart:typed_data';
+
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'local_recorder_models.dart';
+
+const String _storageKey = 'open_wearable.local_recorder.web_recordings.v1';
+
+Future pickRecordingDirectory() async {
+ final prefs = await SharedPreferences.getInstance();
+ final folder = _createFolder(name: _recordingFolderName());
+ final folders = _readFolders(prefs);
+ folders.removeWhere((entry) => entry.path == folder.path);
+ folders.add(folder);
+ await _writeFolders(prefs, folders);
+ return folder.path;
+}
+
+Future> listRecordingFolders() async {
+ final prefs = await SharedPreferences.getInstance();
+ final folders = _readFolders(prefs)
+ ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
+ return folders;
+}
+
+Future isRecordingDirectoryEmpty(String path) async {
+ final prefs = await SharedPreferences.getInstance();
+ final folders = _readFolders(prefs);
+ final match = folders.where((entry) => entry.path == path).toList();
+ return match.isEmpty || match.first.files.isEmpty;
+}
+
+Future deleteRecordingFolder(String path) async {
+ final prefs = await SharedPreferences.getInstance();
+ final folders = _readFolders(prefs)
+ ..removeWhere((entry) => entry.path == path);
+ await _writeFolders(prefs, folders);
+}
+
+Future persistRecordingFolderFiles(
+ String path,
+ List files,
+) async {
+ final prefs = await SharedPreferences.getInstance();
+ final folders = _readFolders(prefs);
+ final folderIndex = folders.indexWhere((entry) => entry.path == path);
+ final updatedFolder = _createFolder(
+ name: _recordingFolderName(),
+ path: path,
+ files: files,
+ );
+
+ if (folderIndex == -1) {
+ folders.add(updatedFolder);
+ } else {
+ folders[folderIndex] = updatedFolder;
+ }
+
+ await _writeFolders(prefs, folders);
+}
+
+Future readRecordingFileBytes(
+ LocalRecorderRecordingFile file,
+) async {
+ final prefs = await SharedPreferences.getInstance();
+ final folders = _readFolders(prefs);
+ for (final folder in folders) {
+ final selected = folder.files.where((entry) => entry.path == file.path);
+ if (selected.isNotEmpty) {
+ final recordingFile = selected.first;
+ if (recordingFile is _WebRecordingFile) {
+ return base64Decode(recordingFile.contentBase64);
+ }
+ break;
+ }
+ }
+ throw StateError('Recording file not found');
+}
+
+Future readRecordingFileText(LocalRecorderRecordingFile file) async {
+ return utf8.decode(await readRecordingFileBytes(file));
+}
+
+LocalRecorderRecordingFolder _createFolder({
+ required String name,
+ String? path,
+ List files = const [],
+}) {
+ final folderPath = path ?? 'web-${DateTime.now().microsecondsSinceEpoch}';
+ final now = DateTime.now();
+ final recordingFiles = files
+ .map(
+ (file) => _WebRecordingFile(
+ path: '$folderPath/${file.name}',
+ name: file.name,
+ sizeBytes: utf8.encode(file.content).length,
+ updatedAt: now,
+ mimeType: file.mimeType,
+ contentBase64: base64Encode(utf8.encode(file.content)),
+ ),
+ )
+ .toList();
+
+ return LocalRecorderRecordingFolder(
+ path: folderPath,
+ name: name,
+ updatedAt: now,
+ files: recordingFiles,
+ );
+}
+
+String _recordingFolderName() {
+ final timestamp = DateTime.now().toIso8601String();
+ return 'OpenWearable_Recording_$timestamp';
+}
+
+List _readFolders(SharedPreferences prefs) {
+ final raw = prefs.getString(_storageKey);
+ if (raw == null || raw.isEmpty) {
+ return [];
+ }
+
+ final decoded = jsonDecode(raw);
+ if (decoded is! Map) {
+ return [];
+ }
+
+ final folders = decoded['folders'];
+ if (folders is! List) {
+ return [];
+ }
+
+ return folders.whereType