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 110052ae..eddf6e53 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -55,6 +55,8 @@ PODS: - Flutter - permission_handler_apple (9.3.0): - Flutter + - record_ios (1.2.0): + - Flutter - SDWebImage (5.21.5): - SDWebImage/Core (= 5.21.5) - SDWebImage/Core (5.21.5) @@ -85,6 +87,7 @@ DEPENDENCIES: - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - universal_ble (from `.symlinks/plugins/universal_ble/darwin`) @@ -121,6 +124,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -145,6 +150,7 @@ SPEC CHECKSUMS: open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb diff --git a/open_wearable/lib/apps/widgets/apps_page.dart b/open_wearable/lib/apps/widgets/apps_page.dart index 936ddd6f..5190f309 100644 --- a/open_wearable/lib/apps/widgets/apps_page.dart +++ b/open_wearable/lib/apps/widgets/apps_page.dart @@ -97,7 +97,8 @@ final List _apps = [ wearable.requireCapability(), sensorConfigProvider, wearable.hasCapability() && - await wearable.requireCapability().position == DevicePosition.left, + await wearable.requireCapability().position == + DevicePosition.left, ), ); }, diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index e15d5580..b1fac3ff 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -3,6 +3,8 @@ 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'; @@ -28,17 +30,162 @@ class SensorRecorderProvider with ChangeNotifier { 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; - Future startRecording(String dirname) async { - if (_isRecording) { + final List _waveformData = []; + 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 (!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"); + } + } + + void startRecording(String dirname) async { + _isRecording = true; _currentDirectory = dirname; _recordingStart = DateTime.now(); @@ -57,12 +204,136 @@ class SensorRecorderProvider with ChangeNotifier { notifyListeners(); rethrow; } + + await _startAudioRecording( + dirname, + ); + + notifyListeners(); + } + + Future _startAudioRecording(String recordingFolderPath) async { + if (!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() { + void stopRecording(bool turnOffMic) async { _isRecording = false; _recordingStart = null; - _stopAllRecorderStreams(); + for (Wearable wearable in _recorders.keys) { + for (Sensor sensor in _recorders[wearable]!.keys) { + Recorder? recorder = _recorders[wearable]?[sensor]; + if (recorder != null) { + recorder.stop(); + logger.i( + 'Stopped recording for ${wearable.name} - ${sensor.sensorName}', + ); + } + } + } + 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(); } @@ -195,10 +466,24 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { - for (final wearable in _recorders.keys.toList()) { + // 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) { _disposeWearable(wearable); } _recorders.clear(); + super.dispose(); } } 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..16c0bb5e --- /dev/null +++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart @@ -0,0 +1,54 @@ +import 'dart:io'; +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.dart'; + +class BLEMicrophoneStreamingRow extends StatelessWidget { + const BLEMicrophoneStreamingRow({super.key}); + + @override + Widget build(BuildContext context) { + if (!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..a197d6f0 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 'dart:io'; 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,17 @@ class SensorConfigurationDetailView extends StatelessWidget { final targetOptions = sensorConfiguration is ConfigurableSensorConfiguration ? (sensorConfiguration as ConfigurableSensorConfiguration) .availableOptions - .toList(growable: false) + .where((option) { + // Only allow microphone stream on Android + if (Platform.isAndroid) return true; + final isMicrophone = + sensorConfiguration.name.toLowerCase().contains('microphone'); + final isStreamOption = option is StreamSensorConfigOption; + if (isMicrophone && isStreamOption) { + return false; + } + return true; + }).toList(growable: false) : const []; return ListView( @@ -350,9 +361,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_value_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart index 1ed81ef6..860be9a4 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart' show setEquals; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; 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..dd8a3b21 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -1,9 +1,11 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; 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.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'; @@ -319,6 +321,30 @@ class SensorConfigurationView extends StatelessWidget { icon: Icons.check_circle_outline_rounded, ); + final recorderProvider = Provider.of( + context, + listen: false, + ); + bool shouldEnableMicrophoneStreaming = Platform.isAndroid && + targets.any((target) { + return target.provider.getSelectedConfigurations().any((entry) { + final config = entry.$1; + final selectedOptions = + target.provider.getSelectedConfigurationOptions(config); + return config is ConfigurableSensorConfiguration && + config.name.toLowerCase().contains('microphone') && + selectedOptions.any((opt) => opt is StreamSensorConfigOption); + }); + }); + + if (shouldEnableMicrophoneStreaming && + !recorderProvider.isBLEMicrophoneStreamingEnabled) { + await recorderProvider.startBLEMicrophoneStream(); + } else if (!shouldEnableMicrophoneStreaming && + recorderProvider.isBLEMicrophoneStreamingEnabled) { + await recorderProvider.stopBLEMicrophoneStream(); + } + (onSetConfigPressed ?? () {})(); } 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_files.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart new file mode 100644 index 00000000..db4b5bf4 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +class Files { + static Future pickDirectory() async { + if (!Platform.isIOS && !kIsWeb) { + final recordingName = + 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; + Directory? appDir = await getExternalStorageDirectory(); + if (appDir == null) return null; + + String dirPath = '${appDir.path}/$recordingName'; + Directory dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + + if (Platform.isIOS) { + final recordingName = + 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; + String dirPath = '${(await getIOSDirectory()).path}/$recordingName'; + Directory dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + + return null; + } + + static Future getIOSDirectory() async { + Directory appDocDir = await getApplicationDocumentsDirectory(); + final dirPath = '${appDocDir.path}/Recordings'; + final dir = Directory(dirPath); + + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + return dir; + } + + static Future isDirectoryEmpty(String path) async { + final dir = Directory(path); + if (!await dir.exists()) return true; + return await dir.list(followLinks: false).isEmpty; + } +} 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 0a7c6846..4d9fd741 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 @@ -5,6 +5,10 @@ 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_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/recording_controls.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; import 'package:open_file/open_file.dart'; import 'package:provider/provider.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; @@ -16,6 +20,7 @@ import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_reco import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_see_all_recordings_card.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_storage.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; +import 'local_recorder_files.dart'; Logger _logger = Logger(); @@ -120,8 +125,11 @@ class _LocalRecorderViewState extends State { _listRecordings(); } catch (e) { _logger.e('Error deleting recording: $e'); - _showErrorDialog('Failed to delete recording: $e'); - return false; + if (!mounted) return false; + LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to delete recording: $e', + ); } } return true; @@ -137,8 +145,9 @@ class _LocalRecorderViewState extends State { }); try { - recorder.stopRecording(); - if (mode == _StopRecordingMode.stopAndTurnOffSensors) { + bool turnOffSensors = mode == _StopRecordingMode.stopAndTurnOffSensors; + recorder.stopRecording(turnOffSensors); + if (turnOffSensors) { final wearablesProvider = context.read(); final futures = wearablesProvider.sensorConfigurationProviders.values .map((provider) => provider.turnOffAllSensors()); @@ -157,32 +166,6 @@ class _LocalRecorderViewState extends State { } } - 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; - }); - } - String _formatDuration(Duration duration) { String twoDigits(int n) => n.toString().padLeft(2, '0'); final hours = twoDigits(duration.inHours); @@ -191,50 +174,16 @@ class _LocalRecorderViewState extends State { return '$hours:$minutes:$seconds'; } - @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(); - } - } - Future _shareFile(File 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', + ); } } @@ -243,7 +192,11 @@ class _LocalRecorderViewState extends State { 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', + ); } } @@ -261,7 +214,7 @@ class _LocalRecorderViewState extends State { } try { - await recorder.startRecording(dir); + recorder.startRecording(dir); await _listRecordings(); } catch (e) { _logger.e('Error starting recording: $e'); 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..b33d89c8 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -0,0 +1,269 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.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(); + +enum _StopRecordingMode { + stopOnly, + stopAndTurnOffSensors, +} + +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 _StopRecordingMode mode, + }) async { + if (_isHandlingStopAction) return; + setState(() { + _isHandlingStopAction = true; + }); + + try { + bool turnOffSensors = (mode == _StopRecordingMode.stopAndTurnOffSensors); + 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, + mode: _StopRecordingMode.stopOnly, + ), + ), + ), + 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, + mode: _StopRecordingMode.stopAndTurnOffSensors, + ), + ), + ], + ), + ); + } +} 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..6d38f0e9 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -4,10 +4,12 @@ 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.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'; import 'package:provider/provider.dart'; +import 'dart:io'; class SensorValuesPage extends StatefulWidget { final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders; @@ -30,9 +32,41 @@ 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 (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 +90,28 @@ class _SensorValuesPageState extends State builder: (context, hideCardsWithoutLiveData, __) { final shouldHideCardsWithoutLiveData = hideCardsWithoutLiveData && !disableLiveDataGraphs; - return Consumer( - builder: (context, wearablesProvider, child) { + return Consumer2( + builder: (context, wearablesProvider, recorderProvider, child) { + if (Platform.isAndroid && mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!recorderProvider.isBLEMicrophoneStreamingEnabled && + _errorMessage == null && + !recorderProvider.isRecording) { + setState(() { + _errorMessage = + 'BLE microphone streaming not enabled. Enable it in sensor configuration.'; + }); + } else if (recorderProvider + .isBLEMicrophoneStreamingEnabled && + _errorMessage != null && + _errorMessage! + .contains('BLE microphone streaming not enabled')) { + setState(() { + _errorMessage = null; + }); + } + }); + } return FutureBuilder>( future: buildWearableDisplayGroups( wearablesProvider.wearables, @@ -100,6 +154,7 @@ class _SensorValuesPageState extends State hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, + recorderProvider: recorderProvider, ); } else { return _buildLargeScreenLayout( @@ -108,6 +163,7 @@ class _SensorValuesPageState extends State hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, + recorderProvider: recorderProvider, ); } }, @@ -267,6 +323,7 @@ class _SensorValuesPageState extends State List charts, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, + required SensorRecorderProvider recorderProvider, }) { if (charts.isEmpty) { final emptyState = _resolveEmptyState( @@ -284,9 +341,74 @@ class _SensorValuesPageState extends State ); } - return ListView( + final showRecorderWaveform = recorderProvider.isRecording; + + return Padding( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - children: charts, + child: ListView( + children: [ + // Android microphone waveform and error + if (Platform.isAndroid) ...[ + // Waveform card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + showRecorderWaveform + ? Icons.fiber_manual_record + : Icons.mic, + color: + showRecorderWaveform ? Colors.red : Colors.blue, + size: 16, + ), + const SizedBox(width: 8), + Text( + showRecorderWaveform + ? 'AUDIO WAVEFORM (Recording)' + : 'AUDIO WAVEFORM', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 100, + child: _isInitializing + ? const Center(child: CircularProgressIndicator()) + : CustomPaint( + painter: WaveformPainter( + recorderProvider.waveformData), + ), + ), + ], + ), + ), + ), + // Error message + if (_errorMessage != null) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + _errorMessage!, + style: + TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ), + const SizedBox(height: 8), + ], + ], + // Sensor charts + ...charts, + ], + ), ); } @@ -295,27 +417,98 @@ class _SensorValuesPageState extends State List charts, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, + required SensorRecorderProvider recorderProvider, }) { final emptyState = _resolveEmptyState( hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: hideCardsWithoutLiveData, ); + final showRecorderWaveform = recorderProvider.isRecording; - return GridView.builder( + return Padding( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 500, - childAspectRatio: 1.5, - crossAxisSpacing: SensorPageSpacing.gridGap, - mainAxisSpacing: SensorPageSpacing.gridGap, + child: SingleChildScrollView( + child: Column( + children: [ + // Android microphone waveform and error (top priority) + if (Platform.isAndroid) ...[ + // Waveform card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + showRecorderWaveform + ? Icons.fiber_manual_record + : Icons.mic, + color: + showRecorderWaveform ? Colors.red : Colors.blue, + size: 16, + ), + const SizedBox(width: 8), + Text( + showRecorderWaveform + ? 'Audio waveform (Recording)' + : 'Audio waveform', + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 80, + child: _isInitializing + ? const Center(child: CircularProgressIndicator()) + : CustomPaint( + painter: WaveformPainter( + recorderProvider.waveformData), + ), + ), + ], + ), + ), + ), + if (_errorMessage != null) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + _errorMessage!, + style: + TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ), + const SizedBox(height: 8), + ], + ], + const SizedBox(height: 16), + // Sensor charts grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const 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, emptyState); + } + return charts[index]; + }, + ), + ], + ), ), - itemCount: charts.isEmpty ? 1 : charts.length, - itemBuilder: (context, index) { - if (charts.isEmpty) { - return _buildEmptyStateCard(context, emptyState); - } - return charts[index]; - }, ); } @@ -435,3 +628,83 @@ class _SensorValuesEmptyState { this.removeCardBackground = false, }); } + +// Custom waveform painter with vertical bars +class WaveformPainter extends CustomPainter { + final List waveformData; + final Color waveColor; + final double spacing; + final double waveThickness; + final bool showMiddleLine; + + WaveformPainter( + this.waveformData, { + 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.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 18a2dcb9..dcc28ba2 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux open_file_linux + record_linux url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 696f95be..2d7ed568 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import file_selector_macos import flutter_archive import open_file_mac import package_info_plus +import record_macos import share_plus import shared_preferences_foundation import universal_ble @@ -24,6 +25,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) 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 6dab90f4..c31d2d66 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -440,6 +440,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" json_annotation: dependency: transitive description: @@ -500,10 +516,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -632,6 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.3" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: transitive description: @@ -676,10 +700,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.23" + version: "2.3.1" path_provider_foundation: dependency: transitive description: @@ -808,6 +832,70 @@ 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_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: @@ -945,10 +1033,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.8" tuple: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 66d61c6e..c6a4e942 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: url_launcher: ^6.3.2 go_router: ^14.6.2 http: ^1.6.0 + record: ^6.1.2 audioplayers: ^6.6.0 wakelock_plus: ^1.4.0 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 154f7830..3689918f 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -6,12 +6,14 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows permission_handler_windows + record_windows share_plus universal_ble url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)