diff --git a/open_wearable/android/app/src/main/AndroidManifest.xml b/open_wearable/android/app/src/main/AndroidManifest.xml
index 8cb9f463..eb49e63d 100644
--- a/open_wearable/android/app/src/main/AndroidManifest.xml
+++ b/open_wearable/android/app/src/main/AndroidManifest.xml
@@ -40,6 +40,8 @@
+
+
diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock
index 110052ae..eddf6e53 100644
--- a/open_wearable/ios/Podfile.lock
+++ b/open_wearable/ios/Podfile.lock
@@ -55,6 +55,8 @@ PODS:
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
+ - record_ios (1.2.0):
+ - Flutter
- SDWebImage (5.21.5):
- SDWebImage/Core (= 5.21.5)
- SDWebImage/Core (5.21.5)
@@ -85,6 +87,7 @@ DEPENDENCIES:
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
+ - record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
@@ -121,6 +124,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
+ record_ios:
+ :path: ".symlinks/plugins/record_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -145,6 +150,7 @@ SPEC CHECKSUMS:
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
+ record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
diff --git a/open_wearable/lib/apps/widgets/apps_page.dart b/open_wearable/lib/apps/widgets/apps_page.dart
index 936ddd6f..5190f309 100644
--- a/open_wearable/lib/apps/widgets/apps_page.dart
+++ b/open_wearable/lib/apps/widgets/apps_page.dart
@@ -97,7 +97,8 @@ final List _apps = [
wearable.requireCapability(),
sensorConfigProvider,
wearable.hasCapability() &&
- await wearable.requireCapability().position == DevicePosition.left,
+ await wearable.requireCapability().position ==
+ DevicePosition.left,
),
);
},
diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart
index e15d5580..b1fac3ff 100644
--- a/open_wearable/lib/view_models/sensor_recorder_provider.dart
+++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart
@@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
+import 'package:path_provider/path_provider.dart';
+import 'package:record/record.dart';
import '../models/logger.dart';
import '../models/sensor_streams.dart';
@@ -28,17 +30,162 @@ class SensorRecorderProvider with ChangeNotifier {
bool _hasSensorsConnected = false;
String? _currentDirectory;
DateTime? _recordingStart;
+ final AudioRecorder _audioRecorder = AudioRecorder();
+ bool _isAudioRecording = false;
+ String? _currentAudioPath;
+ StreamSubscription? _amplitudeSub;
bool get isRecording => _isRecording;
bool get hasSensorsConnected => _hasSensorsConnected;
String? get currentDirectory => _currentDirectory;
DateTime? get recordingStart => _recordingStart;
- Future startRecording(String dirname) async {
- if (_isRecording) {
+ final List _waveformData = [];
+ List get waveformData => List.unmodifiable(_waveformData);
+
+ InputDevice? _selectedBLEDevice;
+
+ bool _isBLEMicrophoneStreamingEnabled = false;
+ bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled;
+
+ // Path for temporary streaming file
+ String? _streamingPath;
+ bool _isStreamingActive = false;
+
+ Future _selectBLEDevice() async {
+ try {
+ final devices = await _audioRecorder.listInputDevices();
+
+ try {
+ _selectedBLEDevice = devices.firstWhere(
+ (device) =>
+ device.label.toLowerCase().contains('bluetooth') ||
+ device.label.toLowerCase().contains('ble') ||
+ device.label.toLowerCase().contains('headset') ||
+ device.label.toLowerCase().contains('openearable'),
+ );
+ logger.i("Selected audio input device: ${_selectedBLEDevice!.label}");
+ } catch (e) {
+ _selectedBLEDevice = null;
+ logger.w("No BLE headset found");
+ }
+ } catch (e) {
+ logger.e("Error selecting BLE device: $e");
+ _selectedBLEDevice = null;
+ }
+ }
+
+ Future startBLEMicrophoneStream() async {
+ if (!Platform.isAndroid) {
+ logger.w("BLE microphone streaming only supported on Android");
+ return false;
+ }
+
+ if (_isStreamingActive) {
+ logger.i("BLE microphone streaming already active");
+ return true;
+ }
+
+ try {
+ if (!await _audioRecorder.hasPermission()) {
+ logger.w("No microphone permission for streaming");
+ return false;
+ }
+
+ await _selectBLEDevice();
+
+ if (_selectedBLEDevice == null) {
+ logger.w("No BLE headset detected, cannot start streaming");
+ return false;
+ }
+
+ const encoder = AudioEncoder.wav;
+ if (!await _audioRecorder.isEncoderSupported(encoder)) {
+ logger.w("WAV encoder not supported");
+ return false;
+ }
+
+ final tempDir = await getTemporaryDirectory();
+ _streamingPath =
+ '${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav';
+
+ final config = RecordConfig(
+ encoder: encoder,
+ sampleRate: 48000,
+ bitRate: 768000,
+ numChannels: 1,
+ device: _selectedBLEDevice,
+ );
+
+ await _audioRecorder.start(config, path: _streamingPath!);
+ _isStreamingActive = true;
+ _isBLEMicrophoneStreamingEnabled = true;
+
+ // Set up amplitude monitoring for waveform display
+ _amplitudeSub?.cancel();
+ _amplitudeSub = _audioRecorder
+ .onAmplitudeChanged(const Duration(milliseconds: 100))
+ .listen((amp) {
+ final normalized = (amp.current + 50) / 50;
+ _waveformData.add(normalized.clamp(0.0, 2.0));
+
+ if (_waveformData.length > 100) {
+ _waveformData.removeAt(0);
+ }
+
+ notifyListeners();
+ });
+
+ logger.i(
+ "BLE microphone streaming started with device: ${_selectedBLEDevice!.label}",
+ );
+ notifyListeners();
+ return true;
+ } catch (e) {
+ logger.e("Failed to start BLE microphone streaming: $e");
+ _isStreamingActive = false;
+ _isBLEMicrophoneStreamingEnabled = false;
+ _streamingPath = null;
+ notifyListeners();
+ return false;
+ }
+ }
+
+ Future stopBLEMicrophoneStream() async {
+ if (!_isStreamingActive) {
return;
}
+ try {
+ await _audioRecorder.stop();
+ _amplitudeSub?.cancel();
+ _amplitudeSub = null;
+ _isStreamingActive = false;
+ _isBLEMicrophoneStreamingEnabled = false;
+ _waveformData.clear();
+
+ // Clean up temporary streaming file
+ if (_streamingPath != null) {
+ try {
+ final file = File(_streamingPath!);
+ if (await file.exists()) {
+ await file.delete();
+ }
+ } catch (e) {
+ // Ignore cleanup errors
+ }
+ _streamingPath = null;
+ }
+
+ logger.i("BLE microphone streaming stopped");
+ notifyListeners();
+ } catch (e) {
+ logger.e("Error stopping BLE microphone streaming: $e");
+ }
+ }
+
+ void startRecording(String dirname) async {
+ _isRecording = true;
_currentDirectory = dirname;
_recordingStart = DateTime.now();
@@ -57,12 +204,136 @@ class SensorRecorderProvider with ChangeNotifier {
notifyListeners();
rethrow;
}
+
+ await _startAudioRecording(
+ dirname,
+ );
+
+ notifyListeners();
+ }
+
+ Future _startAudioRecording(String recordingFolderPath) async {
+ if (!Platform.isAndroid) return;
+
+ // Only start recording if BLE microphone streaming is enabled
+ if (!_isBLEMicrophoneStreamingEnabled) {
+ logger
+ .w("BLE microphone streaming not enabled, skipping audio recording");
+ return;
+ }
+
+ // Stop streaming session before starting actual recording
+ if (_isStreamingActive) {
+ await _audioRecorder.stop();
+ _amplitudeSub?.cancel();
+ _amplitudeSub = null;
+ _isStreamingActive = false;
+
+ // Clean up temporary streaming file
+ if (_streamingPath != null) {
+ try {
+ final file = File(_streamingPath!);
+ if (await file.exists()) {
+ await file.delete();
+ }
+ } catch (e) {
+ // Ignore cleanup errors
+ }
+ _streamingPath = null;
+ }
+ }
+
+ try {
+ if (!await _audioRecorder.hasPermission()) {
+ logger.w("No microphone permission for recording");
+ return;
+ }
+
+ await _selectBLEDevice();
+
+ if (_selectedBLEDevice == null) {
+ logger.w("No BLE headset detected, skipping audio recording");
+ return;
+ }
+
+ const encoder = AudioEncoder.wav;
+ if (!await _audioRecorder.isEncoderSupported(encoder)) {
+ logger.w("WAV encoder not supported");
+ return;
+ }
+
+ final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-');
+ final audioPath = '$recordingFolderPath/audio_$timestamp.wav';
+
+ final config = RecordConfig(
+ encoder: encoder,
+ sampleRate: 48000, // Set to 48kHz for BLE audio quality
+ bitRate: 768000, // 16-bit * 48kHz * 1 channel = 768 kbps
+ numChannels: 1,
+ device: _selectedBLEDevice,
+ );
+
+ await _audioRecorder.start(config, path: audioPath);
+ _currentAudioPath = audioPath;
+ _isAudioRecording = true;
+
+ logger.i(
+ "Audio recording started: $_currentAudioPath with device: ${_selectedBLEDevice?.label ?? 'default'}",
+ );
+
+ _amplitudeSub = _audioRecorder
+ .onAmplitudeChanged(const Duration(milliseconds: 100))
+ .listen((amp) {
+ final normalized = (amp.current + 50) / 50;
+ _waveformData.add(normalized.clamp(0.0, 2.0));
+
+ if (_waveformData.length > 100) {
+ _waveformData.removeAt(0);
+ }
+
+ notifyListeners();
+ });
+ } catch (e) {
+ logger.e("Failed to start audio recording: $e");
+ _isAudioRecording = false;
+ }
}
- void stopRecording() {
+ void stopRecording(bool turnOffMic) async {
_isRecording = false;
_recordingStart = null;
- _stopAllRecorderStreams();
+ for (Wearable wearable in _recorders.keys) {
+ for (Sensor sensor in _recorders[wearable]!.keys) {
+ Recorder? recorder = _recorders[wearable]?[sensor];
+ if (recorder != null) {
+ recorder.stop();
+ logger.i(
+ 'Stopped recording for ${wearable.name} - ${sensor.sensorName}',
+ );
+ }
+ }
+ }
+ try {
+ if (_isAudioRecording) {
+ final path = await _audioRecorder.stop();
+ _amplitudeSub?.cancel();
+ _amplitudeSub = null;
+ _isAudioRecording = false;
+
+ logger.i("Audio recording saved to: $path");
+ _currentAudioPath = null;
+ }
+ } catch (e) {
+ logger.e("Error stopping audio recording: $e");
+ }
+
+ // Restart streaming if it was enabled before recording
+ if (!turnOffMic &&
+ _isBLEMicrophoneStreamingEnabled &&
+ !_isStreamingActive) {
+ unawaited(startBLEMicrophoneStream());
+ }
+
notifyListeners();
}
@@ -195,10 +466,24 @@ class SensorRecorderProvider with ChangeNotifier {
@override
void dispose() {
- for (final wearable in _recorders.keys.toList()) {
+ // Stop streaming
+ stopBLEMicrophoneStream();
+
+ // Stop recording
+ _audioRecorder.stop().then((_) {
+ _audioRecorder.dispose();
+ }).catchError((e) {
+ logger.e("Error stopping audio in dispose: $e");
+ });
+
+ _amplitudeSub?.cancel();
+ _waveformData.clear();
+
+ for (final wearable in _recorders.keys) {
_disposeWearable(wearable);
}
_recorders.clear();
+
super.dispose();
}
}
diff --git a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart
new file mode 100644
index 00000000..16c0bb5e
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart
@@ -0,0 +1,54 @@
+import 'dart:io';
+import 'package:flutter/material.dart';
+import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
+import 'package:provider/provider.dart';
+import '../../../view_models/sensor_recorder_provider.dart';
+
+class BLEMicrophoneStreamingRow extends StatelessWidget {
+ const BLEMicrophoneStreamingRow({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ if (!Platform.isAndroid) {
+ return const SizedBox.shrink();
+ }
+
+ return Consumer(
+ builder: (context, recorderProvider, child) {
+ final isStreamingEnabled =
+ recorderProvider.isBLEMicrophoneStreamingEnabled;
+
+ return PlatformListTile(
+ title: PlatformText('BLE Microphone Streaming'),
+ subtitle: PlatformText(
+ isStreamingEnabled
+ ? 'Microphone stream is active'
+ : 'Enable to start microphone streaming',
+ ),
+ trailing: PlatformSwitch(
+ value: isStreamingEnabled,
+ onChanged: (value) async {
+ if (value) {
+ final success =
+ await recorderProvider.startBLEMicrophoneStream();
+ if (!success && context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: PlatformText(
+ 'Failed to start BLE microphone streaming. '
+ 'Make sure a BLE headset is connected and microphone permission is granted.',
+ ),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ } else {
+ await recorderProvider.stopBLEMicrophoneStream();
+ }
+ },
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart
index 0f7d46d1..a197d6f0 100644
--- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart
+++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart' show setEquals;
+import 'dart:io';
import 'package:flutter/material.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
@@ -38,7 +39,17 @@ class SensorConfigurationDetailView extends StatelessWidget {
final targetOptions = sensorConfiguration is ConfigurableSensorConfiguration
? (sensorConfiguration as ConfigurableSensorConfiguration)
.availableOptions
- .toList(growable: false)
+ .where((option) {
+ // Only allow microphone stream on Android
+ if (Platform.isAndroid) return true;
+ final isMicrophone =
+ sensorConfiguration.name.toLowerCase().contains('microphone');
+ final isStreamOption = option is StreamSensorConfigOption;
+ if (isMicrophone && isStreamOption) {
+ return false;
+ }
+ return true;
+ }).toList(growable: false)
: const [];
return ListView(
@@ -350,9 +361,8 @@ class _OptionToggleTile extends StatelessWidget {
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
- color: selected
- ? accentColor.withValues(alpha: 0.06)
- : Colors.transparent,
+ color:
+ selected ? accentColor.withValues(alpha: 0.06) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: (selected ? accentColor : colorScheme.outlineVariant)
diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart
index 1ed81ef6..860be9a4 100644
--- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart
+++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart' show setEquals;
+import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart';
diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart
index cc9f6411..dd8a3b21 100644
--- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart
+++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart
@@ -1,9 +1,11 @@
+import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
import 'package:open_wearable/models/wearable_display_group.dart';
import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
+import 'package:open_wearable/view_models/sensor_recorder_provider.dart';
import 'package:open_wearable/view_models/wearables_provider.dart';
import 'package:open_wearable/widgets/app_toast.dart';
import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
@@ -319,6 +321,30 @@ class SensorConfigurationView extends StatelessWidget {
icon: Icons.check_circle_outline_rounded,
);
+ final recorderProvider = Provider.of(
+ context,
+ listen: false,
+ );
+ bool shouldEnableMicrophoneStreaming = Platform.isAndroid &&
+ targets.any((target) {
+ return target.provider.getSelectedConfigurations().any((entry) {
+ final config = entry.$1;
+ final selectedOptions =
+ target.provider.getSelectedConfigurationOptions(config);
+ return config is ConfigurableSensorConfiguration &&
+ config.name.toLowerCase().contains('microphone') &&
+ selectedOptions.any((opt) => opt is StreamSensorConfigOption);
+ });
+ });
+
+ if (shouldEnableMicrophoneStreaming &&
+ !recorderProvider.isBLEMicrophoneStreamingEnabled) {
+ await recorderProvider.startBLEMicrophoneStream();
+ } else if (!shouldEnableMicrophoneStreaming &&
+ recorderProvider.isBLEMicrophoneStreamingEnabled) {
+ await recorderProvider.stopBLEMicrophoneStream();
+ }
+
(onSetConfigPressed ?? () {})();
}
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart
new file mode 100644
index 00000000..ffa6c76e
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart
@@ -0,0 +1,50 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
+
+class LocalRecorderDialogs {
+ static Future askOverwriteConfirmation(
+ BuildContext context,
+ String dirPath,
+ ) async {
+ return await showPlatformDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: PlatformText('Directory not empty'),
+ content: PlatformText(
+ '"$dirPath" already contains files or folders.\n\n'
+ 'New sensor files will be added; existing files with the same '
+ 'names will be overwritten. Continue anyway?'),
+ actions: [
+ PlatformTextButton(
+ onPressed: () => Navigator.pop(ctx, false),
+ child: PlatformText('Cancel'),
+ ),
+ PlatformTextButton(
+ onPressed: () => Navigator.pop(ctx, true),
+ child: PlatformText('Continue'),
+ ),
+ ],
+ ),
+ ) ??
+ false;
+ }
+
+ static Future showErrorDialog(
+ BuildContext context,
+ String message,
+ ) async {
+ await showPlatformDialog(
+ context: context,
+ builder: (_) => PlatformAlertDialog(
+ title: PlatformText('Error'),
+ content: PlatformText(message),
+ actions: [
+ PlatformDialogAction(
+ child: PlatformText('OK'),
+ onPressed: () => Navigator.pop(context),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart
new file mode 100644
index 00000000..db4b5bf4
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart
@@ -0,0 +1,53 @@
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:path_provider/path_provider.dart';
+
+class Files {
+ static Future pickDirectory() async {
+ if (!Platform.isIOS && !kIsWeb) {
+ final recordingName =
+ 'OpenWearable_Recording_${DateTime.now().toIso8601String()}';
+ Directory? appDir = await getExternalStorageDirectory();
+ if (appDir == null) return null;
+
+ String dirPath = '${appDir.path}/$recordingName';
+ Directory dir = Directory(dirPath);
+ if (!await dir.exists()) {
+ await dir.create(recursive: true);
+ }
+ return dirPath;
+ }
+
+ if (Platform.isIOS) {
+ final recordingName =
+ 'OpenWearable_Recording_${DateTime.now().toIso8601String()}';
+ String dirPath = '${(await getIOSDirectory()).path}/$recordingName';
+ Directory dir = Directory(dirPath);
+ if (!await dir.exists()) {
+ await dir.create(recursive: true);
+ }
+ return dirPath;
+ }
+
+ return null;
+ }
+
+ static Future getIOSDirectory() async {
+ Directory appDocDir = await getApplicationDocumentsDirectory();
+ final dirPath = '${appDocDir.path}/Recordings';
+ final dir = Directory(dirPath);
+
+ if (!await dir.exists()) {
+ await dir.create(recursive: true);
+ }
+
+ return dir;
+ }
+
+ static Future isDirectoryEmpty(String path) async {
+ final dir = Directory(path);
+ if (!await dir.exists()) return true;
+ return await dir.list(followLinks: false).isEmpty;
+ }
+}
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart
index 0a7c6846..4d9fd741 100644
--- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart
+++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart
@@ -5,6 +5,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:logger/logger.dart';
+import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart';
+import 'package:open_wearable/widgets/sensors/local_recorder/recording_controls.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:provider/provider.dart';
import 'package:open_file/open_file.dart';
import 'package:provider/provider.dart';
import 'package:open_wearable/view_models/sensor_recorder_provider.dart';
@@ -16,6 +20,7 @@ import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_reco
import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_see_all_recordings_card.dart';
import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_storage.dart';
import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
+import 'local_recorder_files.dart';
Logger _logger = Logger();
@@ -120,8 +125,11 @@ class _LocalRecorderViewState extends State {
_listRecordings();
} catch (e) {
_logger.e('Error deleting recording: $e');
- _showErrorDialog('Failed to delete recording: $e');
- return false;
+ if (!mounted) return false;
+ LocalRecorderDialogs.showErrorDialog(
+ context,
+ 'Failed to delete recording: $e',
+ );
}
}
return true;
@@ -137,8 +145,9 @@ class _LocalRecorderViewState extends State {
});
try {
- recorder.stopRecording();
- if (mode == _StopRecordingMode.stopAndTurnOffSensors) {
+ bool turnOffSensors = mode == _StopRecordingMode.stopAndTurnOffSensors;
+ recorder.stopRecording(turnOffSensors);
+ if (turnOffSensors) {
final wearablesProvider = context.read();
final futures = wearablesProvider.sensorConfigurationProviders.values
.map((provider) => provider.turnOffAllSensors());
@@ -157,32 +166,6 @@ class _LocalRecorderViewState extends State {
}
}
- void _startRecordingTimer(DateTime? start) {
- final reference = start ?? DateTime.now();
- _activeRecordingStart = reference;
- _recordingTimer?.cancel();
- setState(() {
- _elapsedRecording = DateTime.now().difference(reference);
- });
- _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
- if (!mounted) return;
- setState(() {
- final base = _activeRecordingStart ?? reference;
- _elapsedRecording = DateTime.now().difference(base);
- });
- });
- }
-
- void _stopRecordingTimer() {
- _recordingTimer?.cancel();
- _recordingTimer = null;
- _activeRecordingStart = null;
- if (!mounted) return;
- setState(() {
- _elapsedRecording = Duration.zero;
- });
- }
-
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
final hours = twoDigits(duration.inHours);
@@ -191,50 +174,16 @@ class _LocalRecorderViewState extends State {
return '$hours:$minutes:$seconds';
}
- @override
- void dispose() {
- _recordingTimer?.cancel();
- _recorder?.removeListener(_handleRecorderUpdate);
- super.dispose();
- }
-
- void _handleRecorderUpdate() {
- final recorder = _recorder;
- if (recorder == null) return;
- final isRecording = recorder.isRecording;
- final start = recorder.recordingStart;
- if (isRecording && !_lastRecordingState) {
- _startRecordingTimer(start);
- } else if (!isRecording && _lastRecordingState) {
- _stopRecordingTimer();
- } else if (isRecording &&
- _lastRecordingState &&
- start != null &&
- _activeRecordingStart != null &&
- start != _activeRecordingStart) {
- _startRecordingTimer(start);
- }
- _lastRecordingState = isRecording;
- }
-
- @override
- void didChangeDependencies() {
- super.didChangeDependencies();
- final nextRecorder = context.watch();
- if (!identical(_recorder, nextRecorder)) {
- _recorder?.removeListener(_handleRecorderUpdate);
- _recorder = nextRecorder;
- _recorder?.addListener(_handleRecorderUpdate);
- _handleRecorderUpdate();
- }
- }
-
Future _shareFile(File file) async {
try {
await localRecorderShareFile(file);
} catch (e) {
_logger.e('Error sharing file: $e');
- await _showErrorDialog('Failed to share file: $e');
+ if (!mounted) return;
+ await LocalRecorderDialogs.showErrorDialog(
+ context,
+ 'Failed to share file: $e',
+ );
}
}
@@ -243,7 +192,11 @@ class _LocalRecorderViewState extends State {
await localRecorderShareFolder(folder);
} catch (e) {
_logger.e('Error sharing folder: $e');
- await _showErrorDialog('Failed to share folder: $e');
+ if (!mounted) return;
+ await LocalRecorderDialogs.showErrorDialog(
+ context,
+ 'Failed to share folder: $e',
+ );
}
}
@@ -261,7 +214,7 @@ class _LocalRecorderViewState extends State {
}
try {
- await recorder.startRecording(dir);
+ recorder.startRecording(dir);
await _listRecordings();
} catch (e) {
_logger.e('Error starting recording: $e');
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart
new file mode 100644
index 00000000..b33d89c8
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart
@@ -0,0 +1,269 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:logger/logger.dart';
+import 'package:open_wearable/view_models/sensor_recorder_provider.dart';
+import 'package:open_wearable/view_models/wearables_provider.dart';
+import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart';
+import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_files.dart';
+import 'package:provider/provider.dart';
+
+Logger _logger = Logger();
+
+enum _StopRecordingMode {
+ stopOnly,
+ stopAndTurnOffSensors,
+}
+
+class RecordingControls extends StatefulWidget {
+ const RecordingControls({
+ super.key,
+ required this.canStartRecording,
+ required this.isRecording,
+ required this.recorder,
+ required this.updateRecordingsList,
+ });
+
+ final bool canStartRecording;
+ final bool isRecording;
+ final SensorRecorderProvider recorder;
+
+ final Future Function() updateRecordingsList;
+
+ @override
+ State createState() => _RecordingControls();
+}
+
+class _RecordingControls extends State {
+ Duration _elapsedRecording = Duration.zero;
+ Timer? _recordingTimer;
+ bool _isHandlingStopAction = false;
+ bool _lastRecordingState = false;
+ SensorRecorderProvider? _recorder;
+ DateTime? _activeRecordingStart;
+
+ String _formatDuration(Duration d) {
+ String twoDigits(int n) => n.toString().padLeft(2, '0');
+ final hours = twoDigits(d.inHours);
+ final minutes = twoDigits(d.inMinutes.remainder(60));
+ final seconds = twoDigits(d.inSeconds.remainder(60));
+ return '$hours:$minutes:$seconds';
+ }
+
+ Future _handleStopRecording(
+ SensorRecorderProvider recorder, {
+ required _StopRecordingMode mode,
+ }) async {
+ if (_isHandlingStopAction) return;
+ setState(() {
+ _isHandlingStopAction = true;
+ });
+
+ try {
+ bool turnOffSensors = (mode == _StopRecordingMode.stopAndTurnOffSensors);
+ recorder.stopRecording(turnOffSensors);
+ if (turnOffSensors) {
+ final wearablesProvider = context.read();
+ final futures = wearablesProvider.sensorConfigurationProviders.values
+ .map((provider) => provider.turnOffAllSensors());
+ await Future.wait(futures);
+ await recorder.stopBLEMicrophoneStream();
+ }
+ await widget.updateRecordingsList();
+ } catch (e) {
+ _logger.e('Error stopping recording: $e');
+ if (!mounted) return;
+ await LocalRecorderDialogs.showErrorDialog(
+ context,
+ 'Failed to stop recording: $e',
+ );
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isHandlingStopAction = false;
+ });
+ }
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant RecordingControls oldWidget) {
+ super.didUpdateWidget(oldWidget);
+
+ // Start timer if parent says recording started
+ if (widget.isRecording && !oldWidget.isRecording) {
+ _startRecordingTimer(widget.recorder.recordingStart);
+ }
+
+ // Stop timer if parent says recording stopped
+ if (!widget.isRecording && oldWidget.isRecording) {
+ _stopRecordingTimer();
+ }
+ }
+
+ @override
+ void dispose() {
+ _recordingTimer?.cancel();
+ _recorder?.removeListener(_handleRecorderUpdate);
+ super.dispose();
+ }
+
+ void _handleRecorderUpdate() {
+ final recorder = _recorder;
+ if (recorder == null) return;
+ final isRecording = recorder.isRecording;
+ final start = recorder.recordingStart;
+ if (isRecording && !_lastRecordingState) {
+ _startRecordingTimer(start);
+ } else if (!isRecording && _lastRecordingState) {
+ _stopRecordingTimer();
+ } else if (isRecording &&
+ _lastRecordingState &&
+ start != null &&
+ _activeRecordingStart != null &&
+ start != _activeRecordingStart) {
+ _startRecordingTimer(start);
+ }
+ _lastRecordingState = isRecording;
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ final nextRecorder = context.watch();
+ if (!identical(_recorder, nextRecorder)) {
+ _recorder?.removeListener(_handleRecorderUpdate);
+ _recorder = nextRecorder;
+ _recorder?.addListener(_handleRecorderUpdate);
+ _handleRecorderUpdate();
+ }
+ }
+
+ void _startRecordingTimer(DateTime? start) {
+ final reference = start ?? DateTime.now();
+ _activeRecordingStart = reference;
+ _recordingTimer?.cancel();
+ setState(() {
+ _elapsedRecording = DateTime.now().difference(reference);
+ });
+ _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
+ if (!mounted) return;
+ setState(() {
+ final base = _activeRecordingStart ?? reference;
+ _elapsedRecording = DateTime.now().difference(base);
+ });
+ });
+ }
+
+ void _stopRecordingTimer() {
+ _recordingTimer?.cancel();
+ _recordingTimer = null;
+ _activeRecordingStart = null;
+ if (!mounted) return;
+ setState(() {
+ _elapsedRecording = Duration.zero;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox(
+ width: double.infinity,
+ child: !widget.isRecording
+ ? ElevatedButton.icon(
+ icon: const Icon(Icons.play_arrow),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: widget.canStartRecording
+ ? Colors.green.shade600
+ : Colors.grey.shade400,
+ foregroundColor: Colors.white,
+ minimumSize: const Size.fromHeight(48),
+ ),
+ label: const Text(
+ 'Start Recording',
+ style: TextStyle(fontSize: 18),
+ ),
+ onPressed: !widget.canStartRecording
+ ? null
+ : () async {
+ final dir = await Files.pickDirectory();
+ if (dir == null) return;
+
+ // Check if directory is empty
+ if (!await Files.isDirectoryEmpty(dir)) {
+ if (!context.mounted) return;
+ final proceed =
+ await LocalRecorderDialogs.askOverwriteConfirmation(
+ context,
+ dir,
+ );
+ if (!proceed) return;
+ }
+
+ widget.recorder.startRecording(dir);
+ await widget.updateRecordingsList(); // Refresh list
+ },
+ )
+ : Column(
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Expanded(
+ child: ElevatedButton.icon(
+ icon: const Icon(Icons.stop),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.red,
+ foregroundColor: Colors.white,
+ minimumSize: const Size.fromHeight(48),
+ ),
+ label: const Text(
+ 'Stop Recording',
+ style: TextStyle(fontSize: 18),
+ ),
+ onPressed: _isHandlingStopAction
+ ? null
+ : () => _handleStopRecording(
+ widget.recorder,
+ mode: _StopRecordingMode.stopOnly,
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ ConstrainedBox(
+ constraints: const BoxConstraints(
+ minWidth: 90,
+ ),
+ child: Text(
+ _formatDuration(_elapsedRecording),
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ ElevatedButton.icon(
+ icon: const Icon(Icons.power_settings_new),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.red[800],
+ foregroundColor: Colors.white,
+ minimumSize: const Size.fromHeight(48),
+ ),
+ label: const Text(
+ 'Stop & Turn Off Sensors',
+ style: TextStyle(fontSize: 18),
+ ),
+ onPressed: _isHandlingStopAction
+ ? null
+ : () => _handleStopRecording(
+ widget.recorder,
+ mode: _StopRecordingMode.stopAndTurnOffSensors,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart
index f847afa7..6d38f0e9 100644
--- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart
+++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart
@@ -4,10 +4,12 @@ import 'package:open_earable_flutter/open_earable_flutter.dart';
import 'package:open_wearable/models/app_shutdown_settings.dart';
import 'package:open_wearable/models/wearable_display_group.dart';
import 'package:open_wearable/view_models/sensor_data_provider.dart';
+import 'package:open_wearable/view_models/sensor_recorder_provider.dart';
import 'package:open_wearable/view_models/wearables_provider.dart';
import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart';
import 'package:provider/provider.dart';
+import 'dart:io';
class SensorValuesPage extends StatefulWidget {
final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders;
@@ -30,9 +32,41 @@ class _SensorValuesPageState extends State
bool get _ownsProviders => widget.sharedProviders == null;
+ String? _errorMessage;
+ bool _isInitializing = true;
+
@override
bool get wantKeepAlive => true;
+ @override
+ void initState() {
+ super.initState();
+ if (Platform.isAndroid) {
+ _checkStreamingStatus();
+ }
+ }
+
+ void _checkStreamingStatus() {
+ final recorderProvider =
+ Provider.of(context, listen: false);
+ if (!recorderProvider.isBLEMicrophoneStreamingEnabled) {
+ if (mounted) {
+ setState(() {
+ _isInitializing = false;
+ _errorMessage =
+ 'BLE microphone streaming not enabled. Enable it in sensor configuration.';
+ });
+ }
+ } else {
+ if (mounted) {
+ setState(() {
+ _isInitializing = false;
+ _errorMessage = null;
+ });
+ }
+ }
+ }
+
@override
void dispose() {
if (_ownsProviders) {
@@ -56,8 +90,28 @@ class _SensorValuesPageState extends State
builder: (context, hideCardsWithoutLiveData, __) {
final shouldHideCardsWithoutLiveData =
hideCardsWithoutLiveData && !disableLiveDataGraphs;
- return Consumer(
- builder: (context, wearablesProvider, child) {
+ return Consumer2(
+ builder: (context, wearablesProvider, recorderProvider, child) {
+ if (Platform.isAndroid && mounted) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!recorderProvider.isBLEMicrophoneStreamingEnabled &&
+ _errorMessage == null &&
+ !recorderProvider.isRecording) {
+ setState(() {
+ _errorMessage =
+ 'BLE microphone streaming not enabled. Enable it in sensor configuration.';
+ });
+ } else if (recorderProvider
+ .isBLEMicrophoneStreamingEnabled &&
+ _errorMessage != null &&
+ _errorMessage!
+ .contains('BLE microphone streaming not enabled')) {
+ setState(() {
+ _errorMessage = null;
+ });
+ }
+ });
+ }
return FutureBuilder>(
future: buildWearableDisplayGroups(
wearablesProvider.wearables,
@@ -100,6 +154,7 @@ class _SensorValuesPageState extends State
hasAnySensors: hasAnySensors,
hideCardsWithoutLiveData:
shouldHideCardsWithoutLiveData,
+ recorderProvider: recorderProvider,
);
} else {
return _buildLargeScreenLayout(
@@ -108,6 +163,7 @@ class _SensorValuesPageState extends State
hasAnySensors: hasAnySensors,
hideCardsWithoutLiveData:
shouldHideCardsWithoutLiveData,
+ recorderProvider: recorderProvider,
);
}
},
@@ -267,6 +323,7 @@ class _SensorValuesPageState extends State
List charts, {
required bool hasAnySensors,
required bool hideCardsWithoutLiveData,
+ required SensorRecorderProvider recorderProvider,
}) {
if (charts.isEmpty) {
final emptyState = _resolveEmptyState(
@@ -284,9 +341,74 @@ class _SensorValuesPageState extends State
);
}
- return ListView(
+ final showRecorderWaveform = recorderProvider.isRecording;
+
+ return Padding(
padding: SensorPageSpacing.pagePaddingWithBottomInset(context),
- children: charts,
+ child: ListView(
+ children: [
+ // Android microphone waveform and error
+ if (Platform.isAndroid) ...[
+ // Waveform card
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ showRecorderWaveform
+ ? Icons.fiber_manual_record
+ : Icons.mic,
+ color:
+ showRecorderWaveform ? Colors.red : Colors.blue,
+ size: 16,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ showRecorderWaveform
+ ? 'AUDIO WAVEFORM (Recording)'
+ : 'AUDIO WAVEFORM',
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ SizedBox(
+ height: 100,
+ child: _isInitializing
+ ? const Center(child: CircularProgressIndicator())
+ : CustomPaint(
+ painter: WaveformPainter(
+ recorderProvider.waveformData),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ // Error message
+ if (_errorMessage != null) ...[
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(
+ _errorMessage!,
+ style:
+ TextStyle(color: Theme.of(context).colorScheme.error),
+ ),
+ ),
+ ),
+ const SizedBox(height: 8),
+ ],
+ ],
+ // Sensor charts
+ ...charts,
+ ],
+ ),
);
}
@@ -295,27 +417,98 @@ class _SensorValuesPageState extends State
List charts, {
required bool hasAnySensors,
required bool hideCardsWithoutLiveData,
+ required SensorRecorderProvider recorderProvider,
}) {
final emptyState = _resolveEmptyState(
hasAnySensors: hasAnySensors,
hideCardsWithoutLiveData: hideCardsWithoutLiveData,
);
+ final showRecorderWaveform = recorderProvider.isRecording;
- return GridView.builder(
+ return Padding(
padding: SensorPageSpacing.pagePaddingWithBottomInset(context),
- gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
- maxCrossAxisExtent: 500,
- childAspectRatio: 1.5,
- crossAxisSpacing: SensorPageSpacing.gridGap,
- mainAxisSpacing: SensorPageSpacing.gridGap,
+ child: SingleChildScrollView(
+ child: Column(
+ children: [
+ // Android microphone waveform and error (top priority)
+ if (Platform.isAndroid) ...[
+ // Waveform card
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ showRecorderWaveform
+ ? Icons.fiber_manual_record
+ : Icons.mic,
+ color:
+ showRecorderWaveform ? Colors.red : Colors.blue,
+ size: 16,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ showRecorderWaveform
+ ? 'Audio waveform (Recording)'
+ : 'Audio waveform',
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ SizedBox(
+ height: 80,
+ child: _isInitializing
+ ? const Center(child: CircularProgressIndicator())
+ : CustomPaint(
+ painter: WaveformPainter(
+ recorderProvider.waveformData),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (_errorMessage != null) ...[
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(
+ _errorMessage!,
+ style:
+ TextStyle(color: Theme.of(context).colorScheme.error),
+ ),
+ ),
+ ),
+ const SizedBox(height: 8),
+ ],
+ ],
+ const SizedBox(height: 16),
+ // Sensor charts grid
+ GridView.builder(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
+ maxCrossAxisExtent: 500,
+ childAspectRatio: 1.5,
+ crossAxisSpacing: SensorPageSpacing.gridGap,
+ mainAxisSpacing: SensorPageSpacing.gridGap,
+ ),
+ itemCount: charts.isEmpty ? 1 : charts.length,
+ itemBuilder: (context, index) {
+ if (charts.isEmpty) {
+ return _buildEmptyStateCard(context, emptyState);
+ }
+ return charts[index];
+ },
+ ),
+ ],
+ ),
),
- itemCount: charts.isEmpty ? 1 : charts.length,
- itemBuilder: (context, index) {
- if (charts.isEmpty) {
- return _buildEmptyStateCard(context, emptyState);
- }
- return charts[index];
- },
);
}
@@ -435,3 +628,83 @@ class _SensorValuesEmptyState {
this.removeCardBackground = false,
});
}
+
+// Custom waveform painter with vertical bars
+class WaveformPainter extends CustomPainter {
+ final List waveformData;
+ final Color waveColor;
+ final double spacing;
+ final double waveThickness;
+ final bool showMiddleLine;
+
+ WaveformPainter(
+ this.waveformData, {
+ this.waveColor = Colors.blue,
+ this.spacing = 4.0,
+ this.waveThickness = 3.0,
+ this.showMiddleLine = true,
+ });
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ if (waveformData.isEmpty) return;
+
+ final double height = size.height;
+ final double centerY = height / 2;
+
+ // Draw middle line first (behind the bars)
+ if (showMiddleLine) {
+ final centerLinePaint = Paint()
+ ..color = Colors.grey.withAlpha(75)
+ ..strokeWidth = 1.0;
+ canvas.drawLine(
+ Offset(0, centerY),
+ Offset(size.width, centerY),
+ centerLinePaint,
+ );
+ }
+
+ // Paint for the vertical bars
+ final paint = Paint()
+ ..color = waveColor
+ ..strokeWidth = waveThickness
+ ..strokeCap = StrokeCap.round
+ ..style = PaintingStyle.stroke;
+
+ // Calculate how many bars can fit in the available width
+ final maxBars = (size.width / spacing).floor();
+ final startIndex =
+ waveformData.length > maxBars ? waveformData.length - maxBars : 0;
+
+ // Calculate starting position (always start at 0 or align right)
+ final visibleData = waveformData.sublist(startIndex);
+ final totalWaveformWidth = visibleData.length * spacing;
+ final startX = size.width - totalWaveformWidth;
+
+ // Draw each amplitude value as a vertical bar
+ for (int i = 0; i < visibleData.length; i++) {
+ final x = startX + (i * spacing);
+ final amplitude = visibleData[i];
+
+ // Scale amplitude to fit within the canvas height
+ final barHeight = amplitude * centerY * 0.8;
+
+ // Draw top half of the bar (above center line)
+ final topY = centerY - barHeight;
+ final bottomY = centerY + barHeight;
+
+ // Draw the vertical line from top to bottom
+ canvas.drawLine(
+ Offset(x, topY),
+ Offset(x, bottomY),
+ paint,
+ );
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant WaveformPainter oldDelegate) {
+ return oldDelegate.waveformData.length != waveformData.length ||
+ oldDelegate.waveColor != waveColor;
+ }
+}
diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc
index f547c379..b180d86f 100644
--- a/open_wearable/linux/flutter/generated_plugin_registrant.cc
+++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#include
void fl_register_plugins(FlPluginRegistry* registry) {
@@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);
+ g_autoptr(FlPluginRegistrar) record_linux_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
+ record_linux_plugin_register_with_registrar(record_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake
index 18a2dcb9..dcc28ba2 100644
--- a/open_wearable/linux/flutter/generated_plugins.cmake
+++ b/open_wearable/linux/flutter/generated_plugins.cmake
@@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
open_file_linux
+ record_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+ jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift
index 696f95be..2d7ed568 100644
--- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -11,6 +11,7 @@ import file_selector_macos
import flutter_archive
import open_file_mac
import package_info_plus
+import record_macos
import share_plus
import shared_preferences_foundation
import universal_ble
@@ -24,6 +25,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
+ RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))
diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock
index 6dab90f4..c31d2d66 100644
--- a/open_wearable/pubspec.lock
+++ b/open_wearable/pubspec.lock
@@ -440,6 +440,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
+ jni:
+ dependency: transitive
+ description:
+ name: jni
+ sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
+ jni_flutter:
+ dependency: transitive
+ description:
+ name: jni_flutter
+ sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.1"
json_annotation:
dependency: transitive
description:
@@ -500,10 +516,10 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
+ sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
- version: "0.12.19"
+ version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
@@ -632,6 +648,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.3"
+ package_config:
+ dependency: transitive
+ description:
+ name: package_config
+ sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
package_info_plus:
dependency: transitive
description:
@@ -676,10 +700,10 @@ packages:
dependency: transitive
description:
name: path_provider_android
- sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
+ sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
- version: "2.2.23"
+ version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
@@ -808,6 +832,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
+ record:
+ dependency: "direct main"
+ description:
+ name: record
+ sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.2.0"
+ record_android:
+ dependency: transitive
+ description:
+ name: record_android
+ sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.5.1"
+ record_ios:
+ dependency: transitive
+ description:
+ name: record_ios
+ sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.0"
+ record_linux:
+ dependency: transitive
+ description:
+ name: record_linux
+ sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
+ record_macos:
+ dependency: transitive
+ description:
+ name: record_macos
+ sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ record_platform_interface:
+ dependency: transitive
+ description:
+ name: record_platform_interface
+ sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.5.0"
+ record_web:
+ dependency: transitive
+ description:
+ name: record_web
+ sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
+ record_windows:
+ dependency: transitive
+ description:
+ name: record_windows
+ sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.7"
rxdart:
dependency: transitive
description:
@@ -945,10 +1033,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
+ sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
url: "https://pub.dev"
source: hosted
- version: "0.7.10"
+ version: "0.7.8"
tuple:
dependency: transitive
description:
diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml
index 66d61c6e..c6a4e942 100644
--- a/open_wearable/pubspec.yaml
+++ b/open_wearable/pubspec.yaml
@@ -56,6 +56,7 @@ dependencies:
url_launcher: ^6.3.2
go_router: ^14.6.2
http: ^1.6.0
+ record: ^6.1.2
audioplayers: ^6.6.0
wakelock_plus: ^1.4.0
diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc
index 37245d29..0eaaf699 100644
--- a/open_wearable/windows/flutter/generated_plugin_registrant.cc
+++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
+ RecordWindowsPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UniversalBlePluginCApiRegisterWithRegistrar(
diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake
index 154f7830..3689918f 100644
--- a/open_wearable/windows/flutter/generated_plugins.cmake
+++ b/open_wearable/windows/flutter/generated_plugins.cmake
@@ -6,12 +6,14 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
file_selector_windows
permission_handler_windows
+ record_windows
share_plus
universal_ble
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+ jni
)
set(PLUGIN_BUNDLED_LIBRARIES)