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().map(_folderFromJson).toList(); +} + +Future _writeFolders( + SharedPreferences prefs, + List folders, +) async { + final payload = { + 'folders': folders.map(_folderToJson).toList(), + }; + await prefs.setString(_storageKey, jsonEncode(payload)); +} + +Map _folderToJson(LocalRecorderRecordingFolder folder) { + return { + 'path': folder.path, + 'name': folder.name, + 'updatedAt': folder.updatedAt.toIso8601String(), + 'files': folder.files.map(_fileToJson).toList(), + }; +} + +Map _fileToJson(LocalRecorderRecordingFile file) { + if (file is _WebRecordingFile) { + return { + 'path': file.path, + 'name': file.name, + 'sizeBytes': file.sizeBytes, + 'updatedAt': file.updatedAt.toIso8601String(), + 'mimeType': file.mimeType, + 'contentBase64': file.contentBase64, + }; + } + + return { + 'path': file.path, + 'name': file.name, + 'sizeBytes': file.sizeBytes, + 'updatedAt': file.updatedAt.toIso8601String(), + 'mimeType': file.mimeType, + 'contentBase64': '', + }; +} + +LocalRecorderRecordingFolder _folderFromJson(Map json) { + final files = (json['files'] as List? ?? const []) + .whereType() + .map(_fileFromJson) + .toList(); + + return LocalRecorderRecordingFolder( + path: json['path'] as String? ?? 'unknown', + name: json['name'] as String? ?? 'Recording', + updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + files: files, + ); +} + +LocalRecorderRecordingFile _fileFromJson(Map json) { + return _WebRecordingFile( + path: json['path'] as String? ?? 'unknown/file.csv', + name: json['name'] as String? ?? 'file.csv', + sizeBytes: (json['sizeBytes'] as num?)?.toInt() ?? 0, + updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + mimeType: json['mimeType'] as String? ?? 'text/csv', + contentBase64: json['contentBase64'] as String? ?? '', + ); +} + +class _WebRecordingFile extends LocalRecorderRecordingFile { + final String contentBase64; + + const _WebRecordingFile({ + required super.path, + required super.name, + required super.sizeBytes, + required super.updatedAt, + required this.contentBase64, + super.mimeType = 'text/csv', + }); +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index c6b22012..fe6e6644 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart @@ -1,16 +1,17 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:logger/logger.dart'; -import 'package:open_file/open_file.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart'; import 'package:provider/provider.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/sensors/local_recorder/local_recorder_empty_state_card.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_file_actions.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_files.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_recording_card.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_see_all_recordings_card.dart'; @@ -34,7 +35,7 @@ class LocalRecorderView extends StatefulWidget { } class _LocalRecorderViewState extends State { - List _recordings = []; + List _recordings = []; final Set _expandedFolders = {}; // Track which folders are expanded Timer? _recordingTimer; Duration _elapsedRecording = Duration.zero; @@ -69,21 +70,25 @@ class _LocalRecorderViewState extends State { /// Load the list of recording folders from the device's storage, filtering and sorting them appropriately. Future _listRecordings() async { - final recordings = await listRecordingDirectories(); + final recordings = await listRecordingFolders(); if (!mounted) return; setState(() { - _recordings = recordings.cast(); + _recordings = recordings; }); } - List _getFilesInFolder(Directory folder) { - return listFilesInRecordingFolder(folder); + List _getFilesInFolder( + LocalRecorderRecordingFolder folder, + ) { + return folder.files; } /// Show a confirmation dialog before deleting a recording folder or file, and handle the deletion if confirmed. - Future _confirmAndDeleteRecording(FileSystemEntity entity) async { + Future _confirmAndDeleteRecording( + LocalRecorderRecordingFolder entity, + ) async { if (!mounted) return false; - final name = localRecorderBasename(entity.path); + final name = entity.name; final shouldDelete = await showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( @@ -110,19 +115,13 @@ class _LocalRecorderViewState extends State { if (!shouldDelete) return false; - if (entity.existsSync()) { - try { - if (entity is Directory) { - entity.deleteSync(recursive: true); - } else { - entity.deleteSync(); - } - _listRecordings(); - } catch (e) { - _logger.e('Error deleting recording: $e'); - _showErrorDialog('Failed to delete recording: $e'); - return false; - } + try { + await deleteRecordingFolder(entity.path); + _listRecordings(); + } catch (e) { + _logger.e('Error deleting recording: $e'); + await _showErrorDialog('Failed to delete recording: $e'); + return false; } return true; } @@ -137,7 +136,7 @@ class _LocalRecorderViewState extends State { }); try { - recorder.stopRecording(); + recorder.stopRecording(mode == _StopRecordingMode.stopAndTurnOffSensors); if (mode == _StopRecordingMode.stopAndTurnOffSensors) { final wearablesProvider = context.read(); final futures = wearablesProvider.sensorConfigurationProviders.values @@ -229,21 +228,29 @@ class _LocalRecorderViewState extends State { } } - Future _shareFile(File file) async { + Future _shareFile(LocalRecorderRecordingFile file) async { try { await localRecorderShareFile(file); } catch (e) { _logger.e('Error sharing file: $e'); - await _showErrorDialog('Failed to share file: $e'); + if (!mounted) return; + await LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to share file: $e', + ); } } - Future _shareFolder(Directory folder) async { + Future _shareFolder(LocalRecorderRecordingFolder folder) async { try { await localRecorderShareFolder(folder); } catch (e) { _logger.e('Error sharing folder: $e'); - await _showErrorDialog('Failed to share folder: $e'); + if (!mounted) return; + await LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to share folder: $e', + ); } } @@ -254,7 +261,7 @@ class _LocalRecorderViewState extends State { return; } - if (!await isDirectoryEmpty(dir)) { + if (!await Files.isDirectoryEmpty(dir)) { if (!mounted) return; final proceed = await _askOverwriteConfirmation(context, dir); if (!proceed) return; @@ -269,11 +276,8 @@ class _LocalRecorderViewState extends State { } } - Future _openRecordingFile(File file) async { - final result = await localRecorderOpenRecordingFile(file); - if (result.type != ResultType.done) { - await _showErrorDialog('Could not open file: ${result.message}'); - } + Future _openRecordingFile(LocalRecorderRecordingFile file) async { + await localRecorderOpenRecordingFile(file); } Future _openAllRecordingsPage({required bool isRecording}) async { @@ -288,8 +292,7 @@ class _LocalRecorderViewState extends State { final isRecording = recorder.isRecording; final canStartRecording = recorder.hasSensorsConnected && !isRecording; final hasRecordings = _recordings.isNotEmpty; - final latestRecording = - hasRecordings ? _recordings.first as Directory : null; + final latestRecording = hasRecordings ? _recordings.first : null; return SafeArea( top: false, @@ -324,22 +327,41 @@ class _LocalRecorderViewState extends State { LocalRecorderRecordingFolderCard( folder: latestRecording, isCurrentRecording: isRecording, - isExpanded: _expandedFolders.contains( - latestRecording.path, - ), + isExpanded: _expandedFolders.contains(latestRecording.path), files: _expandedFolders.contains(latestRecording.path) ? _getFilesInFolder(latestRecording) - : const [], + : const [], updatedLabel: - 'Updated ${localRecorderFormatDateTime(latestRecording.statSync().changed)}', - onToggleExpanded: () { - setState(() { - if (_expandedFolders.contains(latestRecording.path)) { - _expandedFolders.remove(latestRecording.path); - } else { - _expandedFolders.add(latestRecording.path); + 'Updated ${localRecorderFormatDateTime(latestRecording.updatedAt)}', + onToggleExpanded: () async { + final path = latestRecording.path; + final isExpanding = !_expandedFolders.contains(path); + + if (isExpanding) { + // 1. Fetch the absolute latest data from SharedPreferences + final allFolders = await listRecordingFolders(); + + // 2. Find the version of this folder that actually has the files populated + final freshFolder = allFolders.firstWhere( + (f) => f.path == path, + orElse: () => latestRecording, + ); + + if (mounted) { + setState(() { + // 3. Update the files list in our current reference so the UI sees them + latestRecording.files.clear(); + latestRecording.files.addAll(freshFolder.files); + + // 4. Mark as expanded + _expandedFolders.add(path); + }); } - }); + } else { + setState(() { + _expandedFolders.remove(path); + }); + } }, onShareFolder: () => _shareFolder(latestRecording), onDeleteFolder: () async { diff --git a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart new file mode 100644 index 00000000..56f403de --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:logger/logger.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/sensors/local_recorder/local_recorder_dialogs.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_files.dart'; +import 'package:provider/provider.dart'; + +Logger _logger = Logger(); + +class RecordingControls extends StatefulWidget { + const RecordingControls({ + super.key, + required this.canStartRecording, + required this.isRecording, + required this.recorder, + required this.updateRecordingsList, + }); + + final bool canStartRecording; + final bool isRecording; + final SensorRecorderProvider recorder; + + final Future Function() updateRecordingsList; + + @override + State createState() => _RecordingControls(); +} + +class _RecordingControls extends State { + Duration _elapsedRecording = Duration.zero; + Timer? _recordingTimer; + bool _isHandlingStopAction = false; + bool _lastRecordingState = false; + SensorRecorderProvider? _recorder; + DateTime? _activeRecordingStart; + + String _formatDuration(Duration d) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final hours = twoDigits(d.inHours); + final minutes = twoDigits(d.inMinutes.remainder(60)); + final seconds = twoDigits(d.inSeconds.remainder(60)); + return '$hours:$minutes:$seconds'; + } + + Future _handleStopRecording( + SensorRecorderProvider recorder, { + required bool turnOffSensors, + }) async { + if (_isHandlingStopAction) return; + setState(() { + _isHandlingStopAction = true; + }); + + try { + recorder.stopRecording(turnOffSensors); + if (turnOffSensors) { + final wearablesProvider = context.read(); + final futures = wearablesProvider.sensorConfigurationProviders.values + .map((provider) => provider.turnOffAllSensors()); + await Future.wait(futures); + await recorder.stopBLEMicrophoneStream(); + } + await widget.updateRecordingsList(); + } catch (e) { + _logger.e('Error stopping recording: $e'); + if (!mounted) return; + await LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to stop recording: $e', + ); + } finally { + if (mounted) { + setState(() { + _isHandlingStopAction = false; + }); + } + } + } + + @override + void didUpdateWidget(covariant RecordingControls oldWidget) { + super.didUpdateWidget(oldWidget); + + // Start timer if parent says recording started + if (widget.isRecording && !oldWidget.isRecording) { + _startRecordingTimer(widget.recorder.recordingStart); + } + + // Stop timer if parent says recording stopped + if (!widget.isRecording && oldWidget.isRecording) { + _stopRecordingTimer(); + } + } + + @override + void dispose() { + _recordingTimer?.cancel(); + _recorder?.removeListener(_handleRecorderUpdate); + super.dispose(); + } + + void _handleRecorderUpdate() { + final recorder = _recorder; + if (recorder == null) return; + final isRecording = recorder.isRecording; + final start = recorder.recordingStart; + if (isRecording && !_lastRecordingState) { + _startRecordingTimer(start); + } else if (!isRecording && _lastRecordingState) { + _stopRecordingTimer(); + } else if (isRecording && + _lastRecordingState && + start != null && + _activeRecordingStart != null && + start != _activeRecordingStart) { + _startRecordingTimer(start); + } + _lastRecordingState = isRecording; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextRecorder = context.watch(); + if (!identical(_recorder, nextRecorder)) { + _recorder?.removeListener(_handleRecorderUpdate); + _recorder = nextRecorder; + _recorder?.addListener(_handleRecorderUpdate); + _handleRecorderUpdate(); + } + } + + void _startRecordingTimer(DateTime? start) { + final reference = start ?? DateTime.now(); + _activeRecordingStart = reference; + _recordingTimer?.cancel(); + setState(() { + _elapsedRecording = DateTime.now().difference(reference); + }); + _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() { + final base = _activeRecordingStart ?? reference; + _elapsedRecording = DateTime.now().difference(base); + }); + }); + } + + void _stopRecordingTimer() { + _recordingTimer?.cancel(); + _recordingTimer = null; + _activeRecordingStart = null; + if (!mounted) return; + setState(() { + _elapsedRecording = Duration.zero; + }); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: !widget.isRecording + ? ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + style: ElevatedButton.styleFrom( + backgroundColor: widget.canStartRecording + ? Colors.green.shade600 + : Colors.grey.shade400, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + label: const Text( + 'Start Recording', + style: TextStyle(fontSize: 18), + ), + onPressed: !widget.canStartRecording + ? null + : () async { + final dir = await Files.pickDirectory(); + if (dir == null) return; + + // Check if directory is empty + if (!await Files.isDirectoryEmpty(dir)) { + if (!context.mounted) return; + final proceed = + await LocalRecorderDialogs.askOverwriteConfirmation( + context, + dir, + ); + if (!proceed) return; + } + + widget.recorder.startRecording(dir); + await widget.updateRecordingsList(); // Refresh list + }, + ) + : Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.stop), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + label: const Text( + 'Stop Recording', + style: TextStyle(fontSize: 18), + ), + onPressed: _isHandlingStopAction + ? null + : () => _handleStopRecording( + widget.recorder, + turnOffSensors: false, + ), + ), + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 90, + ), + child: Text( + _formatDuration(_elapsedRecording), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ElevatedButton.icon( + icon: const Icon(Icons.power_settings_new), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[800], + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + label: const Text( + 'Stop & Turn Off Sensors', + style: TextStyle(fontSize: 18), + ), + onPressed: _isHandlingStopAction + ? null + : () => _handleStopRecording( + widget.recorder, + turnOffSensors: true, + ), + ), + ], + ), + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index f847afa7..6db4854c 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -1,9 +1,13 @@ +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'; import 'package:open_wearable/models/app_shutdown_settings.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_data_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/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; @@ -30,9 +34,42 @@ class _SensorValuesPageState extends State bool get _ownsProviders => widget.sharedProviders == null; + String? _errorMessage; + + bool _isInitializing = true; + @override bool get wantKeepAlive => true; + @override + void initState() { + super.initState(); + if (!kIsWeb && Platform.isAndroid) { + _checkStreamingStatus(); + } + } + + void _checkStreamingStatus() { + final recorderProvider = + Provider.of(context, listen: false); + if (!recorderProvider.isBLEMicrophoneStreamingEnabled) { + if (mounted) { + setState(() { + _isInitializing = false; + _errorMessage = + 'BLE microphone streaming not enabled. Enable it in sensor configuration.'; + }); + } + } else { + if (mounted) { + setState(() { + _isInitializing = false; + _errorMessage = null; + }); + } + } + } + @override void dispose() { if (_ownsProviders) { @@ -56,8 +93,8 @@ class _SensorValuesPageState extends State builder: (context, hideCardsWithoutLiveData, __) { final shouldHideCardsWithoutLiveData = hideCardsWithoutLiveData && !disableLiveDataGraphs; - return Consumer( - builder: (context, wearablesProvider, child) { + return Consumer2( + builder: (context, wearablesProvider, recorderProvider, child) { return FutureBuilder>( future: buildWearableDisplayGroups( wearablesProvider.wearables, @@ -97,6 +134,7 @@ class _SensorValuesPageState extends State return _buildSmallScreenLayout( context, charts, + recorderProvider, hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, @@ -105,6 +143,7 @@ class _SensorValuesPageState extends State return _buildLargeScreenLayout( context, charts, + recorderProvider, hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, @@ -262,60 +301,139 @@ class _SensorValuesPageState extends State return ordered; } + Widget _buildAudioUI(SensorRecorderProvider recorderProvider) { + // If initializing, show a loading card + if (!kIsWeb && _isInitializing && Platform.isAndroid) { + return Card( + child: Container( + height: 100, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ), + ); + } + + return Column( + children: [ + if (recorderProvider.isBLEMicrophoneStreamingEnabled) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.fiber_manual_record, + color: Colors.red, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'AUDIO WAVEFORM ${recorderProvider.isRecording ? "(RECORDING)" : ""}', + style: Theme.of(context).textTheme.labelLarge, + ), + ], + ), + const SizedBox(height: 8), + CustomPaint( + size: const Size(double.infinity, 100), + painter: WaveformPainter( + recorderProvider.waveformData, + sampleRevision: recorderProvider.waveformRevision, + ), + ), + ], + ), + ), + ) + else if (_errorMessage != null) + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.red), + const SizedBox(width: 12), + Expanded( + child: PlatformText( + _errorMessage!, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 10), + ], + ); + } + Widget _buildSmallScreenLayout( BuildContext context, - List charts, { + List charts, + SensorRecorderProvider recorderProvider, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { - if (charts.isEmpty) { - final emptyState = _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData, - ); - return Padding( - padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500), - child: _buildEmptyStateCard(context, emptyState), - ), - ), - ); - } - return ListView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - children: charts, + children: [ + _buildAudioUI(recorderProvider), + ...charts, + if (charts.isEmpty) + Center( + child: _buildEmptyStateCard( + context, + _resolveEmptyState( + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData, + ), + ), + ), + ], ); } Widget _buildLargeScreenLayout( BuildContext context, - List charts, { + List charts, + SensorRecorderProvider recorderProvider, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { - final emptyState = _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData, - ); - - return GridView.builder( + return SingleChildScrollView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 500, - childAspectRatio: 1.5, - crossAxisSpacing: SensorPageSpacing.gridGap, - mainAxisSpacing: SensorPageSpacing.gridGap, + child: Column( + children: [ + _buildAudioUI(recorderProvider), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 500, + childAspectRatio: 1.5, + crossAxisSpacing: SensorPageSpacing.gridGap, + mainAxisSpacing: SensorPageSpacing.gridGap, + ), + itemCount: charts.isEmpty ? 1 : charts.length, + itemBuilder: (context, index) { + if (charts.isEmpty) { + return _buildEmptyStateCard( + context, + _resolveEmptyState( + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData, + ), + ); + } + return charts[index]; + }, + ), + ], ), - itemCount: charts.isEmpty ? 1 : charts.length, - itemBuilder: (context, index) { - if (charts.isEmpty) { - return _buildEmptyStateCard(context, emptyState); - } - return charts[index]; - }, ); } @@ -435,3 +553,86 @@ class _SensorValuesEmptyState { this.removeCardBackground = false, }); } + +/// Paints the live audio amplitude window as a horizontally scrolling waveform. +class WaveformPainter extends CustomPainter { + final List waveformData; + final int sampleRevision; + final Color waveColor; + final double spacing; + final double waveThickness; + final bool showMiddleLine; + + WaveformPainter( + this.waveformData, { + required this.sampleRevision, + this.waveColor = Colors.blue, + this.spacing = 4.0, + this.waveThickness = 3.0, + this.showMiddleLine = true, + }); + + @override + void paint(Canvas canvas, Size size) { + if (waveformData.isEmpty) return; + + final double height = size.height; + final double centerY = height / 2; + + // Draw middle line first (behind the bars) + if (showMiddleLine) { + final centerLinePaint = Paint() + ..color = Colors.grey.withAlpha(75) + ..strokeWidth = 1.0; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + centerLinePaint, + ); + } + + // Paint for the vertical bars + final paint = Paint() + ..color = waveColor + ..strokeWidth = waveThickness + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + // Calculate how many bars can fit in the available width + final maxBars = (size.width / spacing).floor(); + final startIndex = + waveformData.length > maxBars ? waveformData.length - maxBars : 0; + + // Calculate starting position (always start at 0 or align right) + final visibleData = waveformData.sublist(startIndex); + final totalWaveformWidth = visibleData.length * spacing; + final startX = size.width - totalWaveformWidth; + + // Draw each amplitude value as a vertical bar + for (int i = 0; i < visibleData.length; i++) { + final x = startX + (i * spacing); + final amplitude = visibleData[i]; + + // Scale amplitude to fit within the canvas height + final barHeight = amplitude * centerY * 0.8; + + // Draw top half of the bar (above center line) + final topY = centerY - barHeight; + final bottomY = centerY + barHeight; + + // Draw the vertical line from top to bottom + canvas.drawLine( + Offset(x, topY), + Offset(x, bottomY), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant WaveformPainter oldDelegate) { + return oldDelegate.sampleRevision != sampleRevision || + oldDelegate.waveformData.length != waveformData.length || + oldDelegate.waveColor != waveColor; + } +} diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc index f547c379..b180d86f 100644 --- a/open_wearable/linux/flutter/generated_plugin_registrant.cc +++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index 6462693b..dcc28ba2 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux open_file_linux + record_linux url_launcher_linux ) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 57be32d1..a608bccb 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,6 +13,7 @@ import flutter_archive import mcumgr_flutter import open_file_mac import package_info_plus +import record_macos import share_plus import shared_preferences_foundation import universal_ble @@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { McumgrFlutterPlugin.register(with: registry.registrar(forPlugin: "McumgrFlutterPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index fb9cafb7..3929fb39 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -358,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + flutter_headset_detector: + dependency: "direct main" + description: + name: flutter_headset_detector + sha256: fe061eceb106a61b837ae58eda1575604db24299a4ebe13e34839dd3d30085df + url: "https://pub.dev" + source: hosted + version: "3.1.0" flutter_lints: dependency: "direct dev" description: @@ -761,7 +769,7 @@ packages: source: hosted version: "2.3.0" permission_handler: - dependency: transitive + dependency: "direct main" description: name: permission_handler sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 @@ -824,6 +832,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.6" + playback_capture: + dependency: "direct main" + description: + name: playback_capture + sha256: b2766b8741c00b51e0140660e9503d493a38cf5c9b0b9c5127c1d61f07a8e5e3 + url: "https://pub.dev" + source: hosted + version: "0.0.4" plugin_platform_interface: dependency: transitive description: @@ -864,6 +880,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" record_use: dependency: transitive description: @@ -872,6 +936,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" rxdart: dependency: transitive description: @@ -1009,10 +1089,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 url: "https://pub.dev" source: hosted - version: "3.4.0+1" + version: "3.4.0" term_glyph: dependency: transitive description: @@ -1182,7 +1262,7 @@ packages: source: hosted version: "1.5.1" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 79bd286b..381ca02b 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -62,6 +62,11 @@ dependencies: sensors_plus: ^7.0.0 device_info_plus: ^12.3.0 pub_semver: ^2.2.0 + playback_capture: ^0.0.4 + flutter_headset_detector: ^3.1.0 + record: ^6.1.2 + permission_handler: ^12.0.1 + web: ^1.1.1 dev_dependencies: flutter_test: diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc index 37245d29..0eaaf699 100644 --- a/open_wearable/windows/flutter/generated_plugin_registrant.cc +++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UniversalBlePluginCApiRegisterWithRegistrar( diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index ed5b9c00..3689918f 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows permission_handler_windows + record_windows share_plus universal_ble url_launcher_windows