diff --git a/open_wearable/android/app/src/main/AndroidManifest.xml b/open_wearable/android/app/src/main/AndroidManifest.xml
index 8cb9f463..eb49e63d 100644
--- a/open_wearable/android/app/src/main/AndroidManifest.xml
+++ b/open_wearable/android/app/src/main/AndroidManifest.xml
@@ -40,6 +40,8 @@
+
+
diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock
index 1e2cdfa2..82b7fdd3 100644
--- a/open_wearable/ios/Podfile.lock
+++ b/open_wearable/ios/Podfile.lock
@@ -44,6 +44,8 @@ PODS:
- flutter_archive (0.0.1):
- Flutter
- ZIPFoundation (= 0.9.19)
+ - flutter_headset_detector (3.1.0):
+ - Flutter
- iOSMcuManagerLibrary (1.10.1):
- SwiftCBOR (= 0.4.7)
- ZIPFoundation (= 0.9.19)
@@ -86,6 +88,7 @@ DEPENDENCIES:
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- Flutter (from `Flutter`)
- flutter_archive (from `.symlinks/plugins/flutter_archive/ios`)
+ - flutter_headset_detector (from `.symlinks/plugins/flutter_headset_detector/ios`)
- mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`)
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
@@ -121,6 +124,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_archive:
:path: ".symlinks/plugins/flutter_archive/ios"
+ flutter_headset_detector:
+ :path: ".symlinks/plugins/flutter_headset_detector/ios"
mcumgr_flutter:
:path: ".symlinks/plugins/mcumgr_flutter/ios"
open_file_ios:
@@ -151,6 +156,7 @@ SPEC CHECKSUMS:
file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe
+ flutter_headset_detector: 37d2407c6c59aa6e8a9daecf732854862ff6dd4a
iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58
mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart
index d79e2627..a161391e 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';
@@ -32,18 +34,167 @@ 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);
+ int _waveformRevision = 0;
+
+ /// Monotonically increases whenever a new audio amplitude sample is recorded.
+ ///
+ /// The waveform keeps a capped rolling buffer, so its list length stops
+ /// changing once the buffer is full. Consumers can use this revision to
+ /// repaint when fresh samples shift through the fixed-size window.
+ int get waveformRevision => _waveformRevision;
+
+ 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) {
+ _appendWaveformAmplitude(amp);
+ notifyListeners();
+ });
+
+ logger.i(
+ "BLE microphone streaming started with device: ${_selectedBLEDevice!.label}",
+ );
+ notifyListeners();
+ return true;
+ } catch (e) {
+ logger.e("Failed to start BLE microphone streaming: $e");
+ _isStreamingActive = false;
+ _isBLEMicrophoneStreamingEnabled = false;
+ _streamingPath = null;
+ notifyListeners();
+ return false;
+ }
+ }
+
+ Future stopBLEMicrophoneStream() async {
+ if (!_isStreamingActive) {
+ return;
+ }
+
+ try {
+ await _audioRecorder.stop();
+ _amplitudeSub?.cancel();
+ _amplitudeSub = null;
+ _isStreamingActive = false;
+ _isBLEMicrophoneStreamingEnabled = false;
+ _waveformData.clear();
+
+ // Clean up temporary streaming file
+ if (_streamingPath != null) {
+ try {
+ final file = File(_streamingPath!);
+ if (await file.exists()) {
+ await file.delete();
+ }
+ } catch (e) {
+ // Ignore cleanup errors
+ }
+ _streamingPath = null;
+ }
+
+ logger.i("BLE microphone streaming stopped");
+ notifyListeners();
+ } catch (e) {
+ logger.e("Error stopping BLE microphone streaming: $e");
+ }
+ }
+
Future startRecording(String dirname) async {
if (_isRecording) {
return;
}
-
- _recordingFilepathsBySensorIdentity.clear();
+ _isRecording = true;
_currentDirectory = dirname;
_recordingStart = DateTime.now();
@@ -63,13 +214,121 @@ 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) {
+ _appendWaveformAmplitude(amp);
+ notifyListeners();
+ });
+ } catch (e) {
+ logger.e("Failed to start audio recording: $e");
+ _isAudioRecording = false;
+ }
}
- void stopRecording() {
+ void stopRecording(bool turnOffMic) async {
_isRecording = false;
_recordingStart = null;
_recordingFilepathsBySensorIdentity.clear();
_stopAllRecorderStreams();
+ try {
+ if (_isAudioRecording) {
+ final path = await _audioRecorder.stop();
+ _amplitudeSub?.cancel();
+ _amplitudeSub = null;
+ _isAudioRecording = false;
+
+ logger.i("Audio recording saved to: $path");
+ _currentAudioPath = null;
+ }
+ } catch (e) {
+ logger.e("Error stopping audio recording: $e");
+ }
+
+ // Restart streaming if it was enabled before recording
+ if (!turnOffMic &&
+ _isBLEMicrophoneStreamingEnabled &&
+ !_isStreamingActive) {
+ unawaited(startBLEMicrophoneStream());
+ }
+
notifyListeners();
}
@@ -304,9 +563,31 @@ class SensorRecorderProvider with ChangeNotifier {
}
}
+ /// Appends a normalized amplitude sample to the fixed-size waveform window.
+ void _appendWaveformAmplitude(Amplitude amplitude) {
+ final normalized = (amplitude.current + 50) / 50;
+ _waveformData.add(normalized.clamp(0.0, 2.0));
+ _waveformRevision++;
+
+ if (_waveformData.length > 100) {
+ _waveformData.removeAt(0);
+ }
+ }
+
@override
void dispose() {
_disposed = true;
+ // Stop streaming
+ stopBLEMicrophoneStream();
+
+ // Stop recording
+ _audioRecorder.stop().then((_) {
+ _audioRecorder.dispose();
+ }).catchError((e) {
+ logger.e("Error stopping audio in dispose: $e");
+ });
+ _amplitudeSub?.cancel();
+ _waveformData.clear();
for (final wearable in _recorders.keys.toList()) {
_disposeWearable(wearable);
}
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..549a13c1 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,16 @@ class SensorConfigurationDetailView extends StatelessWidget {
final targetOptions = sensorConfiguration is ConfigurableSensorConfiguration
? (sensorConfiguration as ConfigurableSensorConfiguration)
.availableOptions
- .toList(growable: false)
+ .where((option) {
+ // If on Android, show everything.
+ if (Platform.isAndroid) return true;
+
+ // If on iOS, hide 'microphone' + 'stream' combination
+ final isMic =
+ sensorConfiguration.name.toLowerCase().contains('microphone');
+ final isStream = option is StreamSensorConfigOption;
+ return !(isMic && isStream);
+ }).toList(growable: false)
: const [];
return ListView(
@@ -350,9 +360,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_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart
index 4845f87a..904933f9 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
@@ -6,6 +6,7 @@ import 'package:open_wearable/models/device_name_formatter.dart';
import 'package:open_wearable/view_models/sensor_configuration_storage.dart';
import 'package:open_wearable/view_models/sensor_profile_service.dart';
import 'package:open_wearable/widgets/app_toast.dart';
+import 'package:open_wearable/widgets/devices/device_detail/stereo_pos_label.dart';
import 'package:open_wearable/widgets/devices/stereo_position_badge.dart';
import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart';
import 'package:open_wearable/widgets/sensors/configuration/save_config_row.dart';
@@ -139,6 +140,10 @@ class _SensorConfigurationDeviceRowState
child: tabBar,
),
],
+ if (device.hasCapability())
+ StereoPosLabel(
+ device: 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 cc9f6411..afe8a3d1 100644
--- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart
+++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart
@@ -4,6 +4,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
import 'package:open_wearable/models/wearable_display_group.dart';
import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
+import 'package:open_wearable/view_models/sensor_recorder_provider.dart';
import 'package:open_wearable/view_models/wearables_provider.dart';
import 'package:open_wearable/widgets/app_toast.dart';
import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
@@ -274,6 +275,10 @@ class SensorConfigurationView extends StatelessWidget {
int actionableCount = 0;
+ final recorderProvider =
+ Provider.of(context, listen: false);
+ bool shouldEnableMicrophoneStreaming = false;
+
for (final target in targets) {
final primaryEntriesToApply = _entriesToApplyForProvider(target.provider);
final mirroredEntriesToApply = _entriesToApplyForMirroredTarget(target);
@@ -286,6 +291,13 @@ class SensorConfigurationView extends StatelessWidget {
for (final entry in primaryEntriesToApply) {
final SensorConfiguration config = entry.$1;
final SensorConfigurationValue value = entry.$2;
+ if (config.name.toLowerCase().contains('microphone')) {
+ final options =
+ target.provider.getSelectedConfigurationOptions(config);
+ if (options.any((opt) => opt is StreamSensorConfigOption)) {
+ shouldEnableMicrophoneStreaming = true;
+ }
+ }
// Always push the selected canonical value to the primary device on
// apply. This also heals primary-side drift/unknown states.
config.setConfiguration(value);
@@ -297,11 +309,23 @@ class SensorConfigurationView extends StatelessWidget {
config.setConfiguration(value);
}
+ if (shouldEnableMicrophoneStreaming &&
+ !recorderProvider.isBLEMicrophoneStreamingEnabled) {
+ await recorderProvider.startBLEMicrophoneStream();
+ } else if (!shouldEnableMicrophoneStreaming &&
+ recorderProvider.isBLEMicrophoneStreamingEnabled) {
+ await recorderProvider.stopBLEMicrophoneStream();
+ }
+
logger.d(
"Applied ${primaryEntriesToApply.length} primary and ${mirroredEntriesToApply.length} mirrored sensor settings for ${target.primaryDevice.name}",
);
}
+ if (!context.mounted) {
+ return;
+ }
+
if (actionableCount == 0) {
AppToast.show(
context,
diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_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 c6b22012..440649f0 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,8 +5,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:logger/logger.dart';
-import 'package:open_file/open_file.dart';
+import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart';
import 'package:provider/provider.dart';
+import 'package:open_file/open_file.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_empty_state_card.dart';
@@ -137,7 +138,7 @@ class _LocalRecorderViewState extends State {
});
try {
- recorder.stopRecording();
+ recorder.stopRecording(mode == _StopRecordingMode.stopAndTurnOffSensors);
if (mode == _StopRecordingMode.stopAndTurnOffSensors) {
final wearablesProvider = context.read();
final futures = wearablesProvider.sensorConfigurationProviders.values
@@ -234,7 +235,11 @@ class _LocalRecorderViewState extends State {
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 +248,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',
+ );
}
}
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..59b12d27
--- /dev/null
+++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart
@@ -0,0 +1,263 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:logger/logger.dart';
+import 'package:open_wearable/view_models/sensor_recorder_provider.dart';
+import 'package:open_wearable/view_models/wearables_provider.dart';
+import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart';
+import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_files.dart';
+import 'package:provider/provider.dart';
+
+Logger _logger = Logger();
+
+class RecordingControls extends StatefulWidget {
+ const RecordingControls({
+ super.key,
+ required this.canStartRecording,
+ required this.isRecording,
+ required this.recorder,
+ required this.updateRecordingsList,
+ });
+
+ final bool canStartRecording;
+ final bool isRecording;
+ final SensorRecorderProvider recorder;
+
+ final Future Function() updateRecordingsList;
+
+ @override
+ State createState() => _RecordingControls();
+}
+
+class _RecordingControls extends State {
+ Duration _elapsedRecording = Duration.zero;
+ Timer? _recordingTimer;
+ bool _isHandlingStopAction = false;
+ bool _lastRecordingState = false;
+ SensorRecorderProvider? _recorder;
+ DateTime? _activeRecordingStart;
+
+ String _formatDuration(Duration d) {
+ String twoDigits(int n) => n.toString().padLeft(2, '0');
+ final hours = twoDigits(d.inHours);
+ final minutes = twoDigits(d.inMinutes.remainder(60));
+ final seconds = twoDigits(d.inSeconds.remainder(60));
+ return '$hours:$minutes:$seconds';
+ }
+
+ Future _handleStopRecording(
+ SensorRecorderProvider recorder, {
+ required bool turnOffSensors,
+ }) async {
+ if (_isHandlingStopAction) return;
+ setState(() {
+ _isHandlingStopAction = true;
+ });
+
+ try {
+ recorder.stopRecording(turnOffSensors);
+ if (turnOffSensors) {
+ final wearablesProvider = context.read();
+ final futures = wearablesProvider.sensorConfigurationProviders.values
+ .map((provider) => provider.turnOffAllSensors());
+ await Future.wait(futures);
+ await recorder.stopBLEMicrophoneStream();
+ }
+ await widget.updateRecordingsList();
+ } catch (e) {
+ _logger.e('Error stopping recording: $e');
+ if (!mounted) return;
+ await LocalRecorderDialogs.showErrorDialog(
+ context,
+ 'Failed to stop recording: $e',
+ );
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isHandlingStopAction = false;
+ });
+ }
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant RecordingControls oldWidget) {
+ super.didUpdateWidget(oldWidget);
+
+ // Start timer if parent says recording started
+ if (widget.isRecording && !oldWidget.isRecording) {
+ _startRecordingTimer(widget.recorder.recordingStart);
+ }
+
+ // Stop timer if parent says recording stopped
+ if (!widget.isRecording && oldWidget.isRecording) {
+ _stopRecordingTimer();
+ }
+ }
+
+ @override
+ void dispose() {
+ _recordingTimer?.cancel();
+ _recorder?.removeListener(_handleRecorderUpdate);
+ super.dispose();
+ }
+
+ void _handleRecorderUpdate() {
+ final recorder = _recorder;
+ if (recorder == null) return;
+ final isRecording = recorder.isRecording;
+ final start = recorder.recordingStart;
+ if (isRecording && !_lastRecordingState) {
+ _startRecordingTimer(start);
+ } else if (!isRecording && _lastRecordingState) {
+ _stopRecordingTimer();
+ } else if (isRecording &&
+ _lastRecordingState &&
+ start != null &&
+ _activeRecordingStart != null &&
+ start != _activeRecordingStart) {
+ _startRecordingTimer(start);
+ }
+ _lastRecordingState = isRecording;
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ final nextRecorder = context.watch();
+ if (!identical(_recorder, nextRecorder)) {
+ _recorder?.removeListener(_handleRecorderUpdate);
+ _recorder = nextRecorder;
+ _recorder?.addListener(_handleRecorderUpdate);
+ _handleRecorderUpdate();
+ }
+ }
+
+ void _startRecordingTimer(DateTime? start) {
+ final reference = start ?? DateTime.now();
+ _activeRecordingStart = reference;
+ _recordingTimer?.cancel();
+ setState(() {
+ _elapsedRecording = DateTime.now().difference(reference);
+ });
+ _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
+ if (!mounted) return;
+ setState(() {
+ final base = _activeRecordingStart ?? reference;
+ _elapsedRecording = DateTime.now().difference(base);
+ });
+ });
+ }
+
+ void _stopRecordingTimer() {
+ _recordingTimer?.cancel();
+ _recordingTimer = null;
+ _activeRecordingStart = null;
+ if (!mounted) return;
+ setState(() {
+ _elapsedRecording = Duration.zero;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox(
+ width: double.infinity,
+ child: !widget.isRecording
+ ? ElevatedButton.icon(
+ icon: const Icon(Icons.play_arrow),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: widget.canStartRecording
+ ? Colors.green.shade600
+ : Colors.grey.shade400,
+ foregroundColor: Colors.white,
+ minimumSize: const Size.fromHeight(48),
+ ),
+ label: const Text(
+ 'Start Recording',
+ style: TextStyle(fontSize: 18),
+ ),
+ onPressed: !widget.canStartRecording
+ ? null
+ : () async {
+ final dir = await Files.pickDirectory();
+ if (dir == null) return;
+
+ // Check if directory is empty
+ if (!await Files.isDirectoryEmpty(dir)) {
+ if (!context.mounted) return;
+ final proceed =
+ await LocalRecorderDialogs.askOverwriteConfirmation(
+ context,
+ dir,
+ );
+ if (!proceed) return;
+ }
+
+ widget.recorder.startRecording(dir);
+ await widget.updateRecordingsList(); // Refresh list
+ },
+ )
+ : Column(
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Expanded(
+ child: ElevatedButton.icon(
+ icon: const Icon(Icons.stop),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.red,
+ foregroundColor: Colors.white,
+ minimumSize: const Size.fromHeight(48),
+ ),
+ label: const Text(
+ 'Stop Recording',
+ style: TextStyle(fontSize: 18),
+ ),
+ onPressed: _isHandlingStopAction
+ ? null
+ : () => _handleStopRecording(
+ widget.recorder,
+ turnOffSensors: false,
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ ConstrainedBox(
+ constraints: const BoxConstraints(
+ minWidth: 90,
+ ),
+ child: Text(
+ _formatDuration(_elapsedRecording),
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ ElevatedButton.icon(
+ icon: const Icon(Icons.power_settings_new),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.red[800],
+ foregroundColor: Colors.white,
+ minimumSize: const Size.fromHeight(48),
+ ),
+ label: const Text(
+ 'Stop & Turn Off Sensors',
+ style: TextStyle(fontSize: 18),
+ ),
+ onPressed: _isHandlingStopAction
+ ? null
+ : () => _handleStopRecording(
+ widget.recorder,
+ turnOffSensors: true,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart
index f847afa7..54cdc7fe 100644
--- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart
+++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart
@@ -1,9 +1,12 @@
+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';
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';
@@ -30,9 +33,42 @@ class _SensorValuesPageState extends State
bool get _ownsProviders => widget.sharedProviders == null;
+ String? _errorMessage;
+
+ bool _isInitializing = true;
+
@override
bool get wantKeepAlive => true;
+ @override
+ void initState() {
+ super.initState();
+ if (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 +92,8 @@ class _SensorValuesPageState extends State
builder: (context, hideCardsWithoutLiveData, __) {
final shouldHideCardsWithoutLiveData =
hideCardsWithoutLiveData && !disableLiveDataGraphs;
- return Consumer(
- builder: (context, wearablesProvider, child) {
+ return Consumer2(
+ builder: (context, wearablesProvider, recorderProvider, child) {
return FutureBuilder>(
future: buildWearableDisplayGroups(
wearablesProvider.wearables,
@@ -97,6 +133,7 @@ class _SensorValuesPageState extends State
return _buildSmallScreenLayout(
context,
charts,
+ recorderProvider,
hasAnySensors: hasAnySensors,
hideCardsWithoutLiveData:
shouldHideCardsWithoutLiveData,
@@ -105,6 +142,7 @@ class _SensorValuesPageState extends State
return _buildLargeScreenLayout(
context,
charts,
+ recorderProvider,
hasAnySensors: hasAnySensors,
hideCardsWithoutLiveData:
shouldHideCardsWithoutLiveData,
@@ -262,60 +300,139 @@ class _SensorValuesPageState extends State
return ordered;
}
+ Widget _buildAudioUI(SensorRecorderProvider recorderProvider) {
+ // If initializing, show a loading card
+ if (_isInitializing && Platform.isAndroid) {
+ return Card(
+ child: Container(
+ height: 100,
+ alignment: Alignment.center,
+ child: const CircularProgressIndicator(),
+ ),
+ );
+ }
+
+ return Column(
+ children: [
+ if (recorderProvider.isBLEMicrophoneStreamingEnabled)
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(
+ Icons.fiber_manual_record,
+ color: Colors.red,
+ size: 16,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ 'AUDIO WAVEFORM ${recorderProvider.isRecording ? "(RECORDING)" : ""}',
+ style: Theme.of(context).textTheme.labelLarge,
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ CustomPaint(
+ size: const Size(double.infinity, 100),
+ painter: WaveformPainter(
+ recorderProvider.waveformData,
+ sampleRevision: recorderProvider.waveformRevision,
+ ),
+ ),
+ ],
+ ),
+ ),
+ )
+ else if (_errorMessage != null)
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Row(
+ children: [
+ const Icon(Icons.error_outline, color: Colors.red),
+ const SizedBox(width: 12),
+ Expanded(
+ child: PlatformText(
+ _errorMessage!,
+ style: const TextStyle(color: Colors.red),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 10),
+ ],
+ );
+ }
+
Widget _buildSmallScreenLayout(
BuildContext context,
- List charts, {
+ List charts,
+ SensorRecorderProvider recorderProvider, {
required bool hasAnySensors,
required bool hideCardsWithoutLiveData,
}) {
- if (charts.isEmpty) {
- final emptyState = _resolveEmptyState(
- hasAnySensors: hasAnySensors,
- hideCardsWithoutLiveData: hideCardsWithoutLiveData,
- );
- return Padding(
- padding: SensorPageSpacing.pagePaddingWithBottomInset(context),
- child: Center(
- child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 500),
- child: _buildEmptyStateCard(context, emptyState),
- ),
- ),
- );
- }
-
return ListView(
padding: SensorPageSpacing.pagePaddingWithBottomInset(context),
- children: charts,
+ children: [
+ _buildAudioUI(recorderProvider),
+ ...charts,
+ if (charts.isEmpty)
+ Center(
+ child: _buildEmptyStateCard(
+ context,
+ _resolveEmptyState(
+ hasAnySensors: hasAnySensors,
+ hideCardsWithoutLiveData: hideCardsWithoutLiveData,
+ ),
+ ),
+ ),
+ ],
);
}
Widget _buildLargeScreenLayout(
BuildContext context,
- List charts, {
+ List charts,
+ SensorRecorderProvider recorderProvider, {
required bool hasAnySensors,
required bool hideCardsWithoutLiveData,
}) {
- final emptyState = _resolveEmptyState(
- hasAnySensors: hasAnySensors,
- hideCardsWithoutLiveData: hideCardsWithoutLiveData,
- );
-
- return GridView.builder(
+ return SingleChildScrollView(
padding: SensorPageSpacing.pagePaddingWithBottomInset(context),
- gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
- maxCrossAxisExtent: 500,
- childAspectRatio: 1.5,
- crossAxisSpacing: SensorPageSpacing.gridGap,
- mainAxisSpacing: SensorPageSpacing.gridGap,
+ child: Column(
+ children: [
+ _buildAudioUI(recorderProvider),
+ GridView.builder(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
+ maxCrossAxisExtent: 500,
+ childAspectRatio: 1.5,
+ crossAxisSpacing: SensorPageSpacing.gridGap,
+ mainAxisSpacing: SensorPageSpacing.gridGap,
+ ),
+ itemCount: charts.isEmpty ? 1 : charts.length,
+ itemBuilder: (context, index) {
+ if (charts.isEmpty) {
+ return _buildEmptyStateCard(
+ context,
+ _resolveEmptyState(
+ hasAnySensors: hasAnySensors,
+ hideCardsWithoutLiveData: hideCardsWithoutLiveData,
+ ),
+ );
+ }
+ return charts[index];
+ },
+ ),
+ ],
),
- itemCount: charts.isEmpty ? 1 : charts.length,
- itemBuilder: (context, index) {
- if (charts.isEmpty) {
- return _buildEmptyStateCard(context, emptyState);
- }
- return charts[index];
- },
);
}
@@ -435,3 +552,86 @@ class _SensorValuesEmptyState {
this.removeCardBackground = false,
});
}
+
+/// Paints the live audio amplitude window as a horizontally scrolling waveform.
+class WaveformPainter extends CustomPainter {
+ final List waveformData;
+ final int sampleRevision;
+ final Color waveColor;
+ final double spacing;
+ final double waveThickness;
+ final bool showMiddleLine;
+
+ WaveformPainter(
+ this.waveformData, {
+ required this.sampleRevision,
+ this.waveColor = Colors.blue,
+ this.spacing = 4.0,
+ this.waveThickness = 3.0,
+ this.showMiddleLine = true,
+ });
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ if (waveformData.isEmpty) return;
+
+ final double height = size.height;
+ final double centerY = height / 2;
+
+ // Draw middle line first (behind the bars)
+ if (showMiddleLine) {
+ final centerLinePaint = Paint()
+ ..color = Colors.grey.withAlpha(75)
+ ..strokeWidth = 1.0;
+ canvas.drawLine(
+ Offset(0, centerY),
+ Offset(size.width, centerY),
+ centerLinePaint,
+ );
+ }
+
+ // Paint for the vertical bars
+ final paint = Paint()
+ ..color = waveColor
+ ..strokeWidth = waveThickness
+ ..strokeCap = StrokeCap.round
+ ..style = PaintingStyle.stroke;
+
+ // Calculate how many bars can fit in the available width
+ final maxBars = (size.width / spacing).floor();
+ final startIndex =
+ waveformData.length > maxBars ? waveformData.length - maxBars : 0;
+
+ // Calculate starting position (always start at 0 or align right)
+ final visibleData = waveformData.sublist(startIndex);
+ final totalWaveformWidth = visibleData.length * spacing;
+ final startX = size.width - totalWaveformWidth;
+
+ // Draw each amplitude value as a vertical bar
+ for (int i = 0; i < visibleData.length; i++) {
+ final x = startX + (i * spacing);
+ final amplitude = visibleData[i];
+
+ // Scale amplitude to fit within the canvas height
+ final barHeight = amplitude * centerY * 0.8;
+
+ // Draw top half of the bar (above center line)
+ final topY = centerY - barHeight;
+ final bottomY = centerY + barHeight;
+
+ // Draw the vertical line from top to bottom
+ canvas.drawLine(
+ Offset(x, topY),
+ Offset(x, bottomY),
+ paint,
+ );
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant WaveformPainter oldDelegate) {
+ return oldDelegate.sampleRevision != sampleRevision ||
+ oldDelegate.waveformData.length != waveformData.length ||
+ oldDelegate.waveColor != waveColor;
+ }
+}
diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc
index f547c379..b180d86f 100644
--- a/open_wearable/linux/flutter/generated_plugin_registrant.cc
+++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#include
void fl_register_plugins(FlPluginRegistry* registry) {
@@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);
+ g_autoptr(FlPluginRegistrar) record_linux_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
+ record_linux_plugin_register_with_registrar(record_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake
index 6462693b..dcc28ba2 100644
--- a/open_wearable/linux/flutter/generated_plugins.cmake
+++ b/open_wearable/linux/flutter/generated_plugins.cmake
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
open_file_linux
+ record_linux
url_launcher_linux
)
diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift
index 39bfb0ef..8d21d935 100644
--- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -12,6 +12,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
@@ -26,6 +27,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 39926ab2..b99379de 100644
--- a/open_wearable/pubspec.lock
+++ b/open_wearable/pubspec.lock
@@ -350,6 +350,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:
@@ -753,7 +761,7 @@ packages:
source: hosted
version: "2.3.0"
permission_handler:
- dependency: transitive
+ dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
@@ -816,6 +824,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:
@@ -848,6 +864,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
+ record:
+ dependency: "direct main"
+ description:
+ name: record
+ sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.2.0"
+ record_android:
+ dependency: transitive
+ description:
+ name: record_android
+ sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.5.1"
+ record_ios:
+ dependency: transitive
+ description:
+ name: record_ios
+ sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.0"
+ record_linux:
+ dependency: transitive
+ description:
+ name: record_linux
+ sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
+ record_macos:
+ dependency: transitive
+ description:
+ name: record_macos
+ sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ record_platform_interface:
+ dependency: transitive
+ description:
+ name: record_platform_interface
+ sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.5.0"
record_use:
dependency: transitive
description:
@@ -856,6 +920,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
+ record_web:
+ dependency: transitive
+ description:
+ name: record_web
+ sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
+ record_windows:
+ dependency: transitive
+ description:
+ name: record_windows
+ sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.7"
rxdart:
dependency: transitive
description:
@@ -1113,10 +1193,10 @@ packages:
dependency: transitive
description:
name: vector_graphics
- sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
+ sha256: "6409a25046024f0f8c5d8a59fec314081e81f9d436b66ca4015a8b49772bf445"
url: "https://pub.dev"
source: hosted
- version: "1.1.21"
+ version: "1.2.0"
vector_graphics_codec:
dependency: transitive
description:
@@ -1145,10 +1225,10 @@ packages:
dependency: transitive
description:
name: vm_service
- sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499"
+ sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
- version: "15.1.0"
+ version: "15.2.0"
wakelock_plus:
dependency: "direct main"
description:
diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml
index 510d1c8b..844afb60 100644
--- a/open_wearable/pubspec.yaml
+++ b/open_wearable/pubspec.yaml
@@ -62,6 +62,10 @@ dependencies:
sensors_plus: ^7.0.0
device_info_plus: ^12.3.0
pub_semver: ^2.2.0
+ playback_capture: ^0.0.4
+ flutter_headset_detector: ^3.1.0
+ record: ^6.1.2
+ permission_handler: ^12.0.1
dev_dependencies:
flutter_test:
diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc
index 37245d29..0eaaf699 100644
--- a/open_wearable/windows/flutter/generated_plugin_registrant.cc
+++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
+ RecordWindowsPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UniversalBlePluginCApiRegisterWithRegistrar(
diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake
index ed5b9c00..3689918f 100644
--- a/open_wearable/windows/flutter/generated_plugins.cmake
+++ b/open_wearable/windows/flutter/generated_plugins.cmake
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
file_selector_windows
permission_handler_windows
+ record_windows
share_plus
universal_ble
url_launcher_windows