From 5ac65c50ca1264378a173c65a0a96d1037fc9e96 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Tue, 6 Jan 2026 14:43:09 +0100 Subject: [PATCH 1/8] refactor recorder: extract controls into separate widget --- .../audio_recorder/model/audio_recorder.dart | 82 +++++ .../view/audio_recorder_view.dart | 32 ++ open_wearable/lib/apps/widgets/apps_page.dart | 39 +- .../local_recorder_dialogs.dart | 50 +++ .../local_recorder/local_recorder_files.dart | 53 +++ .../local_recorder/local_recorder_view.dart | 344 ++---------------- .../local_recorder/recording_controls.dart | 262 +++++++++++++ open_wearable/pubspec.lock | 24 +- open_wearable/pubspec.yaml | 1 + 9 files changed, 552 insertions(+), 335 deletions(-) create mode 100644 open_wearable/lib/apps/audio_recorder/model/audio_recorder.dart create mode 100644 open_wearable/lib/apps/audio_recorder/view/audio_recorder_view.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart diff --git a/open_wearable/lib/apps/audio_recorder/model/audio_recorder.dart b/open_wearable/lib/apps/audio_recorder/model/audio_recorder.dart new file mode 100644 index 00000000..3084290b --- /dev/null +++ b/open_wearable/lib/apps/audio_recorder/model/audio_recorder.dart @@ -0,0 +1,82 @@ +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:playback_capture/data/playback_capture_result.dart'; +import 'package:playback_capture/playback_capture.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class AudioRecorder extends ChangeNotifier { + final PlaybackCapture _playbackCapture = PlaybackCapture(); + + bool _isRecording = false; + String _errorMessage = ''; + List _audioBuffers = []; + + bool get isRecording => _isRecording; + String get errorMessage => _errorMessage; + List get audioBuffers => _audioBuffers; + + /// Start capturing system audio + Future startRecording() async { + _errorMessage = ''; + notifyListeners(); + + final PlaybackCaptureResult result = await _playbackCapture.listenAudio( + audioDataCallback: (Uint8List data) { + _audioBuffers.add(data); + notifyListeners(); + }, + ); + + if (result != PlaybackCaptureResult.recording) { + if (result == PlaybackCaptureResult.missingAudioRecordPermission) { + // Request microphone permission + final status = await Permission.microphone.request(); + if (status.isGranted) { + // Retry after permission granted + return await startRecording(); + } else { + _errorMessage = 'Microphone permission denied'; + notifyListeners(); + return false; + } + } else if (result == PlaybackCaptureResult.recordRequestDenied) { + _errorMessage = 'Audio capture request denied by user'; + notifyListeners(); + return false; + } else { + _errorMessage = 'Failed to start recording: $result'; + notifyListeners(); + return false; + } + } + + // Recording successfully started + _isRecording = true; + notifyListeners(); + return true; + } + + /// Stop capturing audio + Future stopRecording() async { + //await _playbackCapture.stopCapture(); + _isRecording = false; + notifyListeners(); + } + + /// Clear recorded audio buffers + void clearBuffers() { + _audioBuffers.clear(); + notifyListeners(); + } + + /// Get total recorded data size in bytes + int get totalRecordedBytes { + return _audioBuffers.fold(0, (sum, buffer) => sum + buffer.length); + } + + @override + void dispose() { + //_playbackCapture.stopCapture(); + super.dispose(); + } +} diff --git a/open_wearable/lib/apps/audio_recorder/view/audio_recorder_view.dart b/open_wearable/lib/apps/audio_recorder/view/audio_recorder_view.dart new file mode 100644 index 00000000..72f4b834 --- /dev/null +++ b/open_wearable/lib/apps/audio_recorder/view/audio_recorder_view.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:playback_capture/playback_capture.dart'; + +class AudioRecorderView extends StatefulWidget { + const AudioRecorderView({super.key}); + + @override + State createState() => _AudioRecorderViewState(); +} + +class _AudioRecorderViewState extends State { + bool _isRecording = false; + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: PlatformAppBar( + title: PlatformText("Audio Recorder"), + ), + body: Center( + child: PlatformElevatedButton( + child: + PlatformText(_isRecording ? "Stop Recording" : "Start Recording"), + onPressed: () => (), //_toggleRecording, + ), + ), + ); + } + + // Recording logic here... +} diff --git a/open_wearable/lib/apps/widgets/apps_page.dart b/open_wearable/lib/apps/widgets/apps_page.dart index b54f388f..f442d1c8 100644 --- a/open_wearable/lib/apps/widgets/apps_page.dart +++ b/open_wearable/lib/apps/widgets/apps_page.dart @@ -1,15 +1,17 @@ +import 'dart:io'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; 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/apps/audio_recorder/view/audio_recorder_view.dart'; import 'package:open_wearable/apps/heart_tracker/widgets/heart_tracker_page.dart'; import 'package:open_wearable/apps/posture_tracker/model/earable_attitude_tracker.dart'; import 'package:open_wearable/apps/posture_tracker/view/posture_tracker_view.dart'; import 'package:open_wearable/apps/widgets/select_earable_view.dart'; import 'package:open_wearable/apps/widgets/app_tile.dart'; - class AppInfo { final String logoPath; final String title; @@ -29,15 +31,17 @@ List _apps = [ logoPath: "lib/apps/posture_tracker/assets/logo.png", title: "Posture Tracker", description: "Get feedback on bad posture", - widget: SelectEarableView(startApp: (wearable, sensorConfigProvider) { - return PostureTrackerView( - EarableAttitudeTracker( - wearable as SensorManager, - sensorConfigProvider, - wearable.name.endsWith("L"), - ), - ); - },), + widget: SelectEarableView( + startApp: (wearable, sensorConfigProvider) { + return PostureTrackerView( + EarableAttitudeTracker( + wearable as SensorManager, + sensorConfigProvider, + wearable.name.endsWith("L"), + ), + ); + }, + ), ), AppInfo( logoPath: "lib/apps/heart_tracker/assets/logo.png", @@ -48,8 +52,10 @@ List _apps = [ if (wearable is SensorManager) { //TODO: show alert if no ppg sensor is found Sensor ppgSensor = (wearable as SensorManager).sensors.firstWhere( - (s) => s.sensorName.toLowerCase() == "photoplethysmography".toLowerCase(), - ); + (s) => + s.sensorName.toLowerCase() == + "photoplethysmography".toLowerCase(), + ); return HeartTrackerPage(ppgSensor: ppgSensor); } @@ -64,6 +70,13 @@ List _apps = [ }, ), ), + if (Platform.isAndroid) + AppInfo( + logoPath: "lib/apps/audio_recorder/assets/logo.png", + title: "Audio Recorder", + description: "Record system audio and Bluetooth streams", + widget: const AudioRecorderView(), // Your audio recorder page + ), ]; class AppsPage extends StatelessWidget { @@ -75,7 +88,7 @@ class AppsPage extends StatelessWidget { appBar: PlatformAppBar( title: PlatformText("Apps"), trailingActions: [ - PlatformIconButton( + PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { context.push('/connect-devices'); 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 8af6147c..596b3be5 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,18 +1,19 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.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:share_plus/share_plus.dart'; import 'package:flutter_archive/flutter_archive.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; -import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'local_recorder_files.dart'; Logger _logger = Logger(); @@ -27,12 +28,6 @@ class _LocalRecorderViewState extends State { static const MethodChannel platform = MethodChannel('edu.teco.open_folder'); List _recordings = []; final Set _expandedFolders = {}; // Track which folders are expanded - Timer? _recordingTimer; - Duration _elapsedRecording = Duration.zero; - bool _lastRecordingState = false; - bool _isHandlingStopAction = false; - DateTime? _activeRecordingStart; - SensorRecorderProvider? _recorder; @override void initState() { @@ -40,24 +35,6 @@ class _LocalRecorderViewState extends State { _listRecordings(); } - /// Helper to show cross-platform error dialogs instead of SnackBars - Future _showErrorDialog(String message) async { - if (!mounted) return; - await showPlatformDialog( - context: context, - builder: (_) => PlatformAlertDialog( - title: PlatformText('Error'), - content: PlatformText(message), - actions: [ - PlatformDialogAction( - child: PlatformText('OK'), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ); - } - Future _openFolder(String path) async { try { if (Platform.isIOS) { @@ -80,7 +57,7 @@ class _LocalRecorderViewState extends State { if (dir == null) return; recordingsDir = dir; } else if (Platform.isIOS) { - recordingsDir = await getIOSDirectory(); + recordingsDir = await Files.getIOSDirectory(); } else { return; } @@ -162,113 +139,15 @@ class _LocalRecorderViewState extends State { _listRecordings(); } catch (e) { _logger.e('Error deleting recording: $e'); - _showErrorDialog('Failed to delete recording: $e'); - } - } - } - - Future _handleStopRecording( - SensorRecorderProvider recorder, { - required bool turnOffSensors, - }) async { - if (_isHandlingStopAction) return; - setState(() { - _isHandlingStopAction = true; - }); - - try { - recorder.stopRecording(); - if (turnOffSensors) { - final wearablesProvider = context.read(); - final futures = wearablesProvider.sensorConfigurationProviders.values - .map((provider) => provider.turnOffAllSensors()); - await Future.wait(futures); - } - await _listRecordings(); - } catch (e) { - _logger.e('Error stopping recording: $e'); - await _showErrorDialog('Failed to stop recording: $e'); - } finally { - if (mounted) { - setState(() { - _isHandlingStopAction = false; - }); + if (!mounted) return; + LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to delete recording: $e', + ); } } } - 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); - final minutes = twoDigits(duration.inMinutes.remainder(60)); - final seconds = twoDigits(duration.inSeconds.remainder(60)); - 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(); - } - } - String _formatFileSize(File file) { int bytes = file.lengthSync(); if (bytes < 1024) return '$bytes B'; @@ -290,7 +169,11 @@ class _LocalRecorderViewState extends State { } } 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', + ); } } @@ -325,7 +208,11 @@ class _LocalRecorderViewState extends State { } } 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', + ); } } @@ -356,108 +243,11 @@ class _LocalRecorderViewState extends State { "Only records sensor data streamed over Bluetooth.", ), const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: !isRecording - ? ElevatedButton.icon( - icon: const Icon(Icons.play_arrow), - style: ElevatedButton.styleFrom( - backgroundColor: 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: !canStartRecording - ? null - : () async { - final dir = await _pickDirectory(); - if (dir == null) return; - - // Check if directory is empty - if (!await _isDirectoryEmpty(dir)) { - if (!context.mounted) return; - final proceed = - await _askOverwriteConfirmation( - context, - dir, - ); - if (!proceed) return; - } - - recorder.startRecording(dir); - await _listRecordings(); // 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( - 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( - recorder, - turnOffSensors: true, - ), - ), - ], - ), + RecordingControls( + canStartRecording: canStartRecording, + isRecording: isRecording, + recorder: recorder, + updateRecordingsList: _listRecordings, ), ], ), @@ -482,7 +272,8 @@ class _LocalRecorderViewState extends State { IconButton( icon: Icon(Icons.folder_open), onPressed: () async { - Directory recordDir = await getIOSDirectory(); + Directory recordDir = + await Files.getIOSDirectory(); _openFolder(recordDir.path); }, ), @@ -647,9 +438,12 @@ class _LocalRecorderViewState extends State { type: 'text/comma-separated-values', ); + if (!mounted) return; if (result.type != ResultType.done) { - await _showErrorDialog( + await LocalRecorderDialogs + .showErrorDialog( + this.context, 'Could not open file: ${result.message}', ); } @@ -671,81 +465,3 @@ class _LocalRecorderViewState extends State { ); } } - -/* ──────────────────────────────────────────────────────────── */ -/* Helpers */ -/* ──────────────────────────────────────────────────────────── */ - -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; -} - -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; -} - -Future _isDirectoryEmpty(String path) async { - final dir = Directory(path); - if (!await dir.exists()) return true; - return await dir.list(followLinks: false).isEmpty; -} - -Future _askOverwriteConfirmation( - BuildContext context, - String dirPath, -) async { - return await showDialog( - 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; -} 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..0fc3b322 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -0,0 +1,262 @@ +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(); + +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(); + if (turnOffSensors) { + final wearablesProvider = context.read(); + final futures = wearablesProvider.sensorConfigurationProviders.values + .map((provider) => provider.turnOffAllSensors()); + await Future.wait(futures); + } + 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/pubspec.lock b/open_wearable/pubspec.lock index 0458b180..872bf501 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -420,18 +420,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" mcumgr_flutter: dependency: "direct main" description: @@ -664,6 +664,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: @@ -825,10 +833,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.8" tuple: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 6b2c9f9d..a169a7eb 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: shared_preferences: ^2.5.3 url_launcher: ^6.3.2 go_router: ^14.6.2 + playback_capture: ^0.0.4 dev_dependencies: flutter_test: From 891d84e820fe82cd341b3a6a2f09e8cde760cd6b Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 16 Jan 2026 17:10:03 +0100 Subject: [PATCH 2/8] add custom audio waveform to the Sensors tab with device selector --- .../android/app/src/main/AndroidManifest.xml | 2 + .../ios/Flutter/AppFrameworkInfo.plist | 4 +- open_wearable/ios/Podfile.lock | 12 + open_wearable/lib/apps/widgets/apps_page.dart | 2 + .../sensors/values/sensor_values_page.dart | 497 ++++++++++++++++-- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + open_wearable/pubspec.lock | 72 +++ open_wearable/pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 12 files changed, 557 insertions(+), 45 deletions(-) diff --git a/open_wearable/android/app/src/main/AndroidManifest.xml b/open_wearable/android/app/src/main/AndroidManifest.xml index 8cb19723..b7029ab2 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/Flutter/AppFrameworkInfo.plist b/open_wearable/ios/Flutter/AppFrameworkInfo.plist index be767884..391a902b 100644 --- a/open_wearable/ios/Flutter/AppFrameworkInfo.plist +++ b/open_wearable/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 - \ No newline at end of file + diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index b520b7a3..a699831f 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - audio_waveforms (0.0.1): + - Flutter - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -53,6 +55,8 @@ PODS: - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter + - record_ios (1.1.0): + - Flutter - SDWebImage (5.21.3): - SDWebImage/Core (= 5.21.3) - SDWebImage/Core (5.21.3) @@ -72,6 +76,7 @@ PODS: - ZIPFoundation (0.9.19) DEPENDENCIES: + - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) @@ -80,6 +85,7 @@ DEPENDENCIES: - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - 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`) @@ -97,6 +103,8 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: + audio_waveforms: + :path: ".symlinks/plugins/audio_waveforms/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" file_selector_ios: @@ -113,6 +121,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_foundation/darwin" 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: @@ -123,6 +133,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: + audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be @@ -134,6 +145,7 @@ SPEC CHECKSUMS: open_file_ios: 5ff7526df64e4394b4fe207636b67a95e83078bb path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a 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 f442d1c8..ed78b461 100644 --- a/open_wearable/lib/apps/widgets/apps_page.dart +++ b/open_wearable/lib/apps/widgets/apps_page.dart @@ -70,6 +70,7 @@ List _apps = [ }, ), ), + /* if (Platform.isAndroid) AppInfo( logoPath: "lib/apps/audio_recorder/assets/logo.png", @@ -77,6 +78,7 @@ List _apps = [ description: "Record system audio and Bluetooth streams", widget: const AudioRecorderView(), // Your audio recorder page ), + */ ]; class AppsPage extends StatelessWidget { 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 2858a18a..6bb37781 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -4,39 +4,248 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +import 'package:record/record.dart'; +import 'dart:async'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; -class SensorValuesPage extends StatelessWidget { +class SensorValuesPage extends StatefulWidget { + const SensorValuesPage({super.key}); + + @override + State createState() => _SensorValuesPageState(); +} + +class _SensorValuesPageState extends State { final Map<(Wearable, Sensor), SensorDataProvider> _sensorDataProvider = {}; + late final AudioRecorder _audioRecorder; + + bool _isRecording = false; + String? _errorMessage; + List _devices = []; + InputDevice? _selectedDevice; + + StreamSubscription? _recordSub; + StreamSubscription? _amplitudeSub; + + RecordState _recordState = RecordState.stop; + List _waveformData = []; + Amplitude? _amplitude; + + @override + void initState() { + super.initState(); + _audioRecorder = AudioRecorder(); + + // Only subscribe to state changes initially + _recordSub = _audioRecorder.onStateChanged().listen((recordState) { + if (mounted) { + setState(() => _recordState = recordState); + + // Clean up amplitude subscription when recording stops + if (recordState == RecordState.stop) { + _amplitudeSub?.cancel(); + _amplitudeSub = null; + } + } + }); + + _initRecording(); + } + + Future _initRecording() async { + print("Initializing audio recorder"); + + try { + if (await _audioRecorder.hasPermission()) { + print("Permission granted"); + await _loadDevices(); + await _startRecording(); + } else { + print("No permission, requesting..."); + final status = await Permission.microphone.request(); + if (status.isGranted) { + await _loadDevices(); + await _startRecording(); + } else { + if (mounted) { + setState(() => _errorMessage = 'Microphone permission denied'); + } + } + } + } catch (e) { + print("Init error: $e"); + if (mounted) { + setState(() => _errorMessage = 'Failed to initialize: $e'); + } + } + } + + Future _loadDevices() async { + try { + final devs = await _audioRecorder.listInputDevices(); + if (mounted) { + setState(() { + _devices = devs; + if (_selectedDevice == null && _devices.isNotEmpty) { + _selectedDevice = _devices.first; + print("Selected device: ${_selectedDevice?.label}"); + } + }); + } + } catch (e) { + print("Error loading devices: $e"); + } + } + + Future _getRecordingPath() async { + final directory = await getTemporaryDirectory(); + return '${directory.path}/temp_recording_${DateTime.now().millisecondsSinceEpoch}.m4a'; + } + + Future _startRecording() async { + try { + const encoder = AudioEncoder.aacLc; + + if (!await _audioRecorder.isEncoderSupported(encoder)) { + if (mounted) { + setState(() => _errorMessage = 'Encoder not supported'); + } + return; + } + + final path = await _getRecordingPath(); + + final config = RecordConfig( + encoder: encoder, + numChannels: 1, + device: _selectedDevice, + ); + + await _audioRecorder.start(config, path: path); + + // Wait a bit to ensure recording is active + await Future.delayed(Duration(milliseconds: 100)); + + _amplitudeSub?.cancel(); + // Subscribe to amplitude changes after recording started + _amplitudeSub = _audioRecorder + .onAmplitudeChanged(const Duration(milliseconds: 100)) + .listen( + (amp) { + if (mounted) { + setState(() { + _amplitude = amp; + // Add normalized amplitude to waveform data + final normalized = (amp.current + 50) / 50; + _waveformData.add(normalized.clamp(0.0, 2.0)); + + // Keep only last 100 samples + if (_waveformData.length > 100) { + _waveformData.removeAt(0); + } + }); + } + }, + onError: (error) { + print("Amplitude stream error: $error"); + }, + ); - SensorValuesPage({super.key}); + if (mounted) { + setState(() { + _isRecording = true; + _errorMessage = null; + }); + } + } catch (e) { + print("Recording start error: $e"); + if (mounted) { + setState(() => _errorMessage = 'Failed to start recording: $e'); + } + } + } + + Future _changeDevice(InputDevice? device) async { + if (device == null) return; + + // Stop current recording + if (_recordState != RecordState.stop) { + await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; + } + + // Update selected device and restart + if (mounted) { + setState(() { + _selectedDevice = device; + _waveformData.clear(); + _isRecording = false; + }); + } + + await _startRecording(); + } + + @override + void dispose() { + _audioRecorder.stop(); + _recordSub?.cancel(); + _amplitudeSub?.cancel(); + _audioRecorder.dispose(); + + // Dispose all sensor data providers + for (var provider in _sensorDataProvider.values) { + provider.dispose(); + } + _sensorDataProvider.clear(); + + super.dispose(); + } @override Widget build(BuildContext context) { return Consumer( builder: (context, wearablesProvider, child) { List charts = []; + for (var wearable in wearablesProvider.wearables) { if (wearable is SensorManager) { for (Sensor sensor in (wearable as SensorManager).sensors) { if (!_sensorDataProvider.containsKey((wearable, sensor))) { - _sensorDataProvider[(wearable, sensor)] = SensorDataProvider(sensor: sensor); + _sensorDataProvider[(wearable, sensor)] = + SensorDataProvider(sensor: sensor); } charts.add( ChangeNotifierProvider.value( value: _sensorDataProvider[(wearable, sensor)], - child: SensorValueCard(sensor: sensor, wearable: wearable,), + child: SensorValueCard( + sensor: sensor, + wearable: wearable, + ), ), ); } } } - _sensorDataProvider.removeWhere((key, _) => - !wearablesProvider.wearables.any((device) => device is SensorManager - && device == key.$1 - && (device as SensorManager).sensors.contains(key.$2),), - ); + // Proper cleanup with disposal + final keysToRemove = _sensorDataProvider.keys + .where( + (key) => !wearablesProvider.wearables.any((device) => + device is SensorManager && + device == key.$1 && + (device as SensorManager).sensors.contains(key.$2)), + ) + .toList(); + + for (var key in keysToRemove) { + _sensorDataProvider[key]?.dispose(); + _sensorDataProvider.remove(key); + } return LayoutBuilder( builder: (context, constraints) { @@ -54,46 +263,250 @@ class SensorValuesPage extends StatelessWidget { Widget _buildSmallScreenLayout(BuildContext context, List charts) { return Padding( padding: EdgeInsets.all(10), - child: charts.isEmpty - ? Center( - child: PlatformText("No sensors connected", style: Theme.of(context).textTheme.titleLarge), - ) - : ListView( - children: charts, - ), + child: Column( + children: [ + // Device selector + if (_devices.isNotEmpty) + Card( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Row( + children: [ + Text('Input: ', + style: TextStyle(fontWeight: FontWeight.bold)), + SizedBox(width: 8), + Expanded( + child: DropdownButton( + value: _selectedDevice, + isExpanded: true, + items: _devices.map((d) { + return DropdownMenuItem( + value: d, + child: Text(d.label), + ); + }).toList(), + onChanged: _changeDevice, + ), + ), + IconButton( + icon: Icon(Icons.refresh), + onPressed: _loadDevices, + ), + ], + ), + ), + ), + SizedBox(height: 10), + + // Custom waveform widget + if (_isRecording) + Card( + child: Padding( + padding: EdgeInsets.all(16), + child: CustomPaint( + size: Size(double.infinity, 100), + painter: WaveformPainter(_waveformData), + ), + ), + ) + else if (_errorMessage != null) + Padding( + padding: EdgeInsets.all(8.0), + child: PlatformText( + _errorMessage!, + style: TextStyle(color: Colors.red), + ), + ), + if (_isRecording || _errorMessage != null) SizedBox(height: 10), + + // Sensor charts + Expanded( + child: charts.isEmpty + ? Center( + child: PlatformText( + "No sensors connected", + style: Theme.of(context).textTheme.titleLarge, + ), + ) + : ListView(children: charts), + ), + ], + ), ); } Widget _buildLargeScreenLayout(BuildContext context, List charts) { - return GridView.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 500, - childAspectRatio: 1.5, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - ), - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemCount: charts.isEmpty ? 1 : charts.length, - itemBuilder: (context, index) { - if (charts.isEmpty) { - return Card( - shape: RoundedRectangleBorder( - side: BorderSide( - color: Colors.grey, - width: 1, - style: BorderStyle.solid, - strokeAlign: -1, + return SingleChildScrollView( + padding: EdgeInsets.all(10), + child: Column( + children: [ + // Device selector for large screens + if (_devices.isNotEmpty) + Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Input Device: ', + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: 16)), + SizedBox(width: 12), + DropdownButton( + value: _selectedDevice, + items: _devices.map((d) { + return DropdownMenuItem( + value: d, + child: Text(d.label), + ); + }).toList(), + onChanged: _changeDevice, + ), + SizedBox(width: 12), + IconButton( + icon: Icon(Icons.refresh), + onPressed: _loadDevices, + ), + ], + ), ), - borderRadius: BorderRadius.circular(10), ), - child: Center( - child: PlatformText("No sensors available", style: Theme.of(context).textTheme.titleLarge), + SizedBox(height: 10), + + // Audio waveform for large screens + if (_isRecording) + Card( + child: Padding( + padding: EdgeInsets.all(16), + child: CustomPaint( + size: Size(double.infinity, 80), + painter: WaveformPainter(_waveformData), + ), + ), ), - ); - } - return charts[index]; - }, + if (_errorMessage != null) + Card( + child: Padding( + padding: EdgeInsets.all(16), + child: PlatformText( + _errorMessage!, + style: TextStyle(color: Colors.red), + ), + ), + ), + if (_isRecording || _errorMessage != null) SizedBox(height: 10), + + // Sensor charts grid + GridView.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 500, + childAspectRatio: 1.5, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: charts.isEmpty ? 1 : charts.length, + itemBuilder: (context, index) { + if (charts.isEmpty) { + return Card( + shape: RoundedRectangleBorder( + side: BorderSide( + color: Colors.grey, + width: 1, + style: BorderStyle.solid, + strokeAlign: -1, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: PlatformText( + "No sensors available", + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ); + } + return charts[index]; + }, + ), + ], + ), ); } } + +// 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 starting position to align bars from right + final totalWaveformWidth = waveformData.length * spacing; + final startX = size.width - totalWaveformWidth; + + // Draw each amplitude value as a vertical bar + for (int i = 0; i < waveformData.length; i++) { + final x = startX + (i * spacing); + final amplitude = waveformData[i]; + + // Scale amplitude to fit within the canvas height + // Amplitude is normalized to 0-2 range, scale it to use 80% of half 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 86be7eb9..6453f41c 100644 --- a/open_wearable/linux/flutter/generated_plugin_registrant.cc +++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -17,6 +18,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 c842924f..e84b5432 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST 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 a3e457d4..24c8a7ea 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import file_selector_macos import flutter_archive import open_file_mac import path_provider_foundation +import record_macos import share_plus import shared_preferences_foundation import universal_ble @@ -21,6 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + 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 872bf501..14c2aba3 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audio_waveforms: + dependency: "direct main" + description: + name: audio_waveforms + sha256: "03b3430ecf430a2e90185518a228c02be3d26653c62dd931e50d671213a6dbc8" + url: "https://pub.dev" + source: hosted + version: "2.0.2" bloc: dependency: transitive description: @@ -704,6 +712,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: "6bad72fb3ea6708d724cf8b6c97c4e236cf9f43a52259b654efeb6fd9b737f1f" + url: "https://pub.dev" + source: hosted + version: "6.1.2" + record_android: + dependency: transitive + description: + name: record_android + sha256: "9aaf3f151e61399b09bd7c31eb5f78253d2962b3f57af019ac5a2d1a3afdcf71" + url: "https://pub.dev" + source: hosted + version: "1.4.5" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "69fcd37c6185834e90254573599a9165db18a2cbfa266b6d1e46ffffeb06a28c" + url: "https://pub.dev" + source: hosted + version: "1.1.5" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "235b1f1fb84e810f8149cc0c2c731d7d697f8d1c333b32cb820c449bf7bb72d8" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "842ea4b7e95f4dd237aacffc686d1b0ff4277e3e5357865f8d28cd28bc18ed95" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: b0065fdf1ec28f5a634d676724d388a77e43ce7646fb049949f58c69f3fcb4ed + url: "https://pub.dev" + source: hosted + version: "1.4.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "3feeffbc0913af3021da9810bb8702a068db6bc9da52dde1d19b6ee7cb9edb51" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" rxdart: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index a169a7eb..e7bc189b 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -55,6 +55,8 @@ dependencies: url_launcher: ^6.3.2 go_router: ^14.6.2 playback_capture: ^0.0.4 + audio_waveforms: ^2.0.2 + record: ^6.1.2 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 55f09317..eae4ff8e 100644 --- a/open_wearable/windows/flutter/generated_plugin_registrant.cc +++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,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 279f13bf..d157b611 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows permission_handler_windows + record_windows share_plus universal_ble url_launcher_windows From de79d163a603e44bf3b8e3470cadc541add199bf Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 19 Jan 2026 14:53:19 +0100 Subject: [PATCH 3/8] fix scrolling problems with charts screen --- .../sensors/values/sensor_values_page.dart | 46 +++++++++++-------- open_wearable/pubspec.lock | 8 ++++ open_wearable/pubspec.yaml | 1 + 3 files changed, 35 insertions(+), 20 deletions(-) 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 6bb37781..dff9d5ed 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -261,7 +261,7 @@ class _SensorValuesPageState extends State { } Widget _buildSmallScreenLayout(BuildContext context, List charts) { - return Padding( + return SingleChildScrollView( padding: EdgeInsets.all(10), child: Column( children: [ @@ -296,7 +296,6 @@ class _SensorValuesPageState extends State { ), ), ), - SizedBox(height: 10), // Custom waveform widget if (_isRecording) @@ -317,19 +316,21 @@ class _SensorValuesPageState extends State { style: TextStyle(color: Colors.red), ), ), - if (_isRecording || _errorMessage != null) SizedBox(height: 10), - - // Sensor charts - Expanded( - child: charts.isEmpty - ? Center( - child: PlatformText( - "No sensors connected", - style: Theme.of(context).textTheme.titleLarge, - ), - ) - : ListView(children: charts), - ), + //if (_isRecording || _errorMessage != null) SizedBox(height: 10), + + // Sensor charts - no longer wrapped in Expanded + charts.isEmpty + ? Center( + child: PlatformText( + "No sensors connected", + style: Theme.of(context).textTheme.titleLarge, + ), + ) + : ListView( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + children: charts, + ), ], ), ); @@ -478,17 +479,22 @@ class WaveformPainter extends CustomPainter { ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke; - // Calculate starting position to align bars from right - final totalWaveformWidth = waveformData.length * spacing; + // 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 < waveformData.length; i++) { + for (int i = 0; i < visibleData.length; i++) { final x = startX + (i * spacing); - final amplitude = waveformData[i]; + final amplitude = visibleData[i]; // Scale amplitude to fit within the canvas height - // Amplitude is normalized to 0-2 range, scale it to use 80% of half height final barHeight = amplitude * centerY * 0.8; // Draw top half of the bar (above center line) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 14c2aba3..91057723 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -278,6 +278,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: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index e7bc189b..be68319c 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: go_router: ^14.6.2 playback_capture: ^0.0.4 audio_waveforms: ^2.0.2 + flutter_headset_detector: ^3.1.0 record: ^6.1.2 dev_dependencies: From cd634b06aab21f0095479fde62bbd9ad505517f9 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 19 Jan 2026 15:42:05 +0100 Subject: [PATCH 4/8] auto select OpenEarable as audio source, improve UI of Chart tab, fix spacing --- .../sensors/values/sensor_value_card.dart | 38 +++++++----- .../sensors/values/sensor_values_page.dart | 58 +++++++++++++++---- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart b/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart index 85f8e111..8089dfe6 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart @@ -10,21 +10,25 @@ class SensorValueCard extends StatelessWidget { final Sensor sensor; final Wearable wearable; - const SensorValueCard({super.key, required this.sensor, required this.wearable}); + const SensorValueCard({ + super.key, + required this.sensor, + required this.wearable, + }); @override Widget build(BuildContext context) { return GestureDetector( onTap: () { - final provider = context.read(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ChangeNotifierProvider.value( - value: provider, - child: SensorValueDetail(sensor: sensor, wearable: wearable), - ), + final provider = context.read(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChangeNotifierProvider.value( + value: provider, + child: SensorValueDetail(sensor: sensor, wearable: wearable), ), - ); + ), + ); }, child: Card( child: Padding( @@ -33,16 +37,24 @@ class SensorValueCard extends StatelessWidget { children: [ Row( children: [ - PlatformText(sensor.sensorName, style: Theme.of(context).textTheme.bodyLarge), + PlatformText( + sensor.sensorName, + style: Theme.of(context).textTheme.bodyLarge, + ), Spacer(), - PlatformText(wearable.name, style: Theme.of(context).textTheme.bodyMedium), + PlatformText( + wearable.name, + style: Theme.of(context).textTheme.bodyMedium, + ), ], ), Padding( padding: const EdgeInsets.only(top: 10.0), - child: SizedBox( + child: SizedBox( height: 200, - child: SensorChart(allowToggleAxes: false,), + child: SensorChart( + allowToggleAxes: false, + ), ), ), ], 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 dff9d5ed..b4c8e70c 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -89,9 +89,17 @@ class _SensorValuesPageState extends State { if (mounted) { setState(() { _devices = devs; - if (_selectedDevice == null && _devices.isNotEmpty) { - _selectedDevice = _devices.first; - print("Selected device: ${_selectedDevice?.label}"); + // Automatically select BLE headset + _selectedDevice = _devices.firstWhere( + (device) => + device.label.toLowerCase().contains('bluetooth') || + device.label.toLowerCase().contains('ble') || + device.label.toLowerCase().contains('headset'), + orElse: () => + _devices.isNotEmpty ? _devices.first : null as InputDevice, + ); + if (_selectedDevice != null) { + print("Auto-selected BLE device: ${_selectedDevice?.label}"); } }); } @@ -264,6 +272,8 @@ class _SensorValuesPageState extends State { return SingleChildScrollView( padding: EdgeInsets.all(10), child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Device selector if (_devices.isNotEmpty) @@ -272,8 +282,10 @@ class _SensorValuesPageState extends State { padding: EdgeInsets.all(8.0), child: Row( children: [ - Text('Input: ', - style: TextStyle(fontWeight: FontWeight.bold)), + Text( + 'Input: ', + style: TextStyle(fontWeight: FontWeight.bold), + ), SizedBox(width: 8), Expanded( child: DropdownButton( @@ -302,9 +314,20 @@ class _SensorValuesPageState extends State { Card( child: Padding( padding: EdgeInsets.all(16), - child: CustomPaint( - size: Size(double.infinity, 100), - painter: WaveformPainter(_waveformData), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AUDIO WAVEFORM', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + CustomPaint( + size: Size(double.infinity, 100), + painter: WaveformPainter(_waveformData), + ), + ], ), ), ) @@ -316,7 +339,6 @@ class _SensorValuesPageState extends State { style: TextStyle(color: Colors.red), ), ), - //if (_isRecording || _errorMessage != null) SizedBox(height: 10), // Sensor charts - no longer wrapped in Expanded charts.isEmpty @@ -329,6 +351,7 @@ class _SensorValuesPageState extends State { : ListView( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, children: charts, ), ], @@ -379,9 +402,20 @@ class _SensorValuesPageState extends State { Card( child: Padding( padding: EdgeInsets.all(16), - child: CustomPaint( - size: Size(double.infinity, 80), - painter: WaveformPainter(_waveformData), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio waveform', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + CustomPaint( + size: Size(double.infinity, 80), + painter: WaveformPainter(_waveformData), + ), + ], ), ), ), From be319c5b50ed4a014ccf8da46bdfeac6b737986c Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 23 Jan 2026 17:17:19 +0100 Subject: [PATCH 5/8] add audio file to recording folder. Fixed some bugs: switching tabs does not cause audio recording to stop. Audio recording is only available on android. Audio recording only records from ble headset. --- open_wearable/ios/Podfile.lock | 12 +- .../view_models/sensor_recorder_provider.dart | 128 +++- .../local_recorder/local_recorder_view.dart | 20 +- .../sensors/values/sensor_values_page.dart | 546 ++++++++++-------- open_wearable/pubspec.lock | 8 - open_wearable/pubspec.yaml | 1 - 6 files changed, 469 insertions(+), 246 deletions(-) diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index a699831f..c2e7d4e2 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -1,6 +1,4 @@ PODS: - - audio_waveforms (0.0.1): - - Flutter - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -41,6 +39,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) @@ -76,11 +76,11 @@ PODS: - ZIPFoundation (0.9.19) DEPENDENCIES: - - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - 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`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -103,8 +103,6 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: - audio_waveforms: - :path: ".symlinks/plugins/audio_waveforms/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" file_selector_ios: @@ -113,6 +111,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: @@ -133,13 +133,13 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe + flutter_headset_detector: 37d2407c6c59aa6e8a9daecf732854862ff6dd4a iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58 mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a open_file_ios: 5ff7526df64e4394b4fe207636b67a95e83078bb diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index 134aea85..15491b6b 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +import 'package:record/record.dart'; import '../models/logger.dart'; @@ -13,12 +14,44 @@ 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; + final List _waveformData = []; + List get waveformData => List.unmodifiable(_waveformData); + + InputDevice? _selectedBLEDevice; + + 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; + } + } + void startRecording(String dirname) async { _isRecording = true; _currentDirectory = dirname; @@ -28,10 +61,70 @@ class SensorRecorderProvider with ChangeNotifier { await _startRecorderForWearable(wearable, dirname); } + await _startAudioRecording( + dirname, + ); + notifyListeners(); } - void stopRecording() { + Future _startAudioRecording(String recordingFolderPath) async { + if (_selectedBLEDevice == null) { + logger.w("No BLE headset detected, skipping audio recording"); + return; + } + if (!Platform.isAndroid) return; + try { + if (!await _audioRecorder.hasPermission()) { + logger.w("No microphone permission for recording"); + return; + } + + await _selectBLEDevice(); + + 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() async { _isRecording = false; _recordingStart = null; for (Wearable wearable in _recorders.keys) { @@ -45,6 +138,20 @@ class SensorRecorderProvider with ChangeNotifier { } } } + try { + if (_isAudioRecording) { + final path = await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; + _isAudioRecording = false; + _waveformData.clear(); + + logger.i("Audio recording saved to: $path"); + _currentAudioPath = null; + } + } catch (e) { + logger.e("Error stopping audio recording: $e"); + } notifyListeners(); } @@ -156,4 +263,23 @@ class SensorRecorderProvider with ChangeNotifier { ); } } + + @override + void dispose() { + _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/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index 596b3be5..d99fd52b 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 @@ -433,11 +433,27 @@ class _LocalRecorderViewState extends State { onPressed: () => _shareFile(file), ), onTap: () async { + String? mimeType; + + if (fileName.endsWith('.csv')) { + mimeType = + 'text/comma-separated-values'; + } else if (fileName + .endsWith('.m4a')) { + mimeType = 'audio/mp4'; + } else if (fileName + .endsWith('.mp3')) { + mimeType = 'audio/mpeg'; + } else if (fileName + .endsWith('.wav')) { + mimeType = 'audio/wav'; + } + final result = await OpenFile.open( file.path, - type: - 'text/comma-separated-values', + type: mimeType, ); + if (!mounted) return; if (result.type != ResultType.done) { 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 b4c8e70c..b3a551b5 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:logger/logger.dart'; import 'package:open_earable_flutter/open_earable_flutter.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/values/sensor_value_card.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -11,6 +13,8 @@ import 'dart:async'; import 'package:path_provider/path_provider.dart'; import 'dart:io'; +Logger _logger = Logger(); + class SensorValuesPage extends StatefulWidget { const SensorValuesPage({super.key}); @@ -20,55 +24,69 @@ class SensorValuesPage extends StatefulWidget { class _SensorValuesPageState extends State { final Map<(Wearable, Sensor), SensorDataProvider> _sensorDataProvider = {}; - late final AudioRecorder _audioRecorder; + AudioRecorder? _audioRecorder; // Separate instance for preview + bool _isPreviewRecording = false; - bool _isRecording = false; String? _errorMessage; - List _devices = []; InputDevice? _selectedDevice; StreamSubscription? _recordSub; StreamSubscription? _amplitudeSub; RecordState _recordState = RecordState.stop; - List _waveformData = []; - Amplitude? _amplitude; + final List _waveformData = []; + + bool _isInitializing = true; @override void initState() { super.initState(); - _audioRecorder = AudioRecorder(); + if (Platform.isAndroid) { + _audioRecorder = AudioRecorder(); - // Only subscribe to state changes initially - _recordSub = _audioRecorder.onStateChanged().listen((recordState) { - if (mounted) { - setState(() => _recordState = recordState); + _recordSub = _audioRecorder!.onStateChanged().listen((recordState) { + if (mounted) { + setState(() => _recordState = recordState); - // Clean up amplitude subscription when recording stops - if (recordState == RecordState.stop) { - _amplitudeSub?.cancel(); - _amplitudeSub = null; + if (recordState == RecordState.stop) { + _amplitudeSub?.cancel(); + _amplitudeSub = null; + } } - } - }); + }); + + _initRecording(); + } + } - _initRecording(); + // Add method to check if provider is recording + bool _isProviderRecording(BuildContext context) { + try { + final recorder = + Provider.of(context, listen: false); + return recorder.isRecording; + } catch (e) { + return false; + } } Future _initRecording() async { - print("Initializing audio recorder"); + if (!Platform.isAndroid || _audioRecorder == null) return; + + if (_isProviderRecording(context)) { + if (mounted) setState(() => _isInitializing = false); + return; + } try { - if (await _audioRecorder.hasPermission()) { - print("Permission granted"); - await _loadDevices(); - await _startRecording(); + if (await _audioRecorder!.hasPermission()) { + await _selectBLEDevice(); + await _startPreview(); } else { - print("No permission, requesting..."); final status = await Permission.microphone.request(); if (status.isGranted) { - await _loadDevices(); - await _startRecording(); + await _selectBLEDevice(); + await _startPreview(); } else { if (mounted) { setState(() => _errorMessage = 'Microphone permission denied'); @@ -76,81 +94,97 @@ class _SensorValuesPageState extends State { } } } catch (e) { - print("Init error: $e"); if (mounted) { setState(() => _errorMessage = 'Failed to initialize: $e'); } + } finally { + if (mounted) { + setState(() => _isInitializing = false); + } } } - Future _loadDevices() async { + Future _selectBLEDevice() async { + if (!Platform.isAndroid || _audioRecorder == null) return; try { - final devs = await _audioRecorder.listInputDevices(); - if (mounted) { - setState(() { - _devices = devs; - // Automatically select BLE headset - _selectedDevice = _devices.firstWhere( - (device) => - device.label.toLowerCase().contains('bluetooth') || - device.label.toLowerCase().contains('ble') || - device.label.toLowerCase().contains('headset'), - orElse: () => - _devices.isNotEmpty ? _devices.first : null as InputDevice, - ); - if (_selectedDevice != null) { - print("Auto-selected BLE device: ${_selectedDevice?.label}"); - } - }); + final devices = await _audioRecorder!.listInputDevices(); + + // Try to find BLE device + try { + _selectedDevice = 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( + "Auto-selected BLE device for preview: ${_selectedDevice!.label}"); + } catch (e) { + // No BLE device found + _selectedDevice = null; + _logger.e("No BLE headset found"); } } catch (e) { - print("Error loading devices: $e"); + _logger.e("Error selecting BLE device: $e"); + _selectedDevice = null; } } - Future _getRecordingPath() async { + Future _getTemporaryPath() async { final directory = await getTemporaryDirectory(); - return '${directory.path}/temp_recording_${DateTime.now().millisecondsSinceEpoch}.m4a'; + return '${directory.path}/preview_${DateTime.now().millisecondsSinceEpoch}.m4a'; } - Future _startRecording() async { + Future _startPreview() async { + if (!Platform.isAndroid || _audioRecorder == null) return; + + // Don't start if provider is recording + if (_isProviderRecording(context)) { + return; + } + + // Don't start if no BLE device selected + if (_selectedDevice == null) { + if (mounted) { + setState(() => _errorMessage = 'No BLE headset detected'); + } + return; + } + try { - const encoder = AudioEncoder.aacLc; + const encoder = AudioEncoder.wav; - if (!await _audioRecorder.isEncoderSupported(encoder)) { + if (!await _audioRecorder!.isEncoderSupported(encoder)) { if (mounted) { - setState(() => _errorMessage = 'Encoder not supported'); + setState(() => _errorMessage = 'WAV encoder not supported'); } return; } - final path = await _getRecordingPath(); + final path = await _getTemporaryPath(); final config = RecordConfig( encoder: encoder, + sampleRate: 48000, + bitRate: 768000, numChannels: 1, device: _selectedDevice, ); - await _audioRecorder.start(config, path: path); - - // Wait a bit to ensure recording is active + await _audioRecorder!.start(config, path: path); await Future.delayed(Duration(milliseconds: 100)); _amplitudeSub?.cancel(); - // Subscribe to amplitude changes after recording started - _amplitudeSub = _audioRecorder + _amplitudeSub = _audioRecorder! .onAmplitudeChanged(const Duration(milliseconds: 100)) .listen( (amp) { if (mounted) { setState(() { - _amplitude = amp; - // Add normalized amplitude to waveform data final normalized = (amp.current + 50) / 50; _waveformData.add(normalized.clamp(0.0, 2.0)); - // Keep only last 100 samples if (_waveformData.length > 100) { _waveformData.removeAt(0); } @@ -158,52 +192,80 @@ class _SensorValuesPageState extends State { } }, onError: (error) { - print("Amplitude stream error: $error"); + _logger.e("Amplitude stream error: $error"); }, ); if (mounted) { setState(() { - _isRecording = true; + _isPreviewRecording = true; _errorMessage = null; }); } } catch (e) { - print("Recording start error: $e"); + _logger.e("Preview start error: $e"); if (mounted) { - setState(() => _errorMessage = 'Failed to start recording: $e'); + setState(() => _errorMessage = 'Failed to start preview: $e'); } } } - Future _changeDevice(InputDevice? device) async { - if (device == null) return; + Future _stopPreview() async { + if (!Platform.isAndroid || _audioRecorder == null) return; + if (!_isPreviewRecording) return; - // Stop current recording - if (_recordState != RecordState.stop) { - await _audioRecorder.stop(); + try { + final tempPath = await _audioRecorder!.stop(); _amplitudeSub?.cancel(); _amplitudeSub = null; - } - // Update selected device and restart - if (mounted) { - setState(() { - _selectedDevice = device; - _waveformData.clear(); - _isRecording = false; - }); - } + if (tempPath != null) { + try { + final file = File(tempPath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + _logger.e("Error deleting temp preview file: $e"); + } + } - await _startRecording(); + if (mounted) { + setState(() { + _isPreviewRecording = false; + _waveformData.clear(); + }); + } + } catch (e) { + _logger.e("Error stopping preview: $e"); + } } @override void dispose() { - _audioRecorder.stop(); - _recordSub?.cancel(); - _amplitudeSub?.cancel(); - _audioRecorder.dispose(); + // Stop and clean up preview recording + if (Platform.isAndroid && _audioRecorder != null) { + if (_recordState != RecordState.stop) { + _audioRecorder!.stop().then((tempPath) { + if (tempPath != null) { + try { + final file = File(tempPath); + file.exists().then((exists) { + if (exists) { + file.delete(); + } + }); + } catch (e) { + _logger.e("Error deleting temp preview file: $e"); + } + } + }); + } + + _recordSub?.cancel(); + _amplitudeSub?.cancel(); + _audioRecorder!.dispose(); + } // Dispose all sensor data providers for (var provider in _sensorDataProvider.values) { @@ -216,8 +278,21 @@ class _SensorValuesPageState extends State { @override Widget build(BuildContext context) { - return Consumer( - builder: (context, wearablesProvider, child) { + return Consumer2( + builder: (context, wearablesProvider, recorderProvider, child) { + // Stop preview if provider starts recording + if (Platform.isAndroid && + recorderProvider.isRecording && + _isPreviewRecording) { + _stopPreview(); + } + // Restart preview if provider stops recording + else if (Platform.isAndroid && + !recorderProvider.isRecording && + !_isPreviewRecording && + _audioRecorder != null) { + _initRecording(); + } List charts = []; for (var wearable in wearablesProvider.wearables) { @@ -243,10 +318,12 @@ class _SensorValuesPageState extends State { // Proper cleanup with disposal final keysToRemove = _sensorDataProvider.keys .where( - (key) => !wearablesProvider.wearables.any((device) => - device is SensorManager && - device == key.$1 && - (device as SensorManager).sensors.contains(key.$2)), + (key) => !wearablesProvider.wearables.any( + (device) => + device is SensorManager && + device == key.$1 && + (device as SensorManager).sensors.contains(key.$2), + ), ) .toList(); @@ -258,9 +335,9 @@ class _SensorValuesPageState extends State { return LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth < 600) { - return _buildSmallScreenLayout(context, charts); + return _buildSmallScreenLayout(context, charts, recorderProvider); } else { - return _buildLargeScreenLayout(context, charts); + return _buildLargeScreenLayout(context, charts, recorderProvider); } }, ); @@ -268,169 +345,182 @@ class _SensorValuesPageState extends State { ); } - Widget _buildSmallScreenLayout(BuildContext context, List charts) { + Widget _buildSmallScreenLayout( + BuildContext context, + List charts, + SensorRecorderProvider recorderProvider, + ) { + final showRecorderWaveform = recorderProvider.isRecording; return SingleChildScrollView( padding: EdgeInsets.all(10), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Device selector - if (_devices.isNotEmpty) - Card( - child: Padding( - padding: EdgeInsets.all(8.0), - child: Row( - children: [ - Text( - 'Input: ', - style: TextStyle(fontWeight: FontWeight.bold), - ), - SizedBox(width: 8), - Expanded( - child: DropdownButton( - value: _selectedDevice, - isExpanded: true, - items: _devices.map((d) { - return DropdownMenuItem( - value: d, - child: Text(d.label), - ); - }).toList(), - onChanged: _changeDevice, - ), - ), - IconButton( - icon: Icon(Icons.refresh), - onPressed: _loadDevices, - ), - ], - ), + if (charts.isEmpty) + Center( + child: PlatformText( + "No sensors connected", + style: Theme.of(context).textTheme.titleLarge, ), - ), - - // Custom waveform widget - if (_isRecording) - Card( - child: Padding( - padding: EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'AUDIO WAVEFORM', - style: Theme.of(context).textTheme.bodyLarge, + ) + else ...[ + if (Platform.isAndroid) ...[ + if (showRecorderWaveform) + // Use waveform from SensorRecorderProvider when recording + Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.fiber_manual_record, + color: Colors.red, size: 16), + SizedBox(width: 8), + Text( + 'AUDIO WAVEFORM', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + const SizedBox(height: 8), + CustomPaint( + size: Size(double.infinity, 100), + painter: + WaveformPainter(recorderProvider.waveformData), + ), + ], ), - const SizedBox(height: 8), - CustomPaint( - size: Size(double.infinity, 100), - painter: WaveformPainter(_waveformData), + ), + ) + else if (_isPreviewRecording || _isInitializing) + Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AUDIO WAVEFORM', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + _isInitializing + ? SizedBox( + height: 100, + child: Center( + child: CircularProgressIndicator(), + ), + ) + : CustomPaint( + size: Size(double.infinity, 100), + painter: WaveformPainter(_waveformData), + ), + ], ), - ], - ), - ), - ) - else if (_errorMessage != null) - Padding( - padding: EdgeInsets.all(8.0), - child: PlatformText( - _errorMessage!, - style: TextStyle(color: Colors.red), - ), - ), - - // Sensor charts - no longer wrapped in Expanded - charts.isEmpty - ? Center( - child: PlatformText( - "No sensors connected", - style: Theme.of(context).textTheme.titleLarge, ), ) - : ListView( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - children: charts, + else if (_errorMessage != null) + Padding( + padding: EdgeInsets.all(8.0), + child: PlatformText( + _errorMessage!, + style: TextStyle(color: Colors.red), + ), ), + ], + ListView( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + children: charts, + ), + ], ], ), ); } - Widget _buildLargeScreenLayout(BuildContext context, List charts) { + Widget _buildLargeScreenLayout( + BuildContext context, + List charts, + SensorRecorderProvider recorderProvider, + ) { + final showRecorderWaveform = recorderProvider.isRecording; + return SingleChildScrollView( padding: EdgeInsets.all(10), child: Column( children: [ - // Device selector for large screens - if (_devices.isNotEmpty) - Card( - child: Padding( - padding: EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Input Device: ', - style: TextStyle( - fontWeight: FontWeight.bold, fontSize: 16)), - SizedBox(width: 12), - DropdownButton( - value: _selectedDevice, - items: _devices.map((d) { - return DropdownMenuItem( - value: d, - child: Text(d.label), - ); - }).toList(), - onChanged: _changeDevice, - ), - SizedBox(width: 12), - IconButton( - icon: Icon(Icons.refresh), - onPressed: _loadDevices, - ), - ], + if (Platform.isAndroid) ...[ + if (showRecorderWaveform) + // Use waveform from SensorRecorderProvider when recording + Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.fiber_manual_record, + color: Colors.red, + size: 16, + ), + SizedBox(width: 8), + Text( + 'Audio waveform (Recording)', + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + const SizedBox(height: 8), + CustomPaint( + size: Size(double.infinity, 80), + painter: WaveformPainter(recorderProvider.waveformData), + ), + ], + ), ), - ), - ), - SizedBox(height: 10), - - // Audio waveform for large screens - if (_isRecording) - Card( - child: Padding( - padding: EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Audio waveform', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - CustomPaint( - size: Size(double.infinity, 80), - painter: WaveformPainter(_waveformData), - ), - ], + ) + else if (_isPreviewRecording || _isInitializing) + Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio waveform', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + CustomPaint( + size: Size(double.infinity, 80), + painter: WaveformPainter(_waveformData), + ), + ], + ), ), ), - ), - if (_errorMessage != null) - Card( - child: Padding( - padding: EdgeInsets.all(16), - child: PlatformText( - _errorMessage!, - style: TextStyle(color: Colors.red), + if (_errorMessage != null) + Card( + child: Padding( + padding: EdgeInsets.all(16), + child: PlatformText( + _errorMessage!, + style: TextStyle(color: Colors.red), + ), ), ), - ), - if (_isRecording || _errorMessage != null) SizedBox(height: 10), - + ], // Sensor charts grid GridView.builder( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 91057723..d4f04520 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -17,14 +17,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" - audio_waveforms: - dependency: "direct main" - description: - name: audio_waveforms - sha256: "03b3430ecf430a2e90185518a228c02be3d26653c62dd931e50d671213a6dbc8" - url: "https://pub.dev" - source: hosted - version: "2.0.2" bloc: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index be68319c..715ef273 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -55,7 +55,6 @@ dependencies: url_launcher: ^6.3.2 go_router: ^14.6.2 playback_capture: ^0.0.4 - audio_waveforms: ^2.0.2 flutter_headset_detector: ^3.1.0 record: ^6.1.2 From f4361ee0b9fdbe78fb26d1b4901a920603305189 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 26 Jan 2026 15:05:05 +0100 Subject: [PATCH 6/8] fix bug --- .../lib/view_models/sensor_recorder_provider.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index d93f48ef..bb83a708 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -69,10 +69,6 @@ class SensorRecorderProvider with ChangeNotifier { } Future _startAudioRecording(String recordingFolderPath) async { - if (_selectedBLEDevice == null) { - logger.w("No BLE headset detected, skipping audio recording"); - return; - } if (!Platform.isAndroid) return; try { if (!await _audioRecorder.hasPermission()) { @@ -82,6 +78,11 @@ class SensorRecorderProvider with ChangeNotifier { 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"); @@ -182,7 +183,8 @@ class SensorRecorderProvider with ChangeNotifier { }); if (wearable.hasCapability()) { - for (Sensor sensor in wearable.requireCapability().sensors) { + for (Sensor sensor + in wearable.requireCapability().sensors) { if (!_recorders[wearable]!.containsKey(sensor)) { _recorders[wearable]![sensor] = Recorder(columns: sensor.axisNames); } From 5ca325123f7b109cb5b8e44ef939b98ef77d1163 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 26 Jan 2026 17:46:02 +0100 Subject: [PATCH 7/8] starting of audio streaming is now manuallycontrolled --- .../view_models/sensor_recorder_provider.dart | 147 +++++++++ .../ble_microphone_streaming_row.dart | 53 ++++ .../sensor_configuration_device_row.dart | 7 + .../sensors/values/sensor_values_page.dart | 293 +++--------------- 4 files changed, 255 insertions(+), 245 deletions(-) create mode 100644 open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index bb83a708..23395019 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -3,6 +3,7 @@ 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'; @@ -29,6 +30,14 @@ class SensorRecorderProvider with ChangeNotifier { InputDevice? _selectedBLEDevice; + bool _isBLEMicrophoneStreamingEnabled = false; + bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; + + // Separate AudioRecorder for streaming + AudioRecorder? _streamingAudioRecorder; + bool _isStreamingActive = false; + StreamSubscription? _streamingAmplitudeSub; + Future _selectBLEDevice() async { try { final devices = await _audioRecorder.listInputDevices(); @@ -52,6 +61,118 @@ class SensorRecorderProvider with ChangeNotifier { } } + 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; + } + + _streamingAudioRecorder = AudioRecorder(); + + const encoder = AudioEncoder.wav; + if (!await _streamingAudioRecorder!.isEncoderSupported(encoder)) { + logger.w("WAV encoder not supported"); + _streamingAudioRecorder = null; + return false; + } + + final tempDir = await getTemporaryDirectory(); + final tempPath = + '${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav'; + + final config = RecordConfig( + encoder: encoder, + sampleRate: 48000, + bitRate: 768000, + numChannels: 1, + device: _selectedBLEDevice, + ); + + await _streamingAudioRecorder!.start(config, path: tempPath); + _isStreamingActive = true; + _isBLEMicrophoneStreamingEnabled = true; + + // Set up amplitude monitoring for waveform display + _streamingAmplitudeSub?.cancel(); + _streamingAmplitudeSub = _streamingAudioRecorder! + .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(); + }); + + Future.delayed(const Duration(seconds: 1), () async { + try { + final file = File(tempPath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // Ignore cleanup errors + } + }); + + 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; + _streamingAudioRecorder?.dispose(); + _streamingAudioRecorder = null; + notifyListeners(); + return false; + } + } + + Future stopBLEMicrophoneStream() async { + if (!_isStreamingActive) { + return; + } + + try { + await _streamingAudioRecorder?.stop(); + _streamingAmplitudeSub?.cancel(); + _streamingAmplitudeSub = null; + _streamingAudioRecorder?.dispose(); + _streamingAudioRecorder = null; + _isStreamingActive = false; + _isBLEMicrophoneStreamingEnabled = false; + _waveformData.clear(); + + 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; @@ -70,6 +191,22 @@ class SensorRecorderProvider with ChangeNotifier { 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 _streamingAudioRecorder?.stop(); + _streamingAmplitudeSub?.cancel(); + _streamingAmplitudeSub = null; + _isStreamingActive = false; + } + try { if (!await _audioRecorder.hasPermission()) { logger.w("No microphone permission for recording"); @@ -153,6 +290,12 @@ class SensorRecorderProvider with ChangeNotifier { } catch (e) { logger.e("Error stopping audio recording: $e"); } + + // Restart streaming if it was enabled before recording + if (_isBLEMicrophoneStreamingEnabled && !_isStreamingActive) { + unawaited(startBLEMicrophoneStream()); + } + notifyListeners(); } @@ -268,6 +411,10 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { + // Stop streaming + stopBLEMicrophoneStream(); + + // Stop recording _audioRecorder.stop().then((_) { _audioRecorder.dispose(); }).catchError((e) { 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..e6efa864 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart @@ -0,0 +1,53 @@ +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'; + +/// Widget to control BLE microphone streaming +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_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 78224085..8546dcfd 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -2,6 +2,7 @@ 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/view_models/sensor_configuration_storage.dart'; +import 'package:open_wearable/widgets/sensors/configuration/ble_microphone_streaming_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/save_config_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_value_row.dart'; @@ -118,6 +119,12 @@ class _SensorConfigurationDeviceRowState const SaveConfigRow(), ]); + // Add BLE microphone streaming control (Android only) + content.addAll([ + const Divider(), + const BLEMicrophoneStreamingRow(), + ]); + if (device.hasCapability()) { content.addAll([ const Divider(), 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 cb7c1c2d..36d9d6bc 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -1,20 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:logger/logger.dart'; import 'package:open_earable_flutter/open_earable_flutter.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/values/sensor_value_card.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; -import 'package:record/record.dart'; -import 'dart:async'; -import 'package:path_provider/path_provider.dart'; import 'dart:io'; -Logger _logger = Logger(); - class SensorValuesPage extends StatefulWidget { const SensorValuesPage({super.key}); @@ -24,249 +17,41 @@ class SensorValuesPage extends StatefulWidget { class _SensorValuesPageState extends State { final Map<(Wearable, Sensor), SensorDataProvider> _sensorDataProvider = {}; - AudioRecorder? _audioRecorder; // Separate instance for preview - bool _isPreviewRecording = false; String? _errorMessage; - InputDevice? _selectedDevice; - - StreamSubscription? _recordSub; - StreamSubscription? _amplitudeSub; - - RecordState _recordState = RecordState.stop; - final List _waveformData = []; - bool _isInitializing = true; @override void initState() { super.initState(); if (Platform.isAndroid) { - _audioRecorder = AudioRecorder(); - - _recordSub = _audioRecorder!.onStateChanged().listen((recordState) { - if (mounted) { - setState(() => _recordState = recordState); - - if (recordState == RecordState.stop) { - _amplitudeSub?.cancel(); - _amplitudeSub = null; - } - } - }); - - _initRecording(); - } - } - - // Add method to check if provider is recording - bool _isProviderRecording(BuildContext context) { - try { - final recorder = - Provider.of(context, listen: false); - return recorder.isRecording; - } catch (e) { - return false; - } - } - - Future _initRecording() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - - if (_isProviderRecording(context)) { - if (mounted) setState(() => _isInitializing = false); - return; - } - - try { - if (await _audioRecorder!.hasPermission()) { - await _selectBLEDevice(); - await _startPreview(); - } else { - final status = await Permission.microphone.request(); - if (status.isGranted) { - await _selectBLEDevice(); - await _startPreview(); - } else { - if (mounted) { - setState(() => _errorMessage = 'Microphone permission denied'); - } - } - } - } catch (e) { - if (mounted) { - setState(() => _errorMessage = 'Failed to initialize: $e'); - } - } finally { - if (mounted) { - setState(() => _isInitializing = false); - } + _checkStreamingStatus(); } } - Future _selectBLEDevice() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - try { - final devices = await _audioRecorder!.listInputDevices(); - - // Try to find BLE device - try { - _selectedDevice = 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( - "Auto-selected BLE device for preview: ${_selectedDevice!.label}"); - } catch (e) { - // No BLE device found - _selectedDevice = null; - _logger.e("No BLE headset found"); - } - } catch (e) { - _logger.e("Error selecting BLE device: $e"); - _selectedDevice = null; - } - } - - Future _getTemporaryPath() async { - final directory = await getTemporaryDirectory(); - return '${directory.path}/preview_${DateTime.now().millisecondsSinceEpoch}.m4a'; - } - - Future _startPreview() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - - // Don't start if provider is recording - if (_isProviderRecording(context)) { - return; - } - - // Don't start if no BLE device selected - if (_selectedDevice == null) { - if (mounted) { - setState(() => _errorMessage = 'No BLE headset detected'); - } - return; - } - - try { - const encoder = AudioEncoder.wav; - - if (!await _audioRecorder!.isEncoderSupported(encoder)) { - if (mounted) { - setState(() => _errorMessage = 'WAV encoder not supported'); - } - return; - } - - final path = await _getTemporaryPath(); - - final config = RecordConfig( - encoder: encoder, - sampleRate: 48000, - bitRate: 768000, - numChannels: 1, - device: _selectedDevice, - ); - - await _audioRecorder!.start(config, path: path); - await Future.delayed(Duration(milliseconds: 100)); - - _amplitudeSub?.cancel(); - _amplitudeSub = _audioRecorder! - .onAmplitudeChanged(const Duration(milliseconds: 100)) - .listen( - (amp) { - if (mounted) { - setState(() { - final normalized = (amp.current + 50) / 50; - _waveformData.add(normalized.clamp(0.0, 2.0)); - - if (_waveformData.length > 100) { - _waveformData.removeAt(0); - } - }); - } - }, - onError: (error) { - _logger.e("Amplitude stream error: $error"); - }, - ); - + void _checkStreamingStatus() { + final recorderProvider = + Provider.of(context, listen: false); + if (!recorderProvider.isBLEMicrophoneStreamingEnabled) { if (mounted) { setState(() { - _isPreviewRecording = true; - _errorMessage = null; + _isInitializing = false; + _errorMessage = + 'BLE microphone streaming not enabled. Enable it in sensor configuration.'; }); } - } catch (e) { - _logger.e("Preview start error: $e"); - if (mounted) { - setState(() => _errorMessage = 'Failed to start preview: $e'); - } - } - } - - Future _stopPreview() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - if (!_isPreviewRecording) return; - - try { - final tempPath = await _audioRecorder!.stop(); - _amplitudeSub?.cancel(); - _amplitudeSub = null; - - if (tempPath != null) { - try { - final file = File(tempPath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - _logger.e("Error deleting temp preview file: $e"); - } - } - + } else { if (mounted) { setState(() { - _isPreviewRecording = false; - _waveformData.clear(); + _isInitializing = false; + _errorMessage = null; }); } - } catch (e) { - _logger.e("Error stopping preview: $e"); } } @override void dispose() { - // Stop and clean up preview recording - if (Platform.isAndroid && _audioRecorder != null) { - if (_recordState != RecordState.stop) { - _audioRecorder!.stop().then((tempPath) { - if (tempPath != null) { - try { - final file = File(tempPath); - file.exists().then((exists) { - if (exists) { - file.delete(); - } - }); - } catch (e) { - _logger.e("Error deleting temp preview file: $e"); - } - } - }); - } - - _recordSub?.cancel(); - _amplitudeSub?.cancel(); - _audioRecorder!.dispose(); - } - // Dispose all sensor data providers for (var provider in _sensorDataProvider.values) { provider.dispose(); @@ -280,18 +65,25 @@ class _SensorValuesPageState extends State { Widget build(BuildContext context) { return Consumer2( builder: (context, wearablesProvider, recorderProvider, child) { - // Stop preview if provider starts recording - if (Platform.isAndroid && - recorderProvider.isRecording && - _isPreviewRecording) { - _stopPreview(); - } - // Restart preview if provider stops recording - else if (Platform.isAndroid && - !recorderProvider.isRecording && - !_isPreviewRecording && - _audioRecorder != null) { - _initRecording(); + // Update error message if streaming status changes + 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; + }); + } + }); } List charts = []; @@ -392,7 +184,8 @@ class _SensorValuesPageState extends State { ), ), ) - else if (_isPreviewRecording || _isInitializing) + else if (recorderProvider.isBLEMicrophoneStreamingEnabled || + _isInitializing) Card( child: Padding( padding: EdgeInsets.all(16), @@ -414,7 +207,8 @@ class _SensorValuesPageState extends State { ) : CustomPaint( size: Size(double.infinity, 100), - painter: WaveformPainter(_waveformData), + painter: WaveformPainter( + recorderProvider.waveformData), ), ], ), @@ -485,7 +279,8 @@ class _SensorValuesPageState extends State { ), ), ) - else if (_isPreviewRecording || _isInitializing) + else if (recorderProvider.isBLEMicrophoneStreamingEnabled || + _isInitializing) Card( child: Padding( padding: EdgeInsets.all(16), @@ -498,10 +293,18 @@ class _SensorValuesPageState extends State { style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), - CustomPaint( - size: Size(double.infinity, 80), - painter: WaveformPainter(_waveformData), - ), + _isInitializing + ? SizedBox( + height: 80, + child: Center( + child: CircularProgressIndicator(), + ), + ) + : CustomPaint( + size: Size(double.infinity, 80), + painter: WaveformPainter( + recorderProvider.waveformData), + ), ], ), ), From 3f411e6f5351397c80155d42b13c390c2fe7117c Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Tue, 27 Jan 2026 15:49:43 +0100 Subject: [PATCH 8/8] microphone stream can now be turned on and off together with the sensor configuration. OpenEarable firmware needs adjustment for this to work. Haven't disabled it for iOS yet --- .../ble_microphone_streaming_row.dart | 7 +- .../sensor_configuration_device_row.dart | 16 +- .../sensor_configuration_view.dart | 220 +++++++++++------- 3 files changed, 148 insertions(+), 95 deletions(-) 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 index e6efa864..16c0bb5e 100644 --- a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart @@ -4,7 +4,6 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; import '../../../view_models/sensor_recorder_provider.dart'; -/// Widget to control BLE microphone streaming class BLEMicrophoneStreamingRow extends StatelessWidget { const BLEMicrophoneStreamingRow({super.key}); @@ -16,7 +15,8 @@ class BLEMicrophoneStreamingRow extends StatelessWidget { return Consumer( builder: (context, recorderProvider, child) { - final isStreamingEnabled = recorderProvider.isBLEMicrophoneStreamingEnabled; + final isStreamingEnabled = + recorderProvider.isBLEMicrophoneStreamingEnabled; return PlatformListTile( title: PlatformText('BLE Microphone Streaming'), @@ -29,7 +29,8 @@ class BLEMicrophoneStreamingRow extends StatelessWidget { value: isStreamingEnabled, onChanged: (value) async { if (value) { - final success = await recorderProvider.startBLEMicrophoneStream(); + final success = + await recorderProvider.startBLEMicrophoneStream(); if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 8546dcfd..06d8f26d 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -2,7 +2,6 @@ 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/view_models/sensor_configuration_storage.dart'; -import 'package:open_wearable/widgets/sensors/configuration/ble_microphone_streaming_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/save_config_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_value_row.dart'; @@ -67,7 +66,8 @@ class _SensorConfigurationDeviceRowState ?.copyWith(fontWeight: FontWeight.bold), ), if (device.hasCapability()) - StereoPosLabel(device: device.requireCapability()), + StereoPosLabel( + device: device.requireCapability()), ], ), trailing: _buildTabBar(context), @@ -87,7 +87,8 @@ class _SensorConfigurationDeviceRowState _content = [ Padding( padding: const EdgeInsets.all(8.0), - child: PlatformText("This device does not support configuring sensors."), + child: PlatformText( + "This device does not support configuring sensors."), ), ]; }); @@ -119,16 +120,11 @@ class _SensorConfigurationDeviceRowState const SaveConfigRow(), ]); - // Add BLE microphone streaming control (Android only) - content.addAll([ - const Divider(), - const BLEMicrophoneStreamingRow(), - ]); - if (device.hasCapability()) { content.addAll([ const Divider(), - EdgeRecorderPrefixRow(manager: device.requireCapability()), + EdgeRecorderPrefixRow( + manager: device.requireCapability()), ]); } 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 2dedc189..aea1f0fe 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -3,6 +3,7 @@ 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/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/sensors/configuration/sensor_configuration_device_row.dart'; import 'package:provider/provider.dart'; @@ -10,7 +11,7 @@ import 'package:provider/provider.dart'; import '../../../models/logger.dart'; /// A view that displays the sensor configurations of all connected wearables. -/// +/// /// The specific sensor configurations should be made available via the [SensorConfigurationProvider]. class SensorConfigurationView extends StatelessWidget { final VoidCallback? onSetConfigPressed; @@ -26,59 +27,97 @@ class SensorConfigurationView extends StatelessWidget { ); } - Widget _buildSmallScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { + Widget _buildSmallScreenLayout( + BuildContext context, WearablesProvider wearablesProvider) { if (wearablesProvider.wearables.isEmpty) { return Center( - child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), + child: PlatformText("No devices connected", + style: Theme.of(context).textTheme.titleLarge), ); } return Padding( padding: EdgeInsets.all(10), child: wearablesProvider.wearables.isEmpty - ? Center( - child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), - ) - : ListView( - children: [ - ...wearablesProvider.wearables.map((wearable) { - if (wearable.hasCapability()) { - return ChangeNotifierProvider.value( - value: wearablesProvider.getSensorConfigurationProvider(wearable), - child: SensorConfigurationDeviceRow(device: wearable), - ); - } else { - return SensorConfigurationDeviceRow(device: wearable); - } - }), - _buildThroughputWarningBanner(context), - _buildSetConfigButton( - configProviders: wearablesProvider.wearables - // ignore: prefer_iterable_wheretype - .where((wearable) => wearable.hasCapability()) - .map( - (wearable) => wearablesProvider.getSensorConfigurationProvider(wearable), - ).toList(), + ? Center( + child: PlatformText("No devices connected", + style: Theme.of(context).textTheme.titleLarge), + ) + : ListView( + children: [ + ...wearablesProvider.wearables.map((wearable) { + if (wearable.hasCapability()) { + return ChangeNotifierProvider< + SensorConfigurationProvider>.value( + value: wearablesProvider + .getSensorConfigurationProvider(wearable), + child: SensorConfigurationDeviceRow(device: wearable), + ); + } else { + return SensorConfigurationDeviceRow(device: wearable); + } + }), + _buildThroughputWarningBanner(context), + _buildSetConfigButton( + configProviders: wearablesProvider.wearables + // ignore: prefer_iterable_wheretype + .where((wearable) => + wearable.hasCapability()) + .map( + (wearable) => wearablesProvider + .getSensorConfigurationProvider(wearable), + ) + .toList(), + ), + ], ), - ], - ), ); } - Widget _buildSetConfigButton({required List configProviders}) { - return PlatformElevatedButton( - onPressed: () { - for (SensorConfigurationProvider notifier in configProviders) { - logger.d("Setting sensor configurations for notifier: $notifier"); - notifier.getSelectedConfigurations().forEach((entry) { - SensorConfiguration config = entry.$1; - SensorConfigurationValue value = entry.$2; - config.setConfiguration(value); + Widget _buildSetConfigButton( + {required List configProviders}) { + return Builder( + builder: (context) => PlatformElevatedButton( + onPressed: () async { + for (SensorConfigurationProvider notifier in configProviders) { + logger.d("Setting sensor configurations for notifier: $notifier"); + notifier.getSelectedConfigurations().forEach((entry) { + SensorConfiguration config = entry.$1; + SensorConfigurationValue value = entry.$2; + config.setConfiguration(value); + }); + } + + // Check if microphone streaming should be enabled + final recorderProvider = Provider.of( + context, + listen: false, + ); + bool shouldEnableMicrophoneStreaming = + configProviders.any((notifier) { + return notifier.getSelectedConfigurations().any((entry) { + final config = entry.$1; + final selectedOptions = + notifier.getSelectedConfigurationOptions(config); + return config is ConfigurableSensorConfiguration && + config.name.toLowerCase().contains('microphone') && + selectedOptions.any((opt) => opt is StreamSensorConfigOption); + }); }); - } - (onSetConfigPressed ?? () {})(); - }, - child: PlatformText('Set Sensor Configurations'), + + // Start or stop streaming based on configuration + if (shouldEnableMicrophoneStreaming && + !recorderProvider.isBLEMicrophoneStreamingEnabled) { + await recorderProvider.startBLEMicrophoneStream(); + } else if (!shouldEnableMicrophoneStreaming && + recorderProvider.isBLEMicrophoneStreamingEnabled) { + await recorderProvider.stopBLEMicrophoneStream(); + } + + (onSetConfigPressed ?? () {})(); + }, + child: PlatformText('Set Sensor Configurations'), + ), ); } @@ -89,17 +128,18 @@ class SensorConfigurationView extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: RichText( text: TextSpan( - style: Theme.of(context).textTheme.bodyLarge - ?? TextStyle(color: Colors.black, fontSize: 16), + style: Theme.of(context).textTheme.bodyLarge ?? + TextStyle(color: Colors.black, fontSize: 16), children: [ const TextSpan( text: "Info: ", style: TextStyle(fontWeight: FontWeight.bold), ), const TextSpan( - text: "Using too many sensors or setting high sampling rates can exceed the system’s " - "available bandwidth, causing data drops. Limit the number of active sensors and their " - "sampling rates, and record high-rate data directly to the SD card.", + text: + "Using too many sensors or setting high sampling rates can exceed the system’s " + "available bandwidth, causing data drops. Limit the number of active sensors and their " + "sampling rates, and record high-rate data directly to the SD card.", ), ], ), @@ -109,55 +149,68 @@ class SensorConfigurationView extends StatelessWidget { } // ignore: unused_element - Widget _buildLargeScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { + Widget _buildLargeScreenLayout( + BuildContext context, WearablesProvider wearablesProvider) { final List devices = wearablesProvider.wearables; - List tiles = _generateTiles(devices, wearablesProvider.sensorConfigurationProviders); + List tiles = + _generateTiles(devices, wearablesProvider.sensorConfigurationProviders); if (tiles.isNotEmpty) { - tiles.addAll([ - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 230.0, - child: _buildThroughputWarningBanner(context), - ), - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 100.0, - child: _buildSetConfigButton( - configProviders: devices.map((device) => wearablesProvider.getSensorConfigurationProvider(device)).toList(), + tiles.addAll( + [ + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 230.0, + child: _buildThroughputWarningBanner(context), + ), + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 100.0, + child: _buildSetConfigButton( + configProviders: devices + .map((device) => + wearablesProvider.getSensorConfigurationProvider(device)) + .toList(), + ), ), - ),], + ], ); } return StaggeredGrid.count( - crossAxisCount: (MediaQuery.of(context).size.width / 250).floor().clamp(1, 4), // Adaptive grid + crossAxisCount: (MediaQuery.of(context).size.width / 250) + .floor() + .clamp(1, 4), // Adaptive grid mainAxisSpacing: 10, crossAxisSpacing: 10, - children: tiles.isNotEmpty ? tiles : [ - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 100.0, - child: Card( - shape: RoundedRectangleBorder( - side: BorderSide( - color: Colors.grey, - width: 1, - style: BorderStyle.solid, - strokeAlign: -1, + children: tiles.isNotEmpty + ? tiles + : [ + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 100.0, + child: Card( + shape: RoundedRectangleBorder( + side: BorderSide( + color: Colors.grey, + width: 1, + style: BorderStyle.solid, + strokeAlign: -1, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: PlatformText("No devices connected", + style: Theme.of(context).textTheme.titleLarge), + ), + ), ), - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), - ), - ), - ), - ], + ], ); } /// Generates a dynamic quilted grid layout based on the device properties - List _generateTiles(List devices, Map notifiers) { + List _generateTiles(List devices, + Map notifiers) { // Sort devices by size dynamically for a balanced layout devices.sort((a, b) => _getGridSpanForDevice(b) - _getGridSpanForDevice(a)); @@ -181,7 +234,10 @@ class SensorConfigurationView extends StatelessWidget { return 1; // Default size } - int sensorConfigCount = device.requireCapability().sensorConfigurations.length; + int sensorConfigCount = device + .requireCapability() + .sensorConfigurations + .length; return sensorConfigCount.clamp(1, 4); }