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..c2e7d4e2 100644
--- a/open_wearable/ios/Podfile.lock
+++ b/open_wearable/ios/Podfile.lock
@@ -39,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)
@@ -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)
@@ -76,10 +80,12 @@ DEPENDENCIES:
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- Flutter (from `Flutter`)
- flutter_archive (from `.symlinks/plugins/flutter_archive/ios`)
+ - flutter_headset_detector (from `.symlinks/plugins/flutter_headset_detector/ios`)
- mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`)
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- 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`)
@@ -105,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:
@@ -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:
@@ -129,11 +139,13 @@ SPEC CHECKSUMS:
file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe
+ flutter_headset_detector: 37d2407c6c59aa6e8a9daecf732854862ff6dd4a
iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58
mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a
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/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 9400bf94..3cf2dc7b 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.requireCapability(),
- sensorConfigProvider,
- wearable.name.endsWith("L"),
- ),
- );
- },),
+ widget: SelectEarableView(
+ startApp: (wearable, sensorConfigProvider) {
+ return PostureTrackerView(
+ EarableAttitudeTracker(
+ wearable.requireCapability(),
+ sensorConfigProvider,
+ wearable.name.endsWith("L"),
+ ),
+ );
+ },
+ ),
),
AppInfo(
logoPath: "lib/apps/heart_tracker/assets/logo.png",
@@ -47,9 +51,12 @@ List _apps = [
startApp: (wearable, _) {
if (wearable.hasCapability()) {
//TODO: show alert if no ppg sensor is found
- Sensor ppgSensor = wearable.requireCapability().sensors.firstWhere(
- (s) => s.sensorName.toLowerCase() == "photoplethysmography".toLowerCase(),
- );
+ Sensor ppgSensor =
+ wearable.requireCapability().sensors.firstWhere(
+ (s) =>
+ s.sensorName.toLowerCase() ==
+ "photoplethysmography".toLowerCase(),
+ );
return HeartTrackerPage(ppgSensor: ppgSensor);
}
@@ -64,6 +71,15 @@ 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 +91,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/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart
index 11ff9909..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,8 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
+import 'package:path_provider/path_provider.dart';
+import 'package:record/record.dart';
import '../models/logger.dart';
@@ -13,12 +15,164 @@ 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;
+
+ 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();
+
+ try {
+ _selectedBLEDevice = devices.firstWhere(
+ (device) =>
+ device.label.toLowerCase().contains('bluetooth') ||
+ device.label.toLowerCase().contains('ble') ||
+ device.label.toLowerCase().contains('headset') ||
+ device.label.toLowerCase().contains('openearable'),
+ );
+ logger.i("Selected audio input device: ${_selectedBLEDevice!.label}");
+ } catch (e) {
+ _selectedBLEDevice = null;
+ logger.w("No BLE headset found");
+ }
+ } catch (e) {
+ logger.e("Error selecting BLE device: $e");
+ _selectedBLEDevice = null;
+ }
+ }
+
+ Future startBLEMicrophoneStream() async {
+ if (!Platform.isAndroid) {
+ logger.w("BLE microphone streaming only supported on Android");
+ return false;
+ }
+
+ if (_isStreamingActive) {
+ logger.i("BLE microphone streaming already active");
+ return true;
+ }
+
+ try {
+ if (!await _audioRecorder.hasPermission()) {
+ logger.w("No microphone permission for streaming");
+ return false;
+ }
+
+ await _selectBLEDevice();
+
+ if (_selectedBLEDevice == null) {
+ logger.w("No BLE headset detected, cannot start streaming");
+ return false;
+ }
+
+ _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;
@@ -28,10 +182,87 @@ class SensorRecorderProvider with ChangeNotifier {
await _startRecorderForWearable(wearable, dirname);
}
+ await _startAudioRecording(
+ dirname,
+ );
+
notifyListeners();
}
- void stopRecording() {
+ 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");
+ return;
+ }
+
+ await _selectBLEDevice();
+
+ if (_selectedBLEDevice == null) {
+ logger.w("No BLE headset detected, skipping audio recording");
+ return;
+ }
+
+ const encoder = AudioEncoder.wav;
+ if (!await _audioRecorder.isEncoderSupported(encoder)) {
+ logger.w("WAV encoder not supported");
+ return;
+ }
+
+ final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-');
+ final audioPath = '$recordingFolderPath/audio_$timestamp.wav';
+
+ final config = RecordConfig(
+ encoder: encoder,
+ sampleRate: 48000, // Set to 48kHz for BLE audio quality
+ bitRate: 768000, // 16-bit * 48kHz * 1 channel = 768 kbps
+ numChannels: 1,
+ device: _selectedBLEDevice,
+ );
+
+ await _audioRecorder.start(config, path: audioPath);
+ _currentAudioPath = audioPath;
+ _isAudioRecording = true;
+
+ logger.i(
+ "Audio recording started: $_currentAudioPath with device: ${_selectedBLEDevice?.label ?? 'default'}");
+
+ _amplitudeSub = _audioRecorder
+ .onAmplitudeChanged(const Duration(milliseconds: 100))
+ .listen((amp) {
+ final normalized = (amp.current + 50) / 50;
+ _waveformData.add(normalized.clamp(0.0, 2.0));
+
+ if (_waveformData.length > 100) {
+ _waveformData.removeAt(0);
+ }
+
+ notifyListeners();
+ });
+ } catch (e) {
+ logger.e("Failed to start audio recording: $e");
+ _isAudioRecording = false;
+ }
+ }
+
+ void stopRecording() async {
_isRecording = false;
_recordingStart = null;
for (Wearable wearable in _recorders.keys) {
@@ -45,6 +276,26 @@ 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");
+ }
+
+ // Restart streaming if it was enabled before recording
+ if (_isBLEMicrophoneStreamingEnabled && !_isStreamingActive) {
+ unawaited(startBLEMicrophoneStream());
+ }
+
notifyListeners();
}
@@ -75,7 +326,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);
}
@@ -156,4 +408,27 @@ class SensorRecorderProvider with ChangeNotifier {
);
}
}
+
+ @override
+ void dispose() {
+ // Stop streaming
+ stopBLEMicrophoneStream();
+
+ // Stop recording
+ _audioRecorder.stop().then((_) {
+ _audioRecorder.dispose();
+ }).catchError((e) {
+ logger.e("Error stopping audio in dispose: $e");
+ });
+
+ _amplitudeSub?.cancel();
+ _waveformData.clear();
+
+ for (final wearable in _recorders.keys) {
+ _disposeWearable(wearable);
+ }
+ _recorders.clear();
+
+ super.dispose();
+ }
}
diff --git a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart
new file mode 100644
index 00000000..16c0bb5e
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart
@@ -0,0 +1,54 @@
+import 'dart:io';
+import 'package:flutter/material.dart';
+import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
+import 'package:provider/provider.dart';
+import '../../../view_models/sensor_recorder_provider.dart';
+
+class BLEMicrophoneStreamingRow extends StatelessWidget {
+ const BLEMicrophoneStreamingRow({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ if (!Platform.isAndroid) {
+ return const SizedBox.shrink();
+ }
+
+ return Consumer(
+ builder: (context, recorderProvider, child) {
+ final isStreamingEnabled =
+ recorderProvider.isBLEMicrophoneStreamingEnabled;
+
+ return PlatformListTile(
+ title: PlatformText('BLE Microphone Streaming'),
+ subtitle: PlatformText(
+ isStreamingEnabled
+ ? 'Microphone stream is active'
+ : 'Enable to start microphone streaming',
+ ),
+ trailing: PlatformSwitch(
+ value: isStreamingEnabled,
+ onChanged: (value) async {
+ if (value) {
+ final success =
+ await recorderProvider.startBLEMicrophoneStream();
+ if (!success && context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: PlatformText(
+ 'Failed to start BLE microphone streaming. '
+ 'Make sure a BLE headset is connected and microphone permission is granted.',
+ ),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ } else {
+ await recorderProvider.stopBLEMicrophoneStream();
+ }
+ },
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart
index 78224085..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
@@ -66,7 +66,8 @@ class _SensorConfigurationDeviceRowState
?.copyWith(fontWeight: FontWeight.bold),
),
if (device.hasCapability())
- StereoPosLabel(device: device.requireCapability()),
+ StereoPosLabel(
+ device: device.requireCapability()),
],
),
trailing: _buildTabBar(context),
@@ -86,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."),
),
];
});
@@ -121,7 +123,8 @@ class _SensorConfigurationDeviceRowState
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);
}
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..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
@@ -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);
},
),
@@ -642,14 +433,33 @@ 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) {
- await _showErrorDialog(
+ await LocalRecorderDialogs
+ .showErrorDialog(
+ this.context,
'Could not open file: ${result.message}',
);
}
@@ -671,81 +481,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/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 97b6101f..36d9d6bc 100644
--- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart
+++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart
@@ -2,48 +2,130 @@ 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_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:provider/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 = {};
- SensorValuesPage({super.key});
+ String? _errorMessage;
+ bool _isInitializing = true;
+
+ @override
+ void initState() {
+ super.initState();
+ if (Platform.isAndroid) {
+ _checkStreamingStatus();
+ }
+ }
+
+ void _checkStreamingStatus() {
+ final recorderProvider =
+ Provider.of(context, listen: false);
+ if (!recorderProvider.isBLEMicrophoneStreamingEnabled) {
+ if (mounted) {
+ setState(() {
+ _isInitializing = false;
+ _errorMessage =
+ 'BLE microphone streaming not enabled. Enable it in sensor configuration.';
+ });
+ }
+ } else {
+ if (mounted) {
+ setState(() {
+ _isInitializing = false;
+ _errorMessage = null;
+ });
+ }
+ }
+ }
+
+ @override
+ void dispose() {
+ // 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) {
+ return Consumer2(
+ builder: (context, wearablesProvider, recorderProvider, child) {
+ // 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 = [];
+
for (var wearable in wearablesProvider.wearables) {
if (wearable.hasCapability()) {
- for (Sensor sensor in wearable.requireCapability().sensors) {
+ for (Sensor sensor
+ in wearable.requireCapability().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.hasCapability()
- && device == key.$1
- && device.requireCapability().sensors.contains(key.$2),),
+ _sensorDataProvider.removeWhere(
+ (key, _) => !wearablesProvider.wearables.any(
+ (device) =>
+ device.hasCapability() &&
+ device == key.$1 &&
+ device
+ .requireCapability()
+ .sensors
+ .contains(key.$2),
+ ),
);
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);
}
},
);
@@ -51,49 +133,309 @@ class SensorValuesPage extends StatelessWidget {
);
}
- Widget _buildSmallScreenLayout(BuildContext context, List charts) {
- return Padding(
+ Widget _buildSmallScreenLayout(
+ BuildContext context,
+ List charts,
+ SensorRecorderProvider recorderProvider,
+ ) {
+ final showRecorderWaveform = recorderProvider.isRecording;
+ return SingleChildScrollView(
padding: EdgeInsets.all(10),
- child: charts.isEmpty
- ? Center(
- child: PlatformText("No sensors connected", style: Theme.of(context).textTheme.titleLarge),
- )
- : ListView(
- children: charts,
- ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (charts.isEmpty)
+ Center(
+ child: PlatformText(
+ "No sensors connected",
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ )
+ 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),
+ ),
+ ],
+ ),
+ ),
+ )
+ else if (recorderProvider.isBLEMicrophoneStreamingEnabled ||
+ _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(
+ recorderProvider.waveformData),
+ ),
+ ],
+ ),
+ ),
+ )
+ 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) {
- 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,
+ Widget _buildLargeScreenLayout(
+ BuildContext context,
+ List charts,
+ SensorRecorderProvider recorderProvider,
+ ) {
+ final showRecorderWaveform = recorderProvider.isRecording;
+
+ return SingleChildScrollView(
+ padding: EdgeInsets.all(10),
+ child: Column(
+ children: [
+ 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),
+ ),
+ ],
+ ),
+ ),
+ )
+ else if (recorderProvider.isBLEMicrophoneStreamingEnabled ||
+ _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),
+ _isInitializing
+ ? SizedBox(
+ height: 80,
+ child: Center(
+ child: CircularProgressIndicator(),
+ ),
+ )
+ : CustomPaint(
+ size: Size(double.infinity, 80),
+ painter: WaveformPainter(
+ recorderProvider.waveformData),
+ ),
+ ],
+ ),
+ ),
),
- borderRadius: BorderRadius.circular(10),
- ),
- child: Center(
- child: PlatformText("No sensors available", style: Theme.of(context).textTheme.titleLarge),
+ if (_errorMessage != null)
+ Card(
+ child: Padding(
+ padding: EdgeInsets.all(16),
+ child: PlatformText(
+ _errorMessage!,
+ style: TextStyle(color: Colors.red),
+ ),
+ ),
+ ),
+ ],
+ // Sensor charts grid
+ GridView.builder(
+ gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
+ maxCrossAxisExtent: 500,
+ childAspectRatio: 1.5,
+ crossAxisSpacing: 10,
+ mainAxisSpacing: 10,
),
- );
- }
- return charts[index];
- },
+ 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 how many bars can fit in the available width
+ final maxBars = (size.width / spacing).floor();
+ final startIndex =
+ waveformData.length > maxBars ? waveformData.length - maxBars : 0;
+
+ // Calculate starting position (always start at 0 or align right)
+ final visibleData = waveformData.sublist(startIndex);
+ final totalWaveformWidth = visibleData.length * spacing;
+ final startX = size.width - totalWaveformWidth;
+
+ // Draw each amplitude value as a vertical bar
+ for (int i = 0; i < visibleData.length; i++) {
+ final x = startX + (i * spacing);
+ final amplitude = visibleData[i];
+
+ // Scale amplitude to fit within the canvas height
+ final barHeight = amplitude * centerY * 0.8;
+
+ // Draw top half of the bar (above center line)
+ final topY = centerY - barHeight;
+ final bottomY = centerY + barHeight;
+
+ // Draw the vertical line from top to bottom
+ canvas.drawLine(
+ Offset(x, topY),
+ Offset(x, bottomY),
+ paint,
+ );
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant WaveformPainter oldDelegate) {
+ return oldDelegate.waveformData.length != waveformData.length ||
+ oldDelegate.waveColor != waveColor;
+ }
+}
diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc
index 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 78870ac3..ec3c7b10 100644
--- a/open_wearable/pubspec.lock
+++ b/open_wearable/pubspec.lock
@@ -21,10 +21,10 @@ packages:
dependency: transitive
description:
name: bloc
- sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81
+ sha256: a2cebb899f91d36eeeaa55c7b20b5915db5a9df1b8fd4a3c9c825e22e474537d
url: "https://pub.dev"
source: hosted
- version: "9.2.0"
+ version: "9.1.0"
bluez:
dependency: transitive
description:
@@ -125,10 +125,10 @@ packages:
dependency: transitive
description:
name: equatable
- sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
+ sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
url: "https://pub.dev"
source: hosted
- version: "2.0.8"
+ version: "2.0.7"
fake_async:
dependency: transitive
description:
@@ -141,10 +141,10 @@ packages:
dependency: transitive
description:
name: ffi
- sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
+ sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
- version: "2.1.5"
+ version: "2.1.4"
file:
dependency: transitive
description:
@@ -157,10 +157,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
- sha256: d974b6ba2606371ac71dd94254beefb6fa81185bde0b59bdc1df09885da85fde
+ sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200"
url: "https://pub.dev"
source: hosted
- version: "10.3.8"
+ version: "10.3.7"
file_selector:
dependency: "direct main"
description:
@@ -181,10 +181,10 @@ packages:
dependency: transitive
description:
name: file_selector_ios
- sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
+ sha256: "628ec99afd8bb40620b4c8707d5fd5fc9e89d83e9b0b327d471fe5f7bc5fc33f"
url: "https://pub.dev"
source: hosted
- version: "0.5.3+5"
+ version: "0.5.3+4"
file_selector_linux:
dependency: transitive
description:
@@ -270,6 +270,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:
@@ -476,10 +484,10 @@ packages:
dependency: "direct main"
description:
name: open_file
- sha256: b22decdae85b459eac24aeece48f33845c6f16d278a9c63d75c5355345ca236b
+ sha256: d17e2bddf5b278cb2ae18393d0496aa4f162142ba97d1a9e0c30d476adf99c0e
url: "https://pub.dev"
source: hosted
- version: "3.5.11"
+ version: "3.5.10"
open_file_android:
dependency: transitive
description:
@@ -492,10 +500,10 @@ packages:
dependency: transitive
description:
name: open_file_ios
- sha256: a5acd07ba1f304f807a97acbcc489457e1ad0aadff43c467987dd9eef814098f
+ sha256: "02996f01e5f6863832068e97f8f3a5ef9b613516db6897f373b43b79849e4d07"
url: "https://pub.dev"
source: hosted
- version: "1.0.4"
+ version: "1.0.3"
open_file_linux:
dependency: transitive
description:
@@ -508,10 +516,10 @@ packages:
dependency: transitive
description:
name: open_file_mac
- sha256: cd293f6750de6438ab2390513c99128ade8c974825d4d8128886d1cda8c64d01
+ sha256: "1440b1e37ceb0642208cfeb2c659c6cda27b25187a90635c9d1acb7d0584d324"
url: "https://pub.dev"
source: hosted
- version: "1.0.4"
+ version: "1.0.3"
open_file_platform_interface:
dependency: transitive
description:
@@ -664,6 +672,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:
@@ -696,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:
@@ -724,18 +804,18 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
- sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
+ sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
- version: "2.5.4"
+ version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
- sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
+ sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b"
url: "https://pub.dev"
source: hosted
- version: "2.4.18"
+ version: "2.4.17"
shared_preferences_foundation:
dependency: transitive
description:
@@ -998,5 +1078,5 @@ packages:
source: hosted
version: "6.6.1"
sdks:
- dart: ">=3.10.0 <4.0.0"
- flutter: ">=3.38.0"
+ dart: ">=3.9.0 <4.0.0"
+ flutter: ">=3.35.0"
diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml
index 4f1365d1..243bf66b 100644
--- a/open_wearable/pubspec.yaml
+++ b/open_wearable/pubspec.yaml
@@ -54,6 +54,9 @@ dependencies:
shared_preferences: ^2.5.3
url_launcher: ^6.3.2
go_router: ^14.6.2
+ playback_capture: ^0.0.4
+ flutter_headset_detector: ^3.1.0
+ 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