diff --git a/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt b/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt index 90d484a2..71435e35 100644 --- a/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt +++ b/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt @@ -1,5 +1,36 @@ package edu.kit.teco.openWearable +import android.content.Intent +import android.provider.Settings import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity: FlutterActivity() +class MainActivity : FlutterActivity() { + companion object { + private const val SYSTEM_SETTINGS_CHANNEL = "edu.kit.teco.open_wearable/system_settings" + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + SYSTEM_SETTINGS_CHANNEL, + ).setMethodCallHandler { call, result -> + if (call.method == "openBluetoothSettings") { + try { + val intent = Intent(Settings.ACTION_BLUETOOTH_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + result.success(true) + } catch (_: Exception) { + result.success(false) + } + } else { + result.notImplemented() + } + } + } +} diff --git a/open_wearable/ios/Runner/AppDelegate.swift b/open_wearable/ios/Runner/AppDelegate.swift index 84aee014..a5710e50 100644 --- a/open_wearable/ios/Runner/AppDelegate.swift +++ b/open_wearable/ios/Runner/AppDelegate.swift @@ -8,9 +8,10 @@ import UIKit didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { let controller: FlutterViewController = window?.rootViewController as! FlutterViewController - let channel = FlutterMethodChannel(name: "edu.teco.open_folder", binaryMessenger: controller.binaryMessenger) + let openFolderChannel = FlutterMethodChannel(name: "edu.teco.open_folder", binaryMessenger: controller.binaryMessenger) + let systemSettingsChannel = FlutterMethodChannel(name: "edu.kit.teco.open_wearable/system_settings", binaryMessenger: controller.binaryMessenger) - channel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in + openFolderChannel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in if call.method == "openFolder", let args = call.arguments as? [String: Any], let path = args["path"] as? String { guard let url = URL(string: path) else { result(FlutterError(code: "INVALID_ARGUMENT", message: "Invalid folder path", details: nil)) @@ -27,6 +28,25 @@ import UIKit } } + systemSettingsChannel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in + if call.method == "openBluetoothSettings" { + guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else { + result(false) + return + } + + if UIApplication.shared.canOpenURL(settingsUrl) { + UIApplication.shared.open(settingsUrl, options: [:]) { success in + result(success) + } + } else { + result(false) + } + } else { + result(FlutterMethodNotImplemented) + } + } + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart b/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart index 6455f325..89a12dc2 100644 --- a/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart +++ b/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart @@ -1,12 +1,13 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:community_charts_flutter/community_charts_flutter.dart' as charts; +import 'package:community_charts_flutter/community_charts_flutter.dart' + as charts; class RollingChart extends StatefulWidget { final Stream<(int, double)> dataSteam; final int timestampExponent; // e.g., 6 for microseconds to milliseconds - final int timeWindow; // in milliseconds + final int timeWindow; // in seconds const RollingChart({ super.key, @@ -20,8 +21,9 @@ class RollingChart extends StatefulWidget { } class _RollingChartState extends State { - List> _seriesList = []; - final List<_ChartPoint> _data = []; + List> _seriesList = []; + final List<_RawChartPoint> _rawData = []; + List<_ChartPoint> _normalizedData = []; StreamSubscription? _subscription; @override @@ -42,56 +44,87 @@ class _RollingChartState extends State { void _listenToStream() { _subscription = widget.dataSteam.listen((event) { final (timestamp, value) = event; - + setState(() { - _data.add(_ChartPoint(timestamp, value)); - + _rawData.add(_RawChartPoint(timestamp, value)); + // Remove old data outside time window - int cutoffTime = timestamp - (widget.timeWindow * pow(10, -widget.timestampExponent) as int); - _data.removeWhere((data) => data.time < cutoffTime); - + final ticksPerSecond = pow(10, -widget.timestampExponent).toDouble(); + final cutoffTime = + timestamp - (widget.timeWindow * ticksPerSecond).round(); + _rawData.removeWhere((data) => data.timestamp < cutoffTime); + _updateSeries(); }); }); } void _updateSeries() { + if (_rawData.isEmpty) { + _normalizedData = []; + _seriesList = []; + return; + } + + final firstTimestamp = _rawData.first.timestamp; + final secondsPerTick = pow(10, widget.timestampExponent).toDouble(); + + _normalizedData = _rawData + .map( + (point) => _ChartPoint( + (point.timestamp - firstTimestamp) * secondsPerTick, + point.value, + ), + ) + .toList(growable: false); + _seriesList = [ - charts.Series<_ChartPoint, int>( - id: 'Live Data', - colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault, - domainFn: (_ChartPoint point, _) => point.time, - measureFn: (_ChartPoint point, _) => point.value, - data: List.of(_data), + charts.Series<_ChartPoint, num>( + id: 'Live Data', + colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault, + domainFn: (_ChartPoint point, _) => point.timeSeconds, + measureFn: (_ChartPoint point, _) => point.value, + data: _normalizedData, ), ]; } @override Widget build(BuildContext context) { - final filteredPoints = _data; + final filteredPoints = _normalizedData; - final xValues = filteredPoints.map((e) => e.time).toList(); + final xValues = filteredPoints.map((e) => e.timeSeconds).toList(); final yValues = filteredPoints.map((e) => e.value).toList(); - final int? xMin = xValues.isNotEmpty ? xValues.reduce((a, b) => a < b ? a : b) : null; - final int? xMax = xValues.isNotEmpty ? xValues.reduce((a, b) => a > b ? a : b) : null; + final double xMin = 0; + final double xMax = max( + widget.timeWindow.toDouble(), + xValues.isNotEmpty ? xValues.reduce((a, b) => a > b ? a : b) : 0, + ); - final double? yMin = yValues.isNotEmpty ? yValues.reduce((a, b) => a < b ? a : b) : null; - final double? yMax = yValues.isNotEmpty ? yValues.reduce((a, b) => a > b ? a : b) : null; + final double? yMin = + yValues.isNotEmpty ? yValues.reduce((a, b) => a < b ? a : b) : null; + final double? yMax = + yValues.isNotEmpty ? yValues.reduce((a, b) => a > b ? a : b) : null; return charts.LineChart( _seriesList, animate: false, domainAxis: charts.NumericAxisSpec( - viewport: xMin != null && xMax != null - ? charts.NumericExtents(xMin, xMax) - : null, + viewport: charts.NumericExtents(xMin, xMax), + tickFormatterSpec: charts.BasicNumericTickFormatterSpec((num? value) { + if (value == null) return ''; + final rounded = value.roundToDouble(); + if ((value - rounded).abs() < 0.05) { + return '${rounded.toInt()}s'; + } + return '${value.toStringAsFixed(1)}s'; + }), ), primaryMeasureAxis: charts.NumericAxisSpec( viewport: yMin != null && yMax != null - ? charts.NumericExtents(yMin, yMax) - : null, + ? charts.NumericExtents(yMin, yMax) + : null, ), ); } @@ -103,9 +136,16 @@ class _RollingChartState extends State { } } +class _RawChartPoint { + final int timestamp; + final double value; + + _RawChartPoint(this.timestamp, this.value); +} + class _ChartPoint { - final int time; + final double timeSeconds; final double value; - _ChartPoint(this.time, this.value); + _ChartPoint(this.timeSeconds, this.value); } diff --git a/open_wearable/lib/apps/self_test/assets/self_test_icon.svg b/open_wearable/lib/apps/self_test/assets/self_test_icon.svg new file mode 100644 index 00000000..6155a9d9 --- /dev/null +++ b/open_wearable/lib/apps/self_test/assets/self_test_icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/open_wearable/lib/apps/self_test/self_test_page.dart b/open_wearable/lib/apps/self_test/self_test_page.dart new file mode 100644 index 00000000..b20f7507 --- /dev/null +++ b/open_wearable/lib/apps/self_test/self_test_page.dart @@ -0,0 +1,2296 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:fl_chart/fl_chart.dart'; +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/sensor_streams.dart'; +import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; +import 'package:provider/provider.dart'; + +class SelfTestPage extends StatefulWidget { + final Wearable wearable; + final SensorConfigurationProvider sensorConfigProvider; + + const SelfTestPage({ + super.key, + required this.wearable, + required this.sensorConfigProvider, + }); + + @override + State createState() => _SelfTestPageState(); +} + +class _SelfTestPageState extends State { + static const double _minimumTestDataCollectionSeconds = 3.0; + static const double _ledBrightnessFactor = 0.2; + + late final List<_TestSpec> _tests; + late final Map _sensorByTestId; + + final Map _resultsByTestId = {}; + final Map + _savedConfigurations = {}; + + WearablesProvider? _wearablesProvider; + StreamSubscription? _sensorSubscription; + Timer? _timeoutTimer; + Timer? _autoAdvanceTimer; + bool _hasDisabledSensorsAfterCompletion = false; + int _postCheckTransitionToken = 0; + + int _currentTestIndex = 0; + _TestAnalyzer? _currentAnalyzer; + bool _isRunning = false; + int? _firstTimestamp; + int _sampleIndex = 0; + bool _isInitializingSensor = false; + String _liveHint = ''; + List<_ChartPoint> _liveCurve = const []; + + @override + void initState() { + super.initState(); + + _tests = [ + _TestSpec( + id: 'accelerometer', + title: 'Accelerometer', + description: + 'Shake the device a few times. This verifies non-static acceleration in m/s².', + timeout: const Duration(seconds: 16), + targetFrequencyHz: 50, + ), + _TestSpec( + id: 'gyroscope', + title: 'Gyroscope', + description: + 'Rotate or shake the device repeatedly. This verifies angular-rate response.', + timeout: const Duration(seconds: 16), + targetFrequencyHz: 50, + ), + _TestSpec( + id: 'magnetometer', + title: 'Magnetometer', + description: + 'Move or rotate the device near metal or a small magnet. This verifies magnetic-field response.', + timeout: const Duration(seconds: 16), + targetFrequencyHz: 25, + ), + _TestSpec( + id: 'barometer', + title: 'Barometer', + description: + 'Blow steadily into the device for about one second to create a pressure change.', + timeout: const Duration(seconds: 18), + targetFrequencyHz: 25, + ), + _TestSpec( + id: 'temperature', + title: 'Temperature Sensor', + description: + 'Touch the sensor with a finger. Expected valid range is 30 to 40 °C.', + timeout: const Duration(seconds: 16), + targetFrequencyHz: 10, + ), + _TestSpec( + id: 'ppg', + title: 'PPG', + description: + 'Place the PPG area on a finger and hold still for around 10 seconds. The app looks for a pulse pattern.', + timeout: const Duration(seconds: 30), + targetFrequencyHz: 50, + ), + ]; + + _sensorByTestId = _resolveSensorsForTests(widget.wearable); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final provider = context.read(); + if (!identical(provider, _wearablesProvider)) { + _wearablesProvider?.removeListener(_onWearablesChanged); + _wearablesProvider = provider; + _wearablesProvider?.addListener(_onWearablesChanged); + } + } + + @override + void dispose() { + _wearablesProvider?.removeListener(_onWearablesChanged); + _stopCurrentRun(); + _autoAdvanceTimer?.cancel(); + _restoreSavedConfigurations(); + unawaited(_resetLedColor()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final connected = context.watch().wearables.any( + (wearable) => wearable.deviceId == widget.wearable.deviceId, + ); + final total = _tests.length; + final completed = _resultsByTestId.length; + final passed = + _resultsByTestId.values.where((result) => result.passed).length; + final currentSpec = _tests[_currentTestIndex]; + final currentResult = _resultsByTestId[currentSpec.id]; + final currentSensor = _sensorByTestId[currentSpec.id]; + + return PlatformScaffold( + appBar: PlatformAppBar( + title: PlatformText('Device Self Test'), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 20), + children: [ + _OverviewCard( + wearable: widget.wearable, + completed: completed, + total: total, + passed: passed, + connected: connected, + tests: _tests, + resultsByTestId: _resultsByTestId, + currentSpec: currentSpec, + running: _isRunning, + initializing: _isInitializingSensor, + currentHasSensor: currentSensor != null, + currentResult: currentResult, + onStartPressed: connected ? _startCurrentTest : null, + onNextPressed: _canGoNext() ? _goToNextTest : null, + onRetryPressed: connected ? _retryCurrentTest : null, + onResetLedPressed: connected ? _handleResetLedPressed : null, + onRunAllAgainPressed: completed == total ? _resetAllTests : null, + ), + const SizedBox(height: 12), + _CurrentTestCard( + spec: currentSpec, + sensor: currentSensor, + running: _isRunning, + initializing: _isInitializingSensor, + liveHint: _liveHint, + liveCurve: _liveCurve, + result: currentResult, + ), + const SizedBox(height: 12), + _ResultsCard( + tests: _tests, + resultsByTestId: _resultsByTestId, + running: _isRunning || _isInitializingSensor, + onRetryTest: connected ? _retryTestById : null, + ), + ], + ), + ); + } + + Map _resolveSensorsForTests(Wearable wearable) { + final result = { + for (final test in _tests) test.id: null, + }; + + if (!wearable.hasCapability()) { + return result; + } + + final sensors = wearable.requireCapability().sensors; + + Sensor? findByKeywords(List keywords) { + final lowered = + keywords.map((k) => k.toLowerCase()).toList(growable: false); + for (final sensor in sensors) { + final text = '${sensor.sensorName} ${sensor.chartTitle}'.toLowerCase(); + if (lowered.any(text.contains)) { + return sensor; + } + } + return null; + } + + String normalizeToken(String input) { + final normalized = input + .trim() + .toUpperCase() + .replaceAll(RegExp(r'[^A-Z0-9]+'), '_') + .replaceAll(RegExp(r'_+'), '_'); + return normalized.replaceAll(RegExp(r'^_|_$'), ''); + } + + Sensor? findByPreferredName(List preferredNames) { + final preferred = preferredNames.map(normalizeToken).toSet(); + for (final sensor in sensors) { + final sensorName = normalizeToken(sensor.sensorName); + final chartName = normalizeToken(sensor.chartTitle); + if (preferred.contains(sensorName) || preferred.contains(chartName)) { + return sensor; + } + } + return null; + } + + result['accelerometer'] = findByKeywords(['accelerometer', 'acc']); + result['gyroscope'] = findByKeywords(['gyroscope', 'gyro']); + result['magnetometer'] = findByKeywords([ + 'magnetometer', + 'magnetic', + 'mag', + ]); + result['barometer'] = findByKeywords(['barometer', 'pressure', 'baro']); + result['temperature'] = + findByPreferredName(['OPTICAL_TEMPERATURE_SENSOR']) ?? + findByKeywords(['temperature', 'temp']); + result['ppg'] = findByKeywords(['ppg', 'photopleth', 'pulse']); + + return result; + } + + void _onWearablesChanged() { + if (!mounted || !_isRunning) { + return; + } + final provider = _wearablesProvider; + if (provider == null) { + return; + } + final connected = provider.wearables.any( + (wearable) => wearable.deviceId == widget.wearable.deviceId, + ); + if (!connected) { + _completeCurrentTest( + passed: false, + message: 'Connection lost while running this test.', + ); + } + } + + Future _startCurrentTest() async { + if (_isRunning || _isInitializingSensor) { + return; + } + _postCheckTransitionToken++; + _autoAdvanceTimer?.cancel(); + unawaited(_setLedColor(r: 255, g: 255, b: 255)); + + final spec = _tests[_currentTestIndex]; + final sensor = _sensorByTestId[spec.id]; + if (sensor == null) { + _registerTestFailure( + testId: spec.id, + message: 'Required sensor is not available on this device.', + ); + return; + } + + final analyzer = _createAnalyzerFor(spec, sensor); + if (analyzer == null) { + _registerTestFailure( + testId: spec.id, + message: 'This test is not supported for the selected sensor setup.', + ); + return; + } + + try { + await _prepareSensorForStreaming( + sensor, + targetFrequencyHz: spec.targetFrequencyHz, + ); + } catch (_) { + _registerTestFailure( + testId: spec.id, + message: 'Unable to configure sensor streaming for this test.', + ); + return; + } + + _stopCurrentRun(); + + setState(() { + _isRunning = true; + _currentAnalyzer = analyzer; + _firstTimestamp = null; + _sampleIndex = 0; + _liveHint = analyzer.liveStatus; + _liveCurve = const []; + _resultsByTestId.remove(spec.id); + }); + + _sensorSubscription = SensorStreams.shared(sensor).listen( + (value) => _onSensorValue(value, sensor: sensor, spec: spec), + onError: (_) { + _completeCurrentTest( + passed: false, + message: 'Sensor stream failed while running this test.', + ); + }, + ); + + _timeoutTimer = Timer(spec.timeout, () { + final analyzerAtTimeout = _currentAnalyzer; + if (!_isRunning || analyzerAtTimeout == null) { + return; + } + _completeCurrentTest( + passed: false, + message: analyzerAtTimeout.failureMessage(timedOut: true), + ); + }); + } + + Future _prepareSensorForStreaming( + Sensor sensor, { + required int targetFrequencyHz, + }) async { + for (final config in sensor.relatedConfigurations) { + _savedConfigurations.putIfAbsent( + config, + () => widget.sensorConfigProvider.getSelectedConfigurationValue(config), + ); + + if (config is ConfigurableSensorConfiguration && + config.availableOptions + .any((option) => option is StreamSensorConfigOption)) { + widget.sensorConfigProvider.addSensorConfigurationOption( + config, + const StreamSensorConfigOption(), + ); + } + + final availableValues = widget.sensorConfigProvider + .getSensorConfigurationValues(config, distinct: true); + if (availableValues.isEmpty) { + continue; + } + + final selected = _selectBestValue( + availableValues, + targetFrequencyHz: targetFrequencyHz, + ); + widget.sensorConfigProvider.addSensorConfiguration(config, selected); + config.setConfiguration(selected); + } + } + + SensorConfigurationValue _selectBestValue( + List values, { + required int targetFrequencyHz, + }) { + if (values.length == 1) { + return values.first; + } + + if (values.first is! SensorFrequencyConfigurationValue) { + return values.first; + } + + SensorFrequencyConfigurationValue? nextBigger; + SensorFrequencyConfigurationValue? maxValue; + + for (final value in values.whereType()) { + if (maxValue == null || value.frequencyHz > maxValue.frequencyHz) { + maxValue = value; + } + + if (value.frequencyHz >= targetFrequencyHz && + (nextBigger == null || value.frequencyHz < nextBigger.frequencyHz)) { + nextBigger = value; + } + } + + return nextBigger ?? maxValue ?? values.first; + } + + _TestAnalyzer? _createAnalyzerFor(_TestSpec spec, Sensor sensor) { + switch (spec.id) { + case 'accelerometer': + return _MotionAnalyzer( + unitLabel: sensor.axisUnits.firstOrNull ?? 'm/s²', + movementLabel: 'shake', + minimumEvents: 3, + deltaThreshold: 4.2, + minimumStdDev: 1.1, + minimumSamples: 24, + highThreshold: null, + lowThreshold: null, + ); + case 'gyroscope': + return _MotionAnalyzer( + unitLabel: sensor.axisUnits.firstOrNull ?? '°/s', + movementLabel: 'rotation', + minimumEvents: 3, + deltaThreshold: 45, + minimumStdDev: 18, + minimumSamples: 24, + highThreshold: 120, + lowThreshold: 40, + ); + case 'magnetometer': + return _MagnetometerAnalyzer( + unitLabel: sensor.axisUnits.firstOrNull ?? 'uT', + ); + case 'barometer': + return _BarometerAnalyzer( + unitLabel: sensor.axisUnits.firstOrNull ?? 'Pa', + ); + case 'temperature': + return _TemperatureAnalyzer( + unitLabel: sensor.axisUnits.firstOrNull ?? '°C', + ); + case 'ppg': + return _PpgAnalyzer(); + default: + return null; + } + } + + void _onSensorValue( + SensorValue value, { + required Sensor sensor, + required _TestSpec spec, + }) { + if (!_isRunning) { + return; + } + + final analyzer = _currentAnalyzer; + if (analyzer == null) { + return; + } + + final sampleValues = _valuesFromSensorValue(value); + if (sampleValues.isEmpty) { + return; + } + + _firstTimestamp ??= value.timestamp; + final timeSeconds = (value.timestamp - _firstTimestamp!) * + pow(10, sensor.timestampExponent).toDouble(); + + final sample = _SensorSample( + timeSeconds: timeSeconds, + values: sampleValues, + ); + + analyzer.addSample(sample); + final primaryValue = analyzer.primaryValue(sample); + final plotX = _sampleIndex.toDouble(); + _sampleIndex += 1; + final minDurationReached = + sample.timeSeconds >= _minimumTestDataCollectionSeconds; + final remainingSeconds = + (_minimumTestDataCollectionSeconds - sample.timeSeconds) + .clamp(0.0, _minimumTestDataCollectionSeconds); + final nextLiveHint = analyzer.hasPassed && !minDurationReached + ? '${analyzer.liveStatus} Criteria reached. Collecting for ${remainingSeconds.toStringAsFixed(1)} s more.' + : analyzer.liveStatus; + final nextCurve = _appendCurvePoint( + _liveCurve, + _ChartPoint(plotX, primaryValue), + maxPoints: 180, + ); + + setState(() { + _liveCurve = nextCurve; + _liveHint = nextLiveHint; + }); + + if (analyzer.hasPassed && minDurationReached) { + _completeCurrentTest( + passed: true, + message: analyzer.successMessage, + ); + return; + } + + final thisTestResult = _resultsByTestId[spec.id]; + if (thisTestResult != null) { + return; + } + } + + List _valuesFromSensorValue(SensorValue value) { + if (value is SensorDoubleValue) { + return value.values; + } + if (value is SensorIntValue) { + return value.values.map((v) => v.toDouble()).toList(growable: false); + } + return value.valueStrings + .map(double.tryParse) + .whereType() + .toList(growable: false); + } + + List<_ChartPoint> _appendCurvePoint( + List<_ChartPoint> curve, + _ChartPoint point, { + required int maxPoints, + }) { + final next = List<_ChartPoint>.from(curve)..add(point); + if (next.length <= maxPoints) { + return next; + } + return next.sublist(next.length - maxPoints); + } + + void _completeCurrentTest({ + required bool passed, + required String message, + }) { + final completedIndex = _currentTestIndex; + final completedSpec = _tests[completedIndex]; + final curve = _downsampleCurve(_liveCurve, maxPoints: 90); + _stopCurrentRun(); + + if (!mounted) { + return; + } + + late final bool allCompleted; + setState(() { + _resultsByTestId[completedSpec.id] = _TestResult( + passed: passed, + message: message, + curve: curve, + ); + _liveHint = ''; + _liveCurve = const []; + allCompleted = _resultsByTestId.length >= _tests.length; + }); + + if (!passed) { + unawaited(_setLedColor(r: 255, g: 0, b: 0)); + } + + unawaited( + _runPostCheckTransition( + completedSpec: completedSpec, + completedIndex: completedIndex, + passed: passed, + allCompleted: allCompleted, + ), + ); + } + + void _registerTestFailure({ + required String testId, + required String message, + }) { + if (!mounted) { + return; + } + setState(() { + _resultsByTestId[testId] = _TestResult( + passed: false, + message: message, + curve: const [], + ); + _liveHint = ''; + _liveCurve = const []; + }); + unawaited(_setLedColor(r: 255, g: 0, b: 0)); + } + + void _stopCurrentRun() { + _timeoutTimer?.cancel(); + _timeoutTimer = null; + _sensorSubscription?.cancel(); + _sensorSubscription = null; + _currentAnalyzer = null; + _firstTimestamp = null; + _sampleIndex = 0; + _isRunning = false; + } + + Future _runPostCheckTransition({ + required _TestSpec completedSpec, + required int completedIndex, + required bool passed, + required bool allCompleted, + }) async { + final token = ++_postCheckTransitionToken; + + await _disableSensorForSpec(completedSpec); + if (!mounted || token != _postCheckTransitionToken) { + return; + } + + if (allCompleted) { + final allPassed = + _tests.every((test) => _resultsByTestId[test.id]?.passed == true); + await _setLedColor( + r: allPassed ? 0 : 255, + g: allPassed ? 255 : 0, + b: 0, + ); + await _disableSensorsAfterCompletion(); + return; + } + + if (!passed) { + return; + } + + final nextIndex = _nextTestIndexAfter(completedIndex); + if (nextIndex == null) { + return; + } + await _showCooldownAndMoveTo(nextIndex, token: token); + } + + Future _disableSensorForSpec(_TestSpec spec) async { + final sensor = _sensorByTestId[spec.id]; + if (sensor == null) { + return; + } + + for (final config in sensor.relatedConfigurations) { + try { + final offValue = config.offValue; + if (offValue != null) { + widget.sensorConfigProvider.addSensorConfiguration(config, offValue); + config.setConfiguration(offValue); + continue; + } + + if (config is ConfigurableSensorConfiguration && + config.availableOptions + .any((option) => option is StreamSensorConfigOption)) { + widget.sensorConfigProvider.removeSensorConfigurationOption( + config, + const StreamSensorConfigOption(), + ); + final selected = + widget.sensorConfigProvider.getSelectedConfigurationValue(config); + if (selected is ConfigurableSensorConfigurationValue) { + config.setConfiguration(selected); + } + } + } catch (_) { + // Continue with remaining configs even if one write fails. + } + } + } + + Future _showCooldownAndMoveTo( + int nextIndex, { + required int token, + }) async { + if (!mounted || token != _postCheckTransitionToken) { + return; + } + + setState(() { + _currentTestIndex = nextIndex; + _isInitializingSensor = true; + _liveHint = ''; + _liveCurve = const []; + }); + + await Future.delayed(const Duration(seconds: 1)); + if (!mounted || token != _postCheckTransitionToken) { + return; + } + + setState(() { + _isInitializingSensor = false; + }); + _autoStartCurrentTestIfPossible(); + } + + int? _nextTestIndexAfter(int fromIndex) { + for (int i = fromIndex + 1; i < _tests.length; i++) { + if (!_resultsByTestId.containsKey(_tests[i].id)) { + return i; + } + } + return null; + } + + List<_ChartPoint> _downsampleCurve( + List<_ChartPoint> source, { + required int maxPoints, + }) { + if (source.length <= maxPoints) { + return source; + } + + final step = source.length / maxPoints; + final sampled = <_ChartPoint>[]; + for (int i = 0; i < maxPoints; i++) { + final index = min((i * step).round(), source.length - 1); + sampled.add(source[index]); + } + return sampled; + } + + bool _canGoNext() { + if (_isRunning || _isInitializingSensor) { + return false; + } + final currentId = _tests[_currentTestIndex].id; + if (!_resultsByTestId.containsKey(currentId)) { + return false; + } + return _currentTestIndex < _tests.length - 1; + } + + void _goToNextTest() { + if (!_canGoNext()) { + return; + } + _postCheckTransitionToken++; + final token = _postCheckTransitionToken; + _autoAdvanceTimer?.cancel(); + final fromSpec = _tests[_currentTestIndex]; + final nextIndex = _currentTestIndex + 1; + unawaited(() async { + await _disableSensorForSpec(fromSpec); + await _showCooldownAndMoveTo(nextIndex, token: token); + }()); + } + + void _retryCurrentTest() { + if (_isRunning) { + return; + } + _postCheckTransitionToken++; + _autoAdvanceTimer?.cancel(); + _hasDisabledSensorsAfterCompletion = false; + final currentId = _tests[_currentTestIndex].id; + setState(() { + _isInitializingSensor = false; + _resultsByTestId.remove(currentId); + _liveHint = ''; + _liveCurve = const []; + }); + _autoStartCurrentTestIfPossible(); + } + + void _retryTestById(String testId) { + if (_isRunning) { + return; + } + _postCheckTransitionToken++; + _autoAdvanceTimer?.cancel(); + _hasDisabledSensorsAfterCompletion = false; + final index = _tests.indexWhere((test) => test.id == testId); + if (index < 0) { + return; + } + setState(() { + _isInitializingSensor = false; + _resultsByTestId.remove(testId); + _currentTestIndex = index; + _liveHint = ''; + _liveCurve = const []; + }); + _autoStartCurrentTestIfPossible(); + } + + void _resetAllTests() { + if (_isRunning) { + return; + } + _postCheckTransitionToken++; + _autoAdvanceTimer?.cancel(); + _hasDisabledSensorsAfterCompletion = false; + setState(() { + _isInitializingSensor = false; + _resultsByTestId.clear(); + _currentTestIndex = 0; + _liveHint = ''; + _liveCurve = const []; + }); + } + + void _handleResetLedPressed() { + unawaited(_resetLedColor()); + } + + void _restoreSavedConfigurations() { + for (final entry in _savedConfigurations.entries) { + final original = entry.value; + if (original == null) { + continue; + } + try { + widget.sensorConfigProvider.addSensorConfiguration(entry.key, original); + entry.key.setConfiguration(original); + } catch (_) { + // Ignore restoration failures during widget teardown. + } + } + } + + Future _disableSensorsAfterCompletion() async { + if (_hasDisabledSensorsAfterCompletion) { + return; + } + _postCheckTransitionToken++; + _hasDisabledSensorsAfterCompletion = true; + _autoAdvanceTimer?.cancel(); + if (mounted && _isInitializingSensor) { + setState(() { + _isInitializingSensor = false; + }); + } + + try { + await widget.sensorConfigProvider.turnOffAllSensors(); + _savedConfigurations.clear(); + return; + } catch (_) { + // Fallback: explicitly set off values for touched configurations. + } + + for (final config in _savedConfigurations.keys) { + final offValue = config.offValue; + if (offValue == null) { + continue; + } + try { + widget.sensorConfigProvider.addSensorConfiguration(config, offValue); + config.setConfiguration(offValue); + } catch (_) { + // Keep going with remaining configs. + } + } + _savedConfigurations.clear(); + } + + Future _setLedColor({ + required int r, + required int g, + required int b, + }) async { + if (!widget.wearable.hasCapability()) { + return; + } + + try { + if (widget.wearable.hasCapability()) { + await widget.wearable.requireCapability().showStatus(false); + } + final dimmedR = (r * _ledBrightnessFactor).round().clamp(0, 255); + final dimmedG = (g * _ledBrightnessFactor).round().clamp(0, 255); + final dimmedB = (b * _ledBrightnessFactor).round().clamp(0, 255); + await widget.wearable.requireCapability().writeLedColor( + r: dimmedR, + g: dimmedG, + b: dimmedB, + ); + } catch (_) { + // LED feedback should not interrupt test execution. + } + } + + Future _resetLedColor() async { + try { + if (widget.wearable.hasCapability()) { + await widget.wearable.requireCapability().showStatus(true); + return; + } + if (widget.wearable.hasCapability()) { + await widget.wearable.requireCapability().writeLedColor( + r: 0, + g: 0, + b: 0, + ); + } + } catch (_) { + // LED reset is best-effort and should not interrupt test execution. + } + } + + void _autoStartCurrentTestIfPossible() { + if (!mounted || _isRunning || _isInitializingSensor) { + return; + } + + final provider = _wearablesProvider; + if (provider == null) { + return; + } + + final connected = provider.wearables.any( + (wearable) => wearable.deviceId == widget.wearable.deviceId, + ); + if (!connected) { + return; + } + + final currentTestId = _tests[_currentTestIndex].id; + if (_resultsByTestId.containsKey(currentTestId)) { + return; + } + + final sensor = _sensorByTestId[currentTestId]; + if (sensor == null) { + return; + } + + unawaited(_startCurrentTest()); + } +} + +class _TestSpec { + final String id; + final String title; + final String description; + final Duration timeout; + final int targetFrequencyHz; + + const _TestSpec({ + required this.id, + required this.title, + required this.description, + required this.timeout, + required this.targetFrequencyHz, + }); +} + +class _TestResult { + final bool passed; + final String message; + final List<_ChartPoint> curve; + + const _TestResult({ + required this.passed, + required this.message, + required this.curve, + }); +} + +class _SensorSample { + final double timeSeconds; + final List values; + + const _SensorSample({ + required this.timeSeconds, + required this.values, + }); +} + +class _ChartPoint { + final double x; + final double y; + + const _ChartPoint(this.x, this.y); +} + +abstract class _TestAnalyzer { + bool get hasPassed; + String get liveStatus; + String get successMessage; + + void addSample(_SensorSample sample); + double primaryValue(_SensorSample sample); + String failureMessage({required bool timedOut}); +} + +class _MotionAnalyzer implements _TestAnalyzer { + final String unitLabel; + final String movementLabel; + final int minimumEvents; + final double deltaThreshold; + final double minimumStdDev; + final int minimumSamples; + final double? highThreshold; + final double? lowThreshold; + + final List _magnitudes = []; + + int _events = 0; + double? _previousMagnitude; + double _lastEventTime = -1000; + bool _peakStateHigh = false; + bool _passed = false; + double _stdDev = 0; + + _MotionAnalyzer({ + required this.unitLabel, + required this.movementLabel, + required this.minimumEvents, + required this.deltaThreshold, + required this.minimumStdDev, + required this.minimumSamples, + required this.highThreshold, + required this.lowThreshold, + }); + + @override + bool get hasPassed => _passed; + + @override + String get liveStatus => + 'Detected $_events of $minimumEvents required $movementLabel events. SD ${_stdDev.toStringAsFixed(1)} $unitLabel.'; + + @override + String get successMessage => + 'Motion detected successfully ($_events events, SD ${_stdDev.toStringAsFixed(1)} $unitLabel).'; + + @override + void addSample(_SensorSample sample) { + final magnitude = _vectorMagnitude(sample.values); + _magnitudes.add(magnitude); + + if (highThreshold != null && lowThreshold != null) { + if (!_peakStateHigh && + magnitude >= highThreshold! && + sample.timeSeconds - _lastEventTime >= 0.25) { + _events += 1; + _peakStateHigh = true; + _lastEventTime = sample.timeSeconds; + } + if (_peakStateHigh && magnitude <= lowThreshold!) { + _peakStateHigh = false; + } + } else { + if (_previousMagnitude != null) { + final delta = (magnitude - _previousMagnitude!).abs(); + if (delta >= deltaThreshold && + sample.timeSeconds - _lastEventTime >= 0.25) { + _events += 1; + _lastEventTime = sample.timeSeconds; + } + } + _previousMagnitude = magnitude; + } + + _stdDev = _stdDevOf(_magnitudes); + _passed = _events >= minimumEvents && + _magnitudes.length >= minimumSamples && + _stdDev >= minimumStdDev; + } + + @override + double primaryValue(_SensorSample sample) { + return _vectorMagnitude(sample.values); + } + + @override + String failureMessage({required bool timedOut}) { + if (!timedOut) { + return 'Motion quality check failed.'; + } + return 'Not enough movement detected. Please repeat with stronger motion and at least $minimumEvents clear events.'; + } +} + +class _BarometerAnalyzer implements _TestAnalyzer { + final String unitLabel; + final List _values = []; + final List _times = []; + + static const double _minimumAbsolutePressurePa = 100000.0; // 100 kPa + + double _baselinePa = 0; + double _baselineStdPa = 0; + double _maxRisePa = 0; + double _requiredRisePa = 0; + double _maxAbsolutePressurePa = 0; + double _sustainedStart = -1; + bool _sustainedRise = false; + bool _passed = false; + + _BarometerAnalyzer({ + required this.unitLabel, + }); + + double _toPascal(double value) { + final unit = unitLabel.toLowerCase(); + if (unit.contains('kpa')) { + return value * 1000.0; + } + if (unit.contains('hpa') || unit.contains('mbar')) { + return value * 100.0; + } + if (unit.contains('pa')) { + return value; + } + return value; + } + + @override + bool get hasPassed => _passed; + + @override + String get liveStatus { + final absKPa = _maxAbsolutePressurePa / 1000.0; + final riseKPa = _maxRisePa / 1000.0; + return 'Abs ${_maxAbsolutePressurePa.toStringAsFixed(0)} Pa (${absKPa.toStringAsFixed(2)} kPa), min 100000 Pa. Rise ${_maxRisePa.toStringAsFixed(0)} Pa (${riseKPa.toStringAsFixed(2)} kPa).'; + } + + @override + String get successMessage { + return 'Pressure response detected (abs ${_maxAbsolutePressurePa.toStringAsFixed(0)} Pa, rise +${_maxRisePa.toStringAsFixed(0)} Pa).'; + } + + @override + void addSample(_SensorSample sample) { + final valuePa = _toPascal(sample.values.first); + if (valuePa > _maxAbsolutePressurePa) { + _maxAbsolutePressurePa = valuePa; + } + + final timeSec = sample.timeSeconds; + _values.add(valuePa); + _times.add(timeSec); + + final baselineWindow = []; + for (int i = 0; i < _values.length; i++) { + if (_times[i] <= 2.0 || baselineWindow.length < 20) { + baselineWindow.add(_values[i]); + } + } + if (baselineWindow.isNotEmpty) { + _baselinePa = _meanOf(baselineWindow); + _baselineStdPa = _stdDevOf(baselineWindow); + } + + const floorRisePa = 600.0; // 0.6 kPa + const strongRisePa = 1200.0; // 1.2 kPa + _requiredRisePa = max(floorRisePa, _baselineStdPa * 8); + + final risePa = valuePa - _baselinePa; + if (risePa > _maxRisePa) { + _maxRisePa = risePa; + } + + if (risePa >= _requiredRisePa) { + if (_sustainedStart < 0) { + _sustainedStart = timeSec; + } + if (timeSec - _sustainedStart >= 0.45) { + _sustainedRise = true; + } + } else if (risePa < _requiredRisePa * 0.6) { + _sustainedStart = -1; + } + + _passed = _maxAbsolutePressurePa >= _minimumAbsolutePressurePa && + (_maxRisePa >= strongRisePa || _sustainedRise); + } + + @override + double primaryValue(_SensorSample sample) { + return sample.values.first; + } + + @override + String failureMessage({required bool timedOut}) { + if (_maxAbsolutePressurePa < _minimumAbsolutePressurePa) { + return 'Absolute pressure too low (${_maxAbsolutePressurePa.toStringAsFixed(0)} Pa). Need at least 100000 Pa.'; + } + if (!timedOut) { + return 'Pressure response check failed.'; + } + return 'No clear pressure rise detected. Blow steadily into the device for about one second and retry.'; + } +} + +class _MagnetometerAnalyzer implements _TestAnalyzer { + final String unitLabel; + final List> _axisValues = []; + + static const int _minimumSamples = 40; + static const int _requiredEvents = 3; + static const double _minimumSpanUt = 18.0; + static const double _minimumStdUt = 4.0; + static const double _minimumDeltaEventUt = 6.0; + + int _sampleCount = 0; + int _events = 0; + double _strongestSpanUt = 0; + double _strongestStdUt = 0; + double _lastAxisUt = 0; + double _lastEventTimeSec = -1000; + bool _hasLastAxis = false; + bool _passed = false; + + _MagnetometerAnalyzer({ + required this.unitLabel, + }); + + @override + bool get hasPassed => _passed; + + @override + String get liveStatus { + return 'Move near metal/magnet. Events $_events/$_requiredEvents, span ${_strongestSpanUt.toStringAsFixed(1)} uT, SD ${_strongestStdUt.toStringAsFixed(1)} uT.'; + } + + @override + String get successMessage { + return 'Magnetometer response detected ($_events events, span ${_strongestSpanUt.toStringAsFixed(1)} uT).'; + } + + double _toMicroTesla(double value) { + final unit = unitLabel.toLowerCase(); + if (unit.contains('mt')) { + return value * 1000.0; + } + if (unit.contains('nt')) { + return value / 1000.0; + } + return value; + } + + @override + void addSample(_SensorSample sample) { + if (sample.values.isEmpty) { + return; + } + + final valuesUt = sample.values.map(_toMicroTesla).toList(growable: false); + while (_axisValues.length < valuesUt.length) { + _axisValues.add([]); + } + for (int i = 0; i < valuesUt.length; i++) { + _axisValues[i].add(valuesUt[i]); + } + + _sampleCount += 1; + + final dominantIndex = _indexOfLargestAbsolute(valuesUt); + final dominantAxisUt = valuesUt[dominantIndex]; + if (_hasLastAxis) { + final delta = (dominantAxisUt - _lastAxisUt).abs(); + if (delta >= _minimumDeltaEventUt && + sample.timeSeconds - _lastEventTimeSec >= 0.35) { + _events += 1; + _lastEventTimeSec = sample.timeSeconds; + } + } + _lastAxisUt = dominantAxisUt; + _hasLastAxis = true; + + double bestSpan = 0; + double bestStd = 0; + for (final axis in _axisValues) { + if (axis.length < 2) { + continue; + } + final minValue = axis.reduce(min); + final maxValue = axis.reduce(max); + final span = maxValue - minValue; + if (span > bestSpan) { + bestSpan = span; + } + final std = _stdDevOf(axis); + if (std > bestStd) { + bestStd = std; + } + } + + _strongestSpanUt = bestSpan; + _strongestStdUt = bestStd; + _passed = _sampleCount >= _minimumSamples && + _events >= _requiredEvents && + _strongestSpanUt >= _minimumSpanUt && + _strongestStdUt >= _minimumStdUt; + } + + int _indexOfLargestAbsolute(List values) { + int bestIndex = 0; + double bestValue = -1; + for (int i = 0; i < values.length; i++) { + final absValue = values[i].abs(); + if (absValue > bestValue) { + bestValue = absValue; + bestIndex = i; + } + } + return bestIndex; + } + + @override + double primaryValue(_SensorSample sample) { + if (sample.values.isEmpty) { + return 0; + } + return _toMicroTesla(sample.values.first); + } + + @override + String failureMessage({required bool timedOut}) { + if (!timedOut) { + return 'Magnetometer quality check failed.'; + } + return 'Magnetometer response too weak. Move near a small magnet or metal object and rotate the device.'; + } +} + +class _TemperatureAnalyzer implements _TestAnalyzer { + final String unitLabel; + final List _values = []; + static const double _minimumFingerSurfaceTemp = 30.0; + static const double _maximumExpectedTemp = 40.0; + + bool _passed = false; + double _mean = 0; + double _minimum = 0; + double _maximum = 0; + + _TemperatureAnalyzer({ + required this.unitLabel, + }); + + @override + bool get hasPassed => _passed; + + @override + String get liveStatus { + final current = _values.isNotEmpty ? _values.last : double.nan; + if (current.isNaN) { + return 'Waiting for temperature samples...'; + } + return 'Current ${current.toStringAsFixed(1)} $unitLabel. Range ${_minimum.toStringAsFixed(1)} to ${_maximum.toStringAsFixed(1)} $unitLabel. Finger-contact range is ${_minimumFingerSurfaceTemp.toStringAsFixed(0)} to ${_maximumExpectedTemp.toStringAsFixed(0)} $unitLabel.'; + } + + @override + String get successMessage => + 'Temperature sensor active (${_mean.toStringAsFixed(1)} $unitLabel, span ${(_maximum - _minimum).toStringAsFixed(2)} $unitLabel).'; + + @override + void addSample(_SensorSample sample) { + final value = sample.values.first; + _values.add(value); + + _mean = _meanOf(_values); + _minimum = _values.reduce(min); + _maximum = _values.reduce(max); + final span = _maximum - _minimum; + + final inRange = + _mean >= _minimumFingerSurfaceTemp && _mean <= _maximumExpectedTemp; + final notStatic = span >= 0.12; + _passed = _values.length >= 12 && inRange && notStatic; + } + + @override + double primaryValue(_SensorSample sample) { + return sample.values.first; + } + + @override + String failureMessage({required bool timedOut}) { + if (_values.isEmpty) { + return 'No temperature values received.'; + } + + if (_mean < _minimumFingerSurfaceTemp || _mean > _maximumExpectedTemp) { + return 'Temperature average is ${_mean.toStringAsFixed(1)} $unitLabel. Expected finger-contact range is ${_minimumFingerSurfaceTemp.toStringAsFixed(0)} to ${_maximumExpectedTemp.toStringAsFixed(0)} $unitLabel.'; + } + + final span = _maximum - _minimum; + if (span < 0.12) { + return 'Temperature data appears static. Move the device between air and skin briefly, then retry.'; + } + + return timedOut + ? 'Temperature check timed out.' + : 'Temperature quality check failed.'; + } +} + +class _PpgAnalyzer implements _TestAnalyzer { + static const double _minimumBpm = 35.0; + static const double _maximumBpm = 180.0; + static const double _minimumWindowSeconds = 8.0; + static const int _minimumSampleCount = 80; + + final List _times = []; + final List> _axisValues = []; + + bool _passed = false; + double? _detectedBpm; + double? _peakBpm; + double? _autocorrBpm; + double _autocorrScore = 0; + int _detectedPeaks = 0; + + @override + bool get hasPassed => _passed; + + @override + String get liveStatus { + if (_detectedBpm != null) { + return 'Pulse candidate ${_detectedBpm!.toStringAsFixed(0)} BPM (peaks $_detectedPeaks, corr ${_autocorrScore.toStringAsFixed(2)}).'; + } + if (_autocorrBpm != null) { + return 'Pulse candidate ${_autocorrBpm!.toStringAsFixed(0)} BPM. Keep finger contact stable.'; + } + return 'Looking for a pulse pattern. Keep finger contact stable.'; + } + + @override + String get successMessage => + 'Pulse detected (${_detectedBpm?.toStringAsFixed(0) ?? '--'} BPM).'; + + @override + void addSample(_SensorSample sample) { + _times.add(sample.timeSeconds); + while (_axisValues.length < sample.values.length) { + _axisValues.add([]); + } + for (int i = 0; i < sample.values.length; i++) { + _axisValues[i].add(sample.values[i]); + } + + if (_times.length < _minimumSampleCount) { + return; + } + final windowSeconds = _times.last - _times.first; + if (windowSeconds < _minimumWindowSeconds) { + return; + } + + final axisIndex = _axisWithHighestStdDev(); + final source = _axisValues[axisIndex]; + + final mean = _meanOf(source); + final centered = + source.map((value) => value - mean).toList(growable: false); + final smoothed = _movingAverage(centered, radius: 2); + final std = _stdDevOf(smoothed); + if (std <= 0) { + return; + } + + final threshold = std * 0.30; + final peaks = []; + final minIntervalSec = 60.0 / _maximumBpm; + + for (int i = 1; i < smoothed.length - 1; i++) { + final prev = smoothed[i - 1]; + final curr = smoothed[i]; + final next = smoothed[i + 1]; + if (curr <= prev || curr <= next || curr < threshold) { + continue; + } + final t = _times[i]; + if (peaks.isNotEmpty) { + final dt = t - peaks.last; + if (dt < minIntervalSec) { + continue; + } + } + peaks.add(t); + } + + _detectedPeaks = peaks.length; + _peakBpm = _estimateBpmFromPeaks(peaks); + final normalized = + smoothed.map((value) => value / std).toList(growable: false); + _autocorrBpm = _estimateBpmFromAutocorrelation( + normalized, + windowSeconds: windowSeconds, + ); + + final peakValid = _peakBpm != null; + final corrValid = _autocorrBpm != null && _autocorrScore >= 0.20; + + double? candidateBpm; + if (peakValid && corrValid && (_peakBpm! - _autocorrBpm!).abs() <= 12.0) { + candidateBpm = (_peakBpm! + _autocorrBpm!) / 2.0; + } else if (corrValid && _autocorrScore >= 0.28) { + candidateBpm = _autocorrBpm; + } else if (peakValid && _detectedPeaks >= 4) { + candidateBpm = _peakBpm; + } + + if (candidateBpm == null) { + return; + } + + final bpm = candidateBpm; + if (bpm < _minimumBpm || bpm > _maximumBpm) { + return; + } + _detectedBpm = bpm; + _passed = true; + } + + double? _estimateBpmFromPeaks(List peaks) { + if (peaks.length < 4) { + return null; + } + final minIntervalSec = 60.0 / _maximumBpm; + final maxIntervalSec = 60.0 / _minimumBpm; + final intervals = []; + for (int i = 1; i < peaks.length; i++) { + final dt = peaks[i] - peaks[i - 1]; + if (dt >= minIntervalSec && dt <= maxIntervalSec) { + intervals.add(dt); + } + } + if (intervals.length < 3) { + return null; + } + final avgInterval = _meanOf(intervals); + return avgInterval <= 0 ? null : 60.0 / avgInterval; + } + + double? _estimateBpmFromAutocorrelation( + List normalized, { + required double windowSeconds, + }) { + _autocorrScore = 0; + if (normalized.length < 40 || windowSeconds <= 0) { + return null; + } + + final avgDt = windowSeconds / max(1, normalized.length - 1); + if (avgDt <= 0) { + return null; + } + + final minLag = max(2, (60.0 / _maximumBpm / avgDt).round()); + final maxLag = + min(normalized.length - 3, (60.0 / _minimumBpm / avgDt).round()); + if (maxLag <= minLag) { + return null; + } + + var bestLag = -1; + var bestScore = -1.0; + + for (int lag = minLag; lag <= maxLag; lag++) { + var cross = 0.0; + var energyA = 0.0; + var energyB = 0.0; + for (int i = lag; i < normalized.length; i++) { + final a = normalized[i]; + final b = normalized[i - lag]; + cross += a * b; + energyA += a * a; + energyB += b * b; + } + + final denom = sqrt(energyA * energyB); + if (denom <= 1e-9) { + continue; + } + + final score = cross / denom; + if (score > bestScore) { + bestScore = score; + bestLag = lag; + } + } + + if (bestLag < 0) { + return null; + } + + _autocorrScore = bestScore; + final periodSeconds = bestLag * avgDt; + if (periodSeconds <= 0) { + return null; + } + return 60.0 / periodSeconds; + } + + int _axisWithHighestStdDev() { + int bestIndex = 0; + double bestStdDev = -1; + for (int i = 0; i < _axisValues.length; i++) { + final axis = _axisValues[i]; + if (axis.isEmpty) { + continue; + } + final std = _stdDevOf(axis); + if (std > bestStdDev) { + bestStdDev = std; + bestIndex = i; + } + } + return bestIndex; + } + + @override + double primaryValue(_SensorSample sample) { + if (sample.values.isEmpty) { + return 0; + } + return sample.values.first; + } + + @override + String failureMessage({required bool timedOut}) { + if (!timedOut) { + return 'PPG quality check failed.'; + } + return 'No reliable pulse pattern detected. Place PPG on a finger and keep still for around 10 seconds, then retry.'; + } +} + +class _OverviewCard extends StatelessWidget { + final Wearable wearable; + final int completed; + final int total; + final int passed; + final bool connected; + final List<_TestSpec> tests; + final Map resultsByTestId; + final _TestSpec currentSpec; + final bool running; + final bool initializing; + final bool currentHasSensor; + final _TestResult? currentResult; + final VoidCallback? onStartPressed; + final VoidCallback? onNextPressed; + final VoidCallback? onRetryPressed; + final VoidCallback? onResetLedPressed; + final VoidCallback? onRunAllAgainPressed; + + const _OverviewCard({ + required this.wearable, + required this.completed, + required this.total, + required this.passed, + required this.connected, + required this.tests, + required this.resultsByTestId, + required this.currentSpec, + required this.running, + required this.initializing, + required this.currentHasSensor, + required this.currentResult, + required this.onStartPressed, + required this.onNextPressed, + required this.onRetryPressed, + required this.onResetLedPressed, + required this.onRunAllAgainPressed, + }); + + @override + Widget build(BuildContext context) { + final progress = total == 0 ? 0.0 : completed / total; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + wearable.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (wearable.hasCapability()) ...[ + const SizedBox(width: 8), + StereoPositionBadge( + device: wearable.requireCapability(), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + _StatusChip( + label: connected ? 'Connected' : 'Disconnected', + passed: connected, + ), + ], + ), + const SizedBox(height: 10), + LinearProgressIndicator( + value: progress, + minHeight: 8, + borderRadius: BorderRadius.circular(999), + backgroundColor: colorScheme.primary.withValues(alpha: 0.16), + ), + const SizedBox(height: 10), + Text( + '$completed of $total tests completed. $passed passed.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 10), + Divider( + height: 1, + thickness: 0.6, + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + const SizedBox(height: 10), + Text( + 'Quick Summary', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: tests.map((test) { + final result = resultsByTestId[test.id]; + final isCurrent = currentSpec.id == test.id; + final isActive = isCurrent && (running || initializing); + + final bool isPass = result?.passed == true; + final bool isFail = result != null && !result.passed; + final IconData icon = isPass + ? Icons.check_circle_rounded + : isFail + ? Icons.cancel_rounded + : isActive + ? Icons.hourglass_top_rounded + : Icons.radio_button_unchecked_rounded; + final Color color = isPass + ? const Color(0xFF2F8F5B) + : isFail + ? theme.colorScheme.error + : isActive + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: color.withValues(alpha: 0.24), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text( + _summaryLabelFor(test), + style: theme.textTheme.labelMedium?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + }).toList(growable: false), + ), + const SizedBox(height: 10), + Divider( + height: 1, + thickness: 0.6, + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + const SizedBox(height: 10), + if (onRunAllAgainPressed != null) ...[ + Row( + children: [ + Expanded( + child: PlatformElevatedButton( + onPressed: initializing ? null : onResetLedPressed, + color: const Color(0xFF8E8E93), + child: PlatformText('Reset'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: PlatformElevatedButton( + onPressed: initializing ? null : onRunAllAgainPressed, + child: PlatformText('Run All Tests Again'), + ), + ), + ], + ), + ] else if (currentResult != null) ...[ + Row( + children: [ + Expanded( + child: PlatformElevatedButton( + onPressed: + !running && !initializing ? onRetryPressed : null, + color: const Color(0xFF8E8E93), + child: const Icon(Icons.refresh_rounded), + ), + ), + if (onNextPressed != null) ...[ + const SizedBox(width: 8), + Expanded( + child: PlatformElevatedButton( + onPressed: initializing ? null : onNextPressed, + child: PlatformText('Next Test'), + ), + ), + ], + ], + ), + ] else ...[ + SizedBox( + width: double.infinity, + child: PlatformElevatedButton( + onPressed: !running && !initializing && currentHasSensor + ? onStartPressed + : null, + child: PlatformText( + running ? 'Running...' : 'Start Test', + ), + ), + ), + ], + ], + ), + ), + ); + } + + String _summaryLabelFor(_TestSpec test) { + return switch (test.id) { + 'accelerometer' => 'Accel', + 'gyroscope' => 'Gyro', + 'magnetometer' => 'Mag', + 'barometer' => 'Baro', + 'temperature' => 'Temp', + 'ppg' => 'PPG', + _ => test.title, + }; + } +} + +class _CurrentTestCard extends StatelessWidget { + final _TestSpec spec; + final Sensor? sensor; + final bool running; + final bool initializing; + final String liveHint; + final List<_ChartPoint> liveCurve; + final _TestResult? result; + + const _CurrentTestCard({ + required this.spec, + required this.sensor, + required this.running, + required this.initializing, + required this.liveHint, + required this.liveCurve, + required this.result, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasResult = result != null; + final hasSensor = sensor != null; + + return Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + spec.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (hasResult) + _StatusChip( + label: result!.passed ? 'Passed' : 'Failed', + passed: result!.passed, + ), + ], + ), + const SizedBox(height: 8), + Text( + spec.description, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + if (!hasSensor) + Text( + 'Sensor not found for this test on the selected device.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + fontWeight: FontWeight.w600, + ), + ) + else if (initializing) + Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: PlatformCircularProgressIndicator(), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Initializing ${spec.title}... cooling down firmware for 1 second.', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ) + else if (running) + Text( + liveHint, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ) + else if (hasResult) + Text( + result!.message, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: result!.passed + ? const Color(0xFF2F8F5B) + : theme.colorScheme.error, + ), + ), + if (running || + liveCurve.isNotEmpty || + (hasResult && result!.curve.isNotEmpty)) ...[ + const SizedBox(height: 8), + _SignalChart( + points: running ? liveCurve : (result?.curve ?? const []), + height: 92, + color: hasResult && !(result?.passed ?? true) + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + ), + ], + ], + ), + ), + ); + } +} + +class _ResultsCard extends StatelessWidget { + final List<_TestSpec> tests; + final Map resultsByTestId; + final bool running; + final void Function(String testId)? onRetryTest; + + const _ResultsCard({ + required this.tests, + required this.resultsByTestId, + required this.running, + required this.onRetryTest, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Detailed Test Report', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + ...List.generate(tests.length, (index) { + final test = tests[index]; + final result = resultsByTestId[test.id]; + final hasResult = result != null; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + test.title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + !hasResult ? 'Pending' : result.message, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: hasResult && !result.passed + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, + fontWeight: hasResult + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + if (hasResult && result.curve.isNotEmpty) + SizedBox( + width: 110, + child: _SignalChart( + points: result.curve, + height: 44, + color: result.passed + ? const Color(0xFF2F8F5B) + : theme.colorScheme.error, + ), + ) + else + const SizedBox(width: 110), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _StatusChip( + label: !hasResult + ? 'Pending' + : result.passed + ? 'Pass' + : 'Fail', + passed: hasResult && result.passed, + ), + if (hasResult) ...[ + const SizedBox(height: 4), + PlatformTextButton( + onPressed: running + ? null + : () => onRetryTest?.call(test.id), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + child: const Icon(Icons.refresh_rounded), + ), + ], + ], + ), + ], + ), + ), + if (index < tests.length - 1) + Divider( + height: 1, + thickness: 0.6, + color: theme.colorScheme.outlineVariant.withValues( + alpha: 0.55, + ), + ), + ], + ); + }), + ], + ), + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + final String label; + final bool passed; + + const _StatusChip({ + required this.label, + required this.passed, + }); + + @override + Widget build(BuildContext context) { + final color = + passed ? const Color(0xFF2F8F5B) : Theme.of(context).colorScheme.error; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.28)), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _SignalChart extends StatelessWidget { + final List<_ChartPoint> points; + final double height; + final Color color; + + const _SignalChart({ + required this.points, + required this.height, + required this.color, + }); + + @override + Widget build(BuildContext context) { + if (points.length < 2) { + return SizedBox( + height: height, + child: Center( + child: Text( + 'No signal yet', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + final yValues = points.map((point) => point.y).toList(growable: false); + var minY = yValues.reduce(min); + var maxY = yValues.reduce(max); + if ((maxY - minY).abs() < 1e-9) { + minY -= 1; + maxY += 1; + } + + final minX = points.first.x; + final maxX = points.last.x <= minX ? minX + 1 : points.last.x; + + return SizedBox( + height: height, + child: LineChart( + LineChartData( + minX: minX, + maxX: maxX, + minY: minY, + maxY: maxY, + titlesData: const FlTitlesData( + leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: FlGridData( + show: true, + drawHorizontalLine: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (value) => FlLine( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.12), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + lineTouchData: const LineTouchData(enabled: false), + lineBarsData: [ + LineChartBarData( + spots: points + .map((point) => FlSpot(point.x, point.y)) + .toList(growable: false), + isCurved: true, + color: color, + barWidth: 1.8, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + color: color.withValues(alpha: 0.08), + ), + ), + ], + ), + duration: const Duration(milliseconds: 0), + ), + ); + } +} + +double _vectorMagnitude(List values) { + var sum = 0.0; + for (final value in values) { + sum += value * value; + } + return sqrt(sum); +} + +double _meanOf(List values) { + if (values.isEmpty) { + return 0; + } + var sum = 0.0; + for (final value in values) { + sum += value; + } + return sum / values.length; +} + +double _stdDevOf(List values) { + if (values.length < 2) { + return 0; + } + final mean = _meanOf(values); + var varianceSum = 0.0; + for (final value in values) { + final diff = value - mean; + varianceSum += diff * diff; + } + return sqrt(varianceSum / values.length); +} + +List _movingAverage( + List input, { + required int radius, +}) { + if (input.isEmpty) { + return const []; + } + final output = []; + for (int i = 0; i < input.length; i++) { + final start = max(0, i - radius); + final end = min(input.length - 1, i + radius); + var sum = 0.0; + var count = 0; + for (int j = start; j <= end; j++) { + sum += input[j]; + count += 1; + } + output.add(sum / count); + } + return output; +} diff --git a/open_wearable/lib/apps/widgets/app_compatibility.dart b/open_wearable/lib/apps/widgets/app_compatibility.dart new file mode 100644 index 00000000..8e1112ec --- /dev/null +++ b/open_wearable/lib/apps/widgets/app_compatibility.dart @@ -0,0 +1,25 @@ +bool wearableNameStartsWithPrefix(String wearableName, String prefix) { + final normalizedWearableName = wearableName.trim().toLowerCase(); + final normalizedPrefix = prefix.trim().toLowerCase(); + if (normalizedWearableName.isEmpty || normalizedPrefix.isEmpty) return false; + return normalizedWearableName.startsWith(normalizedPrefix); +} + +bool wearableIsCompatibleWithApp({ + required String wearableName, + required List supportedDevicePrefixes, +}) { + if (supportedDevicePrefixes.isEmpty) return true; + return supportedDevicePrefixes.any( + (prefix) => wearableNameStartsWithPrefix(wearableName, prefix), + ); +} + +bool hasConnectedWearableForPrefix({ + required String devicePrefix, + required Iterable connectedWearableNames, +}) { + return connectedWearableNames.any( + (name) => wearableNameStartsWithPrefix(name, devicePrefix), + ); +} diff --git a/open_wearable/lib/apps/widgets/app_tile.dart b/open_wearable/lib/apps/widgets/app_tile.dart index 45fd370a..1cfde6fd 100644 --- a/open_wearable/lib/apps/widgets/app_tile.dart +++ b/open_wearable/lib/apps/widgets/app_tile.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:open_wearable/apps/widgets/app_compatibility.dart'; import 'package:open_wearable/apps/widgets/apps_page.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:provider/provider.dart'; class AppTile extends StatelessWidget { final AppInfo app; @@ -9,26 +13,194 @@ class AppTile extends StatelessWidget { @override Widget build(BuildContext context) { - return PlatformListTile( - title: PlatformText(app.title), - subtitle: PlatformText(app.description), - leading: SizedBox( - height: 50.0, - width: 50.0, - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: Image.asset( - app.logoPath, - fit: BoxFit.cover, - ), - ), - ), + final connectedWearableNames = context + .watch() + .wearables + .map((wearable) => wearable.name) + .toList(growable: false); + final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + clipBehavior: Clip.antiAlias, + child: InkWell( onTap: () { Navigator.push( context, - platformPageRoute(context: context, builder: (context) => app.widget), + platformPageRoute( + context: context, + builder: (context) => app.widget, + ), ); }, - ); + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + height: 62.0, + width: 62.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: app.accentColor.withValues(alpha: 0.28), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(13.0), + child: app.logoPath.toLowerCase().endsWith('.svg') + ? Padding( + padding: EdgeInsets.all(app.svgIconInset ?? 10), + child: Transform.scale( + scale: app.svgIconScale ?? 1, + child: SvgPicture.asset( + app.logoPath, + fit: BoxFit.contain, + ), + ), + ) + : Image.asset( + app.logoPath, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + app.title, + style: titleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _LaunchAffordance(accentColor: app.accentColor), + ], + ), + const SizedBox(height: 3), + Text( + app.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + 'Supported devices', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context) + .textTheme + .bodySmall + ?.color + ?.withValues(alpha: 0.72), + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: app.supportedDevices + .map( + (device) => _SupportedDeviceChip( + text: device, + accentColor: app.accentColor, + isConnected: hasConnectedWearableForPrefix( + devicePrefix: device, + connectedWearableNames: connectedWearableNames, + ), + ), + ) + .toList(), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _LaunchAffordance extends StatelessWidget { + final Color accentColor; + + const _LaunchAffordance({ + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(left: 8), + height: 30, + width: 30, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Icon( + Icons.arrow_forward_rounded, + size: 18, + color: accentColor.withValues(alpha: 0.9), + ), + ); + } +} + +class _SupportedDeviceChip extends StatelessWidget { + final String text; + final Color accentColor; + final bool isConnected; + + const _SupportedDeviceChip({ + required this.text, + required this.accentColor, + required this.isConnected, + }); + + @override + Widget build(BuildContext context) { + const connectedDotColor = Color(0xFF2F8F5B); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + text, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: accentColor.withValues(alpha: 0.92), + fontWeight: FontWeight.w600, + ), + ), + if (isConnected) ...[ + const SizedBox(width: 6), + Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: connectedDotColor, + shape: BoxShape.circle, + ), + ), + ], + ], + ), + ); } } diff --git a/open_wearable/lib/apps/widgets/apps_page.dart b/open_wearable/lib/apps/widgets/apps_page.dart index 9400bf94..82929753 100644 --- a/open_wearable/lib/apps/widgets/apps_page.dart +++ b/open_wearable/lib/apps/widgets/apps_page.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; @@ -6,50 +5,91 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/apps/heart_tracker/widgets/heart_tracker_page.dart'; import 'package:open_wearable/apps/posture_tracker/model/earable_attitude_tracker.dart'; import 'package:open_wearable/apps/posture_tracker/view/posture_tracker_view.dart'; +import 'package:open_wearable/apps/self_test/self_test_page.dart'; +import 'package:open_wearable/apps/widgets/app_compatibility.dart'; import 'package:open_wearable/apps/widgets/select_earable_view.dart'; import 'package:open_wearable/apps/widgets/app_tile.dart'; - +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/recording_activity_indicator.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; +import 'package:provider/provider.dart'; class AppInfo { final String logoPath; final String title; final String description; + final List supportedDevices; + final Color accentColor; final Widget widget; + final double? svgIconInset; + final double? svgIconScale; + final Color? iconBackgroundColor; AppInfo({ required this.logoPath, required this.title, required this.description, + required this.supportedDevices, + required this.accentColor, required this.widget, + this.svgIconInset, + this.svgIconScale, + this.iconBackgroundColor, }); } -List _apps = [ +const Color _appAccentColor = Color(0xFF9A6F6B); +const List _postureSupportedDevices = [ + "OpenEarable", + "eSense", + "Cosinuss", +]; +const List _heartSupportedDevices = [ + "OpenEarable", + "OpenRing", + "Cosinuss", +]; +const List _selfTestSupportedDevices = [ + "OpenEarable", +]; + +final List _apps = [ AppInfo( logoPath: "lib/apps/posture_tracker/assets/logo.png", title: "Posture Tracker", description: "Get feedback on bad posture", - widget: SelectEarableView(startApp: (wearable, sensorConfigProvider) { - return PostureTrackerView( - EarableAttitudeTracker( - wearable.requireCapability(), - sensorConfigProvider, - wearable.name.endsWith("L"), - ), - ); - },), + supportedDevices: _postureSupportedDevices, + accentColor: _appAccentColor, + widget: SelectEarableView( + supportedDevicePrefixes: _postureSupportedDevices, + startApp: (wearable, sensorConfigProvider) { + return PostureTrackerView( + EarableAttitudeTracker( + wearable.requireCapability(), + sensorConfigProvider, + wearable.name.endsWith("L"), + ), + ); + }, + ), ), AppInfo( logoPath: "lib/apps/heart_tracker/assets/logo.png", title: "Heart Tracker", description: "Track your heart rate and other vitals", + supportedDevices: _heartSupportedDevices, + accentColor: _appAccentColor, widget: SelectEarableView( + supportedDevicePrefixes: _heartSupportedDevices, startApp: (wearable, _) { if (wearable.hasCapability()) { //TODO: show alert if no ppg sensor is found - Sensor ppgSensor = wearable.requireCapability().sensors.firstWhere( - (s) => s.sensorName.toLowerCase() == "photoplethysmography".toLowerCase(), - ); + Sensor ppgSensor = + wearable.requireCapability().sensors.firstWhere( + (s) => + s.sensorName.toLowerCase() == + "photoplethysmography".toLowerCase(), + ); return HeartTrackerPage(ppgSensor: ppgSensor); } @@ -64,18 +104,56 @@ List _apps = [ }, ), ), + AppInfo( + logoPath: "lib/apps/self_test/assets/self_test_icon.svg", + title: "Device Self Test", + description: "Run guided OpenEarable hardware checks with a test report", + supportedDevices: _selfTestSupportedDevices, + accentColor: _appAccentColor, + svgIconInset: 0, + svgIconScale: 1.14, + iconBackgroundColor: const Color(0xFFF0E6E4), + widget: SelectEarableView( + supportedDevicePrefixes: _selfTestSupportedDevices, + startApp: (wearable, sensorConfigProvider) { + return SelfTestPage( + wearable: wearable, + sensorConfigProvider: sensorConfigProvider, + ); + }, + ), + ), ]; +int getAvailableAppsCount() => _apps.length; + +int getCompatibleAppsCountForWearables(Iterable wearables) { + final names = wearables.map((wearable) => wearable.name).toList(); + if (names.isEmpty) return 0; + + return _apps.where((app) { + return names.any( + (name) => wearableIsCompatibleWithApp( + wearableName: name, + supportedDevicePrefixes: app.supportedDevices, + ), + ); + }).length; +} + class AppsPage extends StatelessWidget { const AppsPage({super.key}); @override Widget build(BuildContext context) { + final connectedCount = context.watch().wearables.length; + return PlatformScaffold( appBar: PlatformAppBar( title: PlatformText("Apps"), trailingActions: [ - PlatformIconButton( + const AppBarRecordingIndicator(), + PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { context.push('/connect-devices'); @@ -83,14 +161,149 @@ class AppsPage extends StatelessWidget { ), ], ), - body: Padding( - padding: EdgeInsets.all(10), - child: ListView.builder( - itemCount: _apps.length, - itemBuilder: (context, index) { - return AppTile(app: _apps[index]); - }, + body: ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + _AppsHeroCard( + totalApps: _apps.length, + connectedDevices: connectedCount, + ), + const SizedBox(height: SensorPageSpacing.sectionGap), + Padding( + padding: const EdgeInsets.only(left: 2, bottom: 8), + child: Text( + 'Available apps', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ..._apps.map((app) => AppTile(app: app)), + ], + ), + ); + } +} + +class _AppsHeroCard extends StatelessWidget { + final int totalApps; + final int connectedDevices; + + const _AppsHeroCard({ + required this.totalApps, + required this.connectedDevices, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF835B58), + Color(0xFFB48A86), + ], ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 14, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + height: 34, + width: 34, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.auto_awesome, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 10), + Text( + 'App Studio', + style: theme.textTheme.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + 'Launch wearable experiences from one place.', + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.white.withValues(alpha: 0.9), + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _HeroStatPill( + label: '$totalApps apps', + icon: Icons.widgets_outlined, + ), + _HeroStatPill( + label: '$connectedDevices wearables connected', + icon: Icons.link_rounded, + ), + ], + ), + ], + ), + ); + } +} + +class _HeroStatPill extends StatelessWidget { + final String label; + final IconData icon; + + const _HeroStatPill({ + required this.label, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: Colors.white), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], ), ); } diff --git a/open_wearable/lib/apps/widgets/select_earable_view.dart b/open_wearable/lib/apps/widgets/select_earable_view.dart index 49653a88..819fbce1 100644 --- a/open_wearable/lib/apps/widgets/select_earable_view.dart +++ b/open_wearable/lib/apps/widgets/select_earable_view.dart @@ -1,17 +1,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/apps/widgets/app_compatibility.dart'; +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/wearables_provider.dart'; +import 'package:open_wearable/widgets/devices/battery_state.dart'; +import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; import 'package:provider/provider.dart'; -class SelectEarableView extends StatefulWidget { - /// Callback to start the app - /// -- [wearable] the selected wearable - /// returns a [Widget] of the home page of the app +class SelectEarableView extends StatefulWidget { final Widget Function(Wearable, SensorConfigurationProvider) startApp; + final List supportedDevicePrefixes; - const SelectEarableView({super.key, required this.startApp}); + const SelectEarableView({ + super.key, + required this.startApp, + this.supportedDevicePrefixes = const [], + }); @override State createState() => _SelectEarableViewState(); @@ -19,60 +26,712 @@ class SelectEarableView extends StatefulWidget { class _SelectEarableViewState extends State { Wearable? _selectedWearable; + Future>? _groupsFuture; + String _groupFingerprint = ''; + final Map _deviceInfoFutureCache = {}; @override Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText("Select Earable"), + title: PlatformText('Select Wearable'), ), - body: Consumer( - builder: (context, WearablesProvider wearablesProvider, child) => - Column( + body: Consumer( + builder: (context, wearablesProvider, _) { + final compatibleWearables = wearablesProvider.wearables + .where( + (wearable) => wearableIsCompatibleWithApp( + wearableName: wearable.name, + supportedDevicePrefixes: widget.supportedDevicePrefixes, + ), + ) + .toList(growable: false); + + _refreshGroupFutureIfNeeded(compatibleWearables); + final selectedDeviceId = _selectedWearable?.deviceId; + final hasSelectedCompatibleWearable = selectedDeviceId != null && + compatibleWearables.any( + (wearable) => wearable.deviceId == selectedDeviceId, + ); + + return Column( children: [ - ListView.builder( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemCount: wearablesProvider.wearables.length, - itemBuilder: (context, index) { - Wearable wearable = wearablesProvider.wearables[index]; - return PlatformListTile( - title: PlatformText(wearable.name), - subtitle: PlatformText(wearable.deviceId), //TODO: use device ID - trailing: _selectedWearable == wearable - ? Icon(Icons.check) + Expanded( + child: _buildBody( + context, + compatibleWearables: compatibleWearables, + wearablesProvider: wearablesProvider, + ), + ), + SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: SizedBox( + width: double.infinity, + child: PlatformElevatedButton( + onPressed: hasSelectedCompatibleWearable + ? () => _startSelectedApp( + context, + wearablesProvider, + compatibleWearables, + ) : null, - onTap: () => setState(() { - _selectedWearable = wearable; - }), - ); - }, + child: PlatformText('Start App'), + ), + ), ), + ], + ); + }, + ), + ); + } + + void _refreshGroupFutureIfNeeded(List wearables) { + final activeIds = wearables.map((wearable) => wearable.deviceId).toSet(); + _deviceInfoFutureCache.removeWhere((id, _) => !activeIds.contains(id)); + for (final wearable in wearables) { + _ensureInfoCacheForWearable(wearable); + } + + final fingerprint = wearables + .map((wearable) => '${wearable.deviceId}:${wearable.name}') + .join('|'); + if (_groupsFuture != null && _groupFingerprint == fingerprint) { + return; + } + + _groupFingerprint = fingerprint; + _groupsFuture = buildWearableDisplayGroups( + wearables, + shouldCombinePair: (_, __) => false, + ); + } + + _DeviceInfoFutureCache _ensureInfoCacheForWearable(Wearable wearable) { + final cache = _deviceInfoFutureCache.putIfAbsent( + wearable.deviceId, + _DeviceInfoFutureCache.new, + ); + + if (cache.firmwareVersionFuture == null && + wearable.hasCapability()) { + final capability = wearable.requireCapability(); + cache.firmwareVersionFuture = capability.readDeviceFirmwareVersion(); + cache.firmwareSupportFuture = capability.checkFirmwareSupport(); + } + if (cache.hardwareVersionFuture == null && + wearable.hasCapability()) { + final capability = wearable.requireCapability(); + cache.hardwareVersionFuture = capability.readDeviceHardwareVersion(); + } + return cache; + } + + Widget _buildBody( + BuildContext context, { + required List compatibleWearables, + required WearablesProvider wearablesProvider, + }) { + if (compatibleWearables.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Text( + 'No compatible wearables connected for this app.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } + + return FutureBuilder>( + future: _groupsFuture, + builder: (context, snapshot) { + final groups = _sortGroupsForSelection( + snapshot.data ?? + compatibleWearables + .map( + (wearable) => + WearableDisplayGroup.single(wearable: wearable), + ) + .toList(growable: false), + ); + + if (groups.isEmpty) { + return const SizedBox.shrink(); + } + + final selectedId = _selectedWearable?.deviceId; + + return ListView.builder( + padding: const EdgeInsets.all(10), + itemCount: groups.length, + itemBuilder: (context, index) { + final group = groups[index]; + final wearable = group.primary; + final isSelected = selectedId == wearable.deviceId; + + return _SelectableWearableCard( + wearable: wearable, + position: group.primaryPosition, + infoCache: _ensureInfoCacheForWearable(wearable), + selected: isSelected, + onTap: () { + setState(() { + _selectedWearable = wearable; + }); + }, + ); + }, + ); + }, + ); + } + + List _sortGroupsForSelection( + List groups, + ) { + final indexed = groups.asMap().entries.toList(); + + String normalizedName(String name) { + var value = name.trim(); + value = value.replaceFirst( + RegExp(r'\s*\((left|right|l|r)\)$', caseSensitive: false), + '', + ); + value = value.replaceFirst( + RegExp(r'[\s_-]+(left|right|l|r)$', caseSensitive: false), + '', + ); + value = value.trim(); + return value.isEmpty ? name.trim() : value; + } + + int positionRank(DevicePosition? position) { + return switch (position) { + DevicePosition.left => 0, + DevicePosition.right => 1, + _ => 2, + }; + } + + indexed.sort((a, b) { + final aBase = normalizedName(a.value.primary.name).toLowerCase(); + final bBase = normalizedName(b.value.primary.name).toLowerCase(); + final byBase = aBase.compareTo(bBase); + if (byBase != 0) { + return byBase; + } + + final byPosition = positionRank(a.value.primaryPosition) + .compareTo(positionRank(b.value.primaryPosition)); + if (byPosition != 0) { + return byPosition; + } + + final byName = a.value.primary.name + .toLowerCase() + .compareTo(b.value.primary.name.toLowerCase()); + if (byName != 0) { + return byName; + } + + return a.key.compareTo(b.key); + }); + + return indexed.map((entry) => entry.value).toList(growable: false); + } + + void _startSelectedApp( + BuildContext context, + WearablesProvider wearablesProvider, + List compatibleWearables, + ) { + final selectedId = _selectedWearable?.deviceId; + if (selectedId == null) { + return; + } - PlatformElevatedButton( - child: PlatformText("Start App"), - onPressed: () { - if (_selectedWearable != null) { - Navigator.push( - context, - platformPageRoute( - context: context, - builder: (context) { - return ChangeNotifierProvider.value( - value: wearablesProvider.getSensorConfigurationProvider(_selectedWearable!), - child: widget.startApp( - _selectedWearable!, - wearablesProvider.getSensorConfigurationProvider(_selectedWearable!), + final selectedWearable = compatibleWearables + .where((wearable) => wearable.deviceId == selectedId) + .firstOrNull; + + if (selectedWearable == null) { + return; + } + + final sensorConfigProvider = + wearablesProvider.getSensorConfigurationProvider(selectedWearable); + + Navigator.push( + context, + platformPageRoute( + context: context, + builder: (context) => ChangeNotifierProvider.value( + value: sensorConfigProvider, + child: widget.startApp( + selectedWearable, + sensorConfigProvider, + ), + ), + ), + ); + } +} + +class _SelectableWearableCard extends StatelessWidget { + final Wearable wearable; + final DevicePosition? position; + final _DeviceInfoFutureCache infoCache; + final bool selected; + final VoidCallback onTap; + + const _SelectableWearableCard({ + required this.wearable, + required this.position, + required this.infoCache, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final iconVariant = _iconVariantForPosition(position); + final hasWearableIcon = _hasWearableIcon(iconVariant); + final cardColor = selected + ? colorScheme.primaryContainer.withValues(alpha: 0.34) + : colorScheme.surface; + final pills = _buildDeviceStatusPills(); + + return Card( + color: cardColor, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasWearableIcon) ...[ + Padding( + padding: const EdgeInsets.only(top: 2), + child: SizedBox( + width: 56, + height: 56, + child: _SelectableWearableIconView( + wearable: wearable, + initialVariant: iconVariant, + ), + ), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + wearable.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 170), + child: Text( + wearable.deviceId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, ), - ); - }, - ), - ); - } - }, + ), + ), + if (selected) ...[ + const SizedBox(width: 6), + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + Icons.check_circle_rounded, + color: colorScheme.primary, + size: 18, + ), + ), + ], + ], + ), + if (pills.isNotEmpty) ...[ + const SizedBox(height: 8), + _buildStatusPillLine(pills), + ], + ], + ), ), ], ), + ), + ), + ); + } + + WearableIconVariant _iconVariantForPosition(DevicePosition? position) { + return switch (position) { + DevicePosition.left => WearableIconVariant.left, + DevicePosition.right => WearableIconVariant.right, + _ => WearableIconVariant.single, + }; + } + + bool _hasWearableIcon(WearableIconVariant initialVariant) { + final variantPath = wearable.getWearableIconPath(variant: initialVariant); + if (variantPath != null && variantPath.isNotEmpty) { + return true; + } + final fallbackPath = wearable.getWearableIconPath(); + return fallbackPath != null && fallbackPath.isNotEmpty; + } + + List _buildDeviceStatusPills() { + final hasBatteryStatus = wearable.hasCapability() || + wearable.hasCapability(); + final hasFirmwareInfo = wearable.hasCapability(); + final hasHardwareInfo = wearable.hasCapability(); + + String? sideLabel; + if (position == DevicePosition.left) { + sideLabel = 'L'; + } else if (position == DevicePosition.right) { + sideLabel = 'R'; + } + + return [ + if (sideLabel != null) + _MetadataBubble(label: sideLabel, highlighted: true) + else if (wearable.hasCapability()) + StereoPositionBadge(device: wearable.requireCapability()), + if (hasBatteryStatus) + BatteryStateView( + device: wearable, + showBackground: false, + ), + if (hasFirmwareInfo) + _FirmwareVersionBubble( + firmwareVersionFuture: infoCache.firmwareVersionFuture, + firmwareSupportFuture: infoCache.firmwareSupportFuture, + ), + if (hasHardwareInfo) + _HardwareVersionBubble( + hardwareVersionFuture: infoCache.hardwareVersionFuture, + ), + ]; + } + + Widget _buildStatusPillLine(List pills) { + if (pills.isEmpty) { + return const SizedBox.shrink(); + } + + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Row( + children: [ + for (var i = 0; i < pills.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + pills[i], + ], + ], + ), + ), + ), + ); + } +} + +class _SelectableWearableIconView extends StatefulWidget { + final Wearable wearable; + final WearableIconVariant initialVariant; + + const _SelectableWearableIconView({ + required this.wearable, + required this.initialVariant, + }); + + @override + State<_SelectableWearableIconView> createState() => + _SelectableWearableIconViewState(); +} + +class _SelectableWearableIconViewState + extends State<_SelectableWearableIconView> { + static final Expando> _positionFutureCache = + Expando>(); + + Future? _positionFuture; + + @override + void initState() { + super.initState(); + _configurePositionFuture(); + } + + @override + void didUpdateWidget(covariant _SelectableWearableIconView oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.wearable, widget.wearable) || + oldWidget.initialVariant != widget.initialVariant) { + _configurePositionFuture(); + } + } + + void _configurePositionFuture() { + if (widget.initialVariant != WearableIconVariant.single || + !widget.wearable.hasCapability()) { + _positionFuture = null; + return; + } + + final stereoDevice = widget.wearable.requireCapability(); + _positionFuture = + _positionFutureCache[stereoDevice] ??= stereoDevice.position; + } + + WearableIconVariant _variantForPosition(DevicePosition? position) { + return switch (position) { + DevicePosition.left => WearableIconVariant.left, + DevicePosition.right => WearableIconVariant.right, + _ => widget.initialVariant, + }; + } + + String? _resolveIconPath(WearableIconVariant variant) { + final variantPath = widget.wearable.getWearableIconPath(variant: variant); + if (variantPath != null && variantPath.isNotEmpty) { + return variantPath; + } + + if (variant != WearableIconVariant.single) { + final fallbackPath = widget.wearable.getWearableIconPath(); + if (fallbackPath != null && fallbackPath.isNotEmpty) { + return fallbackPath; + } + } + return null; + } + + Widget _buildIcon(WearableIconVariant variant) { + final path = _resolveIconPath(variant); + if (path == null) { + return const SizedBox.shrink(); + } + + if (path.toLowerCase().endsWith('.svg')) { + return SvgPicture.asset( + path, + fit: BoxFit.contain, + ); + } + + return Image.asset( + path, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Icon(Icons.watch_outlined), + ); + } + + @override + Widget build(BuildContext context) { + if (_positionFuture == null) { + return _buildIcon(widget.initialVariant); + } + + return FutureBuilder( + future: _positionFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox.shrink(); + } + final variant = _variantForPosition(snapshot.data); + if (variant == WearableIconVariant.single) { + return const SizedBox.shrink(); + } + return _buildIcon(variant); + }, + ); + } +} + +class _FirmwareVersionBubble extends StatelessWidget { + final Future? firmwareVersionFuture; + final Future? firmwareSupportFuture; + + const _FirmwareVersionBubble({ + required this.firmwareVersionFuture, + required this.firmwareSupportFuture, + }); + + @override + Widget build(BuildContext context) { + if (firmwareVersionFuture == null || firmwareSupportFuture == null) { + return const _MetadataBubble(label: 'FW', value: '--'); + } + + return FutureBuilder( + future: firmwareVersionFuture, + builder: (context, versionSnapshot) { + if (versionSnapshot.connectionState == ConnectionState.waiting) { + return const _MetadataBubble(label: 'FW', isLoading: true); + } + + final versionText = versionSnapshot.hasError + ? '--' + : (versionSnapshot.data?.toString() ?? '--'); + + return FutureBuilder( + future: firmwareSupportFuture, + builder: (context, supportSnapshot) { + IconData? statusIcon; + Color? statusColor; + + switch (supportSnapshot.data) { + case FirmwareSupportStatus.tooOld: + case FirmwareSupportStatus.tooNew: + statusIcon = Icons.warning_rounded; + statusColor = Colors.orange; + break; + case FirmwareSupportStatus.unknown: + statusIcon = Icons.help_rounded; + statusColor = Theme.of(context).colorScheme.onSurfaceVariant; + break; + default: + break; + } + + return _MetadataBubble( + label: 'FW', + value: versionText, + trailingIcon: statusIcon, + foregroundColor: statusColor, + ); + }, + ); + }, + ); + } +} + +class _HardwareVersionBubble extends StatelessWidget { + final Future? hardwareVersionFuture; + + const _HardwareVersionBubble({required this.hardwareVersionFuture}); + + @override + Widget build(BuildContext context) { + if (hardwareVersionFuture == null) { + return const _MetadataBubble(label: 'HW', value: '--'); + } + + return FutureBuilder( + future: hardwareVersionFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _MetadataBubble(label: 'HW', isLoading: true); + } + + final versionText = + snapshot.hasError ? '--' : (snapshot.data?.toString() ?? '--'); + + return _MetadataBubble( + label: 'HW', + value: versionText, + ); + }, + ); + } +} + +class _DeviceInfoFutureCache { + Future? firmwareVersionFuture; + Future? firmwareSupportFuture; + Future? hardwareVersionFuture; +} + +class _MetadataBubble extends StatelessWidget { + final String label; + final String? value; + final bool isLoading; + final bool highlighted; + final IconData? trailingIcon; + final Color? foregroundColor; + + const _MetadataBubble({ + required this.label, + this.value, + this.isLoading = false, + this.highlighted = false, + this.trailingIcon, + this.foregroundColor, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final defaultForeground = colorScheme.primary; + final resolvedForeground = foregroundColor ?? defaultForeground; + final effectiveForeground = + highlighted ? colorScheme.primary : resolvedForeground; + final backgroundColor = highlighted + ? effectiveForeground.withValues(alpha: 0.12) + : Colors.transparent; + final borderColor = highlighted + ? effectiveForeground.withValues(alpha: 0.24) + : resolvedForeground.withValues(alpha: 0.42); + final displayText = + isLoading ? '$label ...' : (value == null ? label : '$label $value'); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isLoading && trailingIcon != null) + Icon( + trailingIcon, + size: 14, + color: effectiveForeground, + ), + if (!isLoading && trailingIcon != null) const SizedBox(width: 6), + Text( + displayText, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: effectiveForeground, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ], ), ); } diff --git a/open_wearable/lib/assets/devices/pair.png b/open_wearable/lib/assets/devices/pair.png new file mode 100644 index 00000000..cd34dc91 Binary files /dev/null and b/open_wearable/lib/assets/devices/pair.png differ diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart index 70bead86..0c3c3fe3 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -5,8 +5,10 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/models/log_file_manager.dart'; -import 'package:open_wearable/models/wearable_connector.dart'; +import 'package:open_wearable/models/wearable_connector.dart' + hide WearableEvent; import 'package:open_wearable/router.dart'; +import 'package:open_wearable/theme/app_theme.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/widgets/app_banner.dart'; import 'package:open_wearable/widgets/global_app_banner_overlay.dart'; @@ -98,7 +100,8 @@ class _MyAppState extends State with WidgetsBindingObserver { ); }); - _wearableProvEventSub = wearablesProvider.wearableEventStream.listen((event) { + _wearableProvEventSub = + wearablesProvider.wearableEventStream.listen((event) { if (!mounted) return; // Handle firmware update available events with a dialog @@ -127,7 +130,8 @@ class _MyAppState extends State with WidgetsBindingObserver { child: const Text('Update Now'), onPressed: () { // Set the selected peripheral for firmware update - final updateProvider = Provider.of( + final updateProvider = + Provider.of( rootNavigatorKey.currentContext!, listen: false, ); @@ -147,32 +151,45 @@ class _MyAppState extends State with WidgetsBindingObserver { final appBannerController = context.read(); appBannerController.showBanner( (id) { - late final Color backgroundColor; - if (event is WearableErrorEvent) { - backgroundColor = Theme.of(context).colorScheme.error; - } else { - backgroundColor = Theme.of(context).colorScheme.primary; - } - - late final Color textColor; - if (event is WearableErrorEvent) { - textColor = Theme.of(context).colorScheme.onError; - } else { - textColor = Theme.of(context).colorScheme.onPrimary; - } + final colorScheme = Theme.of(context).colorScheme; + final bool isError = event is WearableErrorEvent; + final bool isTimeSync = event is WearableTimeSynchronizedEvent; + const timeSyncBackground = Color(0xFFEDE4FF); + const timeSyncForeground = Color(0xFF5A2EA6); + final backgroundColor = isError + ? colorScheme.errorContainer + : isTimeSync + ? timeSyncBackground + : colorScheme.primaryContainer; + final textColor = isError + ? colorScheme.onErrorContainer + : isTimeSync + ? timeSyncForeground + : colorScheme.onPrimaryContainer; + final icon = isError + ? Icons.error_outline_rounded + : isTimeSync + ? Icons.schedule_rounded + : Icons.info_outline_rounded; + final textStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + ); return AppBanner( - content: Text( - event.description, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: textColor, - ), + content: _buildBannerContent( + event: event, + textColor: textColor, + textStyle: textStyle, + accentColor: textColor, ), backgroundColor: backgroundColor, + foregroundColor: textColor, + leadingIcon: icon, key: ValueKey(id), ); }, - duration: const Duration(seconds: 3), + duration: const Duration(seconds: 4), ); }); @@ -230,6 +247,49 @@ class _MyAppState extends State with WidgetsBindingObserver { } } + Widget _buildBannerContent({ + required WearableEvent event, + required Color textColor, + required Color accentColor, + TextStyle? textStyle, + }) { + final resolvedTextStyle = textStyle ?? + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + ) ?? + TextStyle( + color: textColor, + fontWeight: FontWeight.w600, + ); + + if (event is! WearableTimeSynchronizedEvent) { + return Text(event.description, style: resolvedTextStyle); + } + + final parsed = _ParsedStereoSyncMessage.tryParse(event.description); + if (parsed == null) { + return Text(event.description, style: resolvedTextStyle); + } + + return Text.rich( + TextSpan( + style: resolvedTextStyle, + children: [ + if (parsed.prefix.isNotEmpty) TextSpan(text: '${parsed.prefix} '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _ToastStereoSideBadge( + sideLabel: parsed.sideLabel, + accentColor: accentColor, + ), + ), + if (parsed.suffix.isNotEmpty) TextSpan(text: ' ${parsed.suffix}'), + ], + ), + ); + } + @override void dispose() { _unsupportedFirmwareSub.cancel(); @@ -247,14 +307,9 @@ class _MyAppState extends State with WidgetsBindingObserver { iosUsesMaterialWidgets: true, ), builder: (context) => PlatformTheme( - materialLightTheme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), - cardTheme: const CardThemeData( - color: Colors.white, - elevation: 0, - ), - ), + materialLightTheme: AppTheme.lightTheme(), + materialDarkTheme: AppTheme.darkTheme(), + themeMode: ThemeMode.light, builder: (context) => GlobalAppBannerOverlay( child: PlatformApp.router( routerConfig: router, @@ -270,3 +325,72 @@ class _MyAppState extends State with WidgetsBindingObserver { ); } } + +class _ParsedStereoSyncMessage { + final String prefix; + final String sideLabel; + final String suffix; + + const _ParsedStereoSyncMessage({ + required this.prefix, + required this.sideLabel, + required this.suffix, + }); + + static _ParsedStereoSyncMessage? tryParse(String message) { + final match = RegExp(r'\((Left|Right)\)').firstMatch(message); + if (match == null) return null; + + final sideWord = match.group(1); + final sideLabel = switch (sideWord) { + 'Left' => 'L', + 'Right' => 'R', + _ => null, + }; + if (sideLabel == null) return null; + + final prefix = message.substring(0, match.start).trimRight(); + final suffix = message.substring(match.end).trimLeft(); + + return _ParsedStereoSyncMessage( + prefix: prefix, + sideLabel: sideLabel, + suffix: suffix, + ); + } +} + +class _ToastStereoSideBadge extends StatelessWidget { + final String sideLabel; + final Color accentColor; + + const _ToastStereoSideBadge({ + required this.sideLabel, + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + final foreground = accentColor; + final background = foreground.withValues(alpha: 0.16); + final border = foreground.withValues(alpha: 0.34); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 1), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: border), + ), + child: Text( + sideLabel, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w800, + letterSpacing: 0.1, + ), + ), + ); + } +} diff --git a/open_wearable/lib/models/bluetooth_auto_connector.dart b/open_wearable/lib/models/bluetooth_auto_connector.dart index bc68d6b8..bfffdcaa 100644 --- a/open_wearable/lib/models/bluetooth_auto_connector.dart +++ b/open_wearable/lib/models/bluetooth_auto_connector.dart @@ -21,7 +21,9 @@ class BluetoothAutoConnector { StreamSubscription? _scanSubscription; bool _isConnecting = false; + bool _isAttemptingConnection = false; bool _askedPermissionsThisSession = false; + int _sessionToken = 0; // Names to look for during scanning List _targetNames = []; @@ -35,10 +37,14 @@ class BluetoothAutoConnector { }); void start() async { - stop(); + final token = ++_sessionToken; + _stopInternal(); // Load the last connected names final prefs = await prefsFuture; + if (token != _sessionToken) { + return; + } _targetNames = prefs.getStringList(_connectedDeviceNamesKey) ?? []; // Start listening for successful connections (to save names and set disconnect logic) @@ -46,21 +52,20 @@ class BluetoothAutoConnector { wearableManager.connectStream.listen(_onDeviceConnected); // Initiate the connection sequence - _attemptConnection(); + _attemptConnection(token: token); } void stop() { + _sessionToken++; + _stopInternal(); + } + + void _stopInternal() { _connectSubscription?.cancel(); _connectSubscription = null; - _scanSubscription?.cancel(); - _scanSubscription = null; - // Stop any ongoing scan initiated by this class - // Use the public WearableManager function to stop the scan - wearableManager.setAutoConnect([]); - - // Cancel the local listener to prevent further triggers - _scanSubscription?.cancel(); - _scanSubscription = null; + _isAttemptingConnection = false; + _isConnecting = false; + _stopScanning(); } /// Called when the WearableManager successfully connects to a device. @@ -75,16 +80,7 @@ class BluetoothAutoConnector { } // Stop scanning immediately when a successful connection is made - if (_scanSubscription != null) { - // stop scan - wearableManager.setAutoConnect([]); - - _scanSubscription?.cancel(); - _scanSubscription = null; - - _scanSubscription?.cancel(); - _scanSubscription = null; - } + _stopScanning(); // Set up the disconnect listener to trigger a scan for the saved name. wearable.addDisconnectListener(() { @@ -99,24 +95,47 @@ class BluetoothAutoConnector { }); } - Future _attemptConnection() async { + Future _attemptConnection({int? token}) async { + final activeToken = token ?? _sessionToken; + if (activeToken != _sessionToken) { + return; + } + if (_isAttemptingConnection) { + return; + } + + _isAttemptingConnection = true; if (!Platform.isIOS) { final hasPerm = await wearableManager.hasPermissions(); + if (activeToken != _sessionToken) { + _isAttemptingConnection = false; + return; + } if (!hasPerm) { if (!_askedPermissionsThisSession) { _askedPermissionsThisSession = true; _showPermissionsDialog(); } logger.w('Skipping auto-connect: no permissions granted yet.'); + _isAttemptingConnection = false; return; } } - await connector.connectToSystemDevices(); + try { + await connector.connectToSystemDevices(); + if (activeToken != _sessionToken) { + return; + } - if (_targetNames.isNotEmpty) { - _setupScanListener(); - await wearableManager.startScan(); + if (_targetNames.isNotEmpty) { + _setupScanListener(); + await wearableManager.startScan(); + } + } catch (error, stackTrace) { + logger.w('Auto-connect attempt failed: $error\n$stackTrace'); + } finally { + _isAttemptingConnection = false; } } @@ -128,10 +147,7 @@ class BluetoothAutoConnector { if (_targetNames.contains(device.name)) { _isConnecting = true; - // stop scan - wearableManager.setAutoConnect([]); - _scanSubscription?.cancel(); - _scanSubscription = null; + _stopScanning(); logger.i( "Match found for ${device.name}. Connecting using rotating ID: ${device.id}", @@ -151,6 +167,11 @@ class BluetoothAutoConnector { }); } + void _stopScanning() { + _scanSubscription?.cancel(); + _scanSubscription = null; + } + void _showPermissionsDialog() { final nav = navStateGetter(); final navCtx = nav?.context; diff --git a/open_wearable/lib/models/sensor_streams.dart b/open_wearable/lib/models/sensor_streams.dart new file mode 100644 index 00000000..68235fd7 --- /dev/null +++ b/open_wearable/lib/models/sensor_streams.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +/// Shared sensor streams to avoid multiple direct subscriptions to +/// single-subscription sensor streams. +class SensorStreams { + SensorStreams._(); + + static final Map> _sharedStreams = {}; + + static Stream shared(Sensor sensor) { + return _sharedStreams.putIfAbsent( + sensor, + () => sensor.sensorStream.asBroadcastStream(), + ); + } + + static void clearForSensor(Sensor sensor) { + _sharedStreams.remove(sensor); + } +} diff --git a/open_wearable/lib/models/wearable_display_group.dart b/open_wearable/lib/models/wearable_display_group.dart new file mode 100644 index 00000000..a7182b79 --- /dev/null +++ b/open_wearable/lib/models/wearable_display_group.dart @@ -0,0 +1,358 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +class WearableDisplayGroup { + final Wearable primary; + final Wearable? secondary; + final Wearable? pairCandidate; + final DevicePosition? primaryPosition; + final DevicePosition? secondaryPosition; + final String displayName; + final String? stereoPairKey; + + const WearableDisplayGroup._({ + required this.primary, + required this.secondary, + required this.pairCandidate, + required this.primaryPosition, + required this.secondaryPosition, + required this.displayName, + required this.stereoPairKey, + }); + + factory WearableDisplayGroup.single({ + required Wearable wearable, + DevicePosition? position, + Wearable? pairCandidate, + String? stereoPairKey, + }) { + return WearableDisplayGroup._( + primary: wearable, + secondary: null, + pairCandidate: pairCandidate, + primaryPosition: position, + secondaryPosition: null, + displayName: wearable.name, + stereoPairKey: stereoPairKey, + ); + } + + factory WearableDisplayGroup.combined({ + required Wearable left, + required Wearable right, + required String displayName, + String? stereoPairKey, + }) { + return WearableDisplayGroup._( + primary: left, + secondary: right, + pairCandidate: null, + primaryPosition: DevicePosition.left, + secondaryPosition: DevicePosition.right, + displayName: displayName, + stereoPairKey: stereoPairKey ?? stereoPairKeyForDevices(left, right), + ); + } + + bool get isCombined => secondary != null; + + Wearable get representative => primary; + + Wearable? get leftDevice { + if (!isCombined) { + return primaryPosition == DevicePosition.left ? primary : null; + } + return primaryPosition == DevicePosition.left ? primary : secondary; + } + + Wearable? get rightDevice { + if (!isCombined) { + return primaryPosition == DevicePosition.right ? primary : null; + } + return primaryPosition == DevicePosition.right ? primary : secondary; + } + + String get identifiersLabel { + if (!isCombined) { + return primary.deviceId; + } + + final leftId = leftDevice?.deviceId ?? primary.deviceId; + final rightId = rightDevice?.deviceId ?? secondary!.deviceId; + return '$leftId / $rightId'; + } + + List get members => isCombined ? [primary, secondary!] : [primary]; + + static String stereoPairKeyForDevices(Wearable a, Wearable b) { + return stereoPairKeyForIds(a.deviceId, b.deviceId); + } + + static String stereoPairKeyForIds(String aDeviceId, String bDeviceId) { + final ids = [ + Uri.encodeComponent(aDeviceId), + Uri.encodeComponent(bDeviceId), + ]..sort(); + return '${ids.first}|${ids.last}'; + } + + static bool stereoPairKeyContainsDevice(String key, String deviceId) { + final encoded = Uri.encodeComponent(deviceId); + final parts = key.split('|'); + return parts.contains(encoded); + } +} + +class _StereoMetadata { + final Wearable wearable; + final DevicePosition? position; + final String? pairedDeviceId; + + const _StereoMetadata({ + required this.wearable, + required this.position, + required this.pairedDeviceId, + }); +} + +Future> buildWearableDisplayGroups( + List wearables, { + required bool Function(Wearable left, Wearable right) shouldCombinePair, +}) async { + if (wearables.isEmpty) { + return const []; + } + + final metadataById = await _buildStereoMetadata(wearables); + + final wearablesById = { + for (final wearable in wearables) wearable.deviceId: wearable, + }; + final used = {}; + final groups = []; + + for (final wearable in wearables) { + if (used.contains(wearable.deviceId)) { + continue; + } + + final metadata = metadataById[wearable.deviceId]; + if (metadata == null) { + used.add(wearable.deviceId); + groups.add(WearableDisplayGroup.single(wearable: wearable)); + continue; + } + + final partner = _findPartner( + current: metadata, + wearablesById: wearablesById, + metadataById: metadataById, + wearablesInOrder: wearables, + used: used, + ); + + if (partner != null) { + final left = metadata.position == DevicePosition.left + ? metadata.wearable + : partner.wearable; + final right = metadata.position == DevicePosition.right + ? metadata.wearable + : partner.wearable; + final pairKey = WearableDisplayGroup.stereoPairKeyForDevices(left, right); + final combine = shouldCombinePair(left, right); + + used.add(metadata.wearable.deviceId); + used.add(partner.wearable.deviceId); + + if (combine) { + groups.add( + WearableDisplayGroup.combined( + left: left, + right: right, + displayName: _combinedDisplayName(left.name, right.name), + stereoPairKey: pairKey, + ), + ); + } else { + groups.add( + WearableDisplayGroup.single( + wearable: left, + position: DevicePosition.left, + pairCandidate: right, + stereoPairKey: pairKey, + ), + ); + groups.add( + WearableDisplayGroup.single( + wearable: right, + position: DevicePosition.right, + pairCandidate: left, + stereoPairKey: pairKey, + ), + ); + } + + continue; + } + + used.add(wearable.deviceId); + groups.add( + WearableDisplayGroup.single( + wearable: wearable, + position: metadata.position, + ), + ); + } + + return groups; +} + +Future> _buildStereoMetadata( + List wearables, +) async { + final entries = await Future.wait( + wearables.map((wearable) async { + if (!wearable.hasCapability()) { + return null; + } + + final stereo = wearable.requireCapability(); + final positionFuture = stereo.position; + final pairedFuture = stereo.pairedDevice; + final position = await positionFuture; + final paired = await pairedFuture; + String? pairedDeviceId; + if (paired != null) { + for (final candidate in wearables) { + if (!candidate.hasCapability()) { + continue; + } + if (identical( + candidate.requireCapability(), + paired, + )) { + pairedDeviceId = candidate.deviceId; + break; + } + } + } + + return MapEntry( + wearable.deviceId, + _StereoMetadata( + wearable: wearable, + position: position, + pairedDeviceId: pairedDeviceId, + ), + ); + }), + ); + + final map = {}; + for (final entry in entries) { + if (entry != null) { + map[entry.key] = entry.value; + } + } + return map; +} + +_StereoMetadata? _findPartner({ + required _StereoMetadata current, + required Map wearablesById, + required Map metadataById, + required List wearablesInOrder, + required Set used, +}) { + final pairedId = current.pairedDeviceId; + if (pairedId != null && !used.contains(pairedId)) { + final pairedWearable = wearablesById[pairedId]; + final pairedMetadata = + pairedWearable == null ? null : metadataById[pairedId]; + if (pairedMetadata != null && + _canCombine( + a: current, + b: pairedMetadata, + requireMutualPairing: false, + )) { + return pairedMetadata; + } + } + + for (final candidateWearable in wearablesInOrder) { + if (candidateWearable.deviceId == current.wearable.deviceId) { + continue; + } + if (used.contains(candidateWearable.deviceId)) { + continue; + } + + final candidate = metadataById[candidateWearable.deviceId]; + if (candidate == null) { + continue; + } + if (_canCombine( + a: current, + b: candidate, + requireMutualPairing: false, + )) { + return candidate; + } + } + + return null; +} + +bool _canCombine({ + required _StereoMetadata a, + required _StereoMetadata b, + required bool requireMutualPairing, +}) { + final oppositePositions = (a.position == DevicePosition.left && + b.position == DevicePosition.right) || + (a.position == DevicePosition.right && b.position == DevicePosition.left); + if (!oppositePositions) { + return false; + } + + if (!_stereoNamesMatch(a.wearable.name, b.wearable.name)) { + return false; + } + + if (!requireMutualPairing) { + return true; + } + + return a.pairedDeviceId == b.wearable.deviceId && + b.pairedDeviceId == a.wearable.deviceId; +} + +String _combinedDisplayName(String leftName, String rightName) { + final leftBase = _normalizedStereoName(leftName); + final rightBase = _normalizedStereoName(rightName); + + if (leftBase.isNotEmpty && + leftBase.toLowerCase() == rightBase.toLowerCase()) { + return leftBase; + } + return leftName.trim(); +} + +bool _stereoNamesMatch(String a, String b) { + final normalizedA = _normalizedStereoName(a); + final normalizedB = _normalizedStereoName(b); + return normalizedA.toLowerCase() == normalizedB.toLowerCase(); +} + +String _normalizedStereoName(String name) { + var value = name.trim(); + value = value.replaceFirst( + RegExp(r'\s*\((left|right|l|r)\)$', caseSensitive: false), + '', + ); + value = value.replaceFirst( + RegExp(r'[\s_-]+(left|right|l|r)$', caseSensitive: false), + '', + ); + value = value.trim(); + return value.isEmpty ? name.trim() : value; +} diff --git a/open_wearable/lib/router.dart b/open_wearable/lib/router.dart index d4293e40..1be00791 100644 --- a/open_wearable/lib/router.dart +++ b/open_wearable/lib/router.dart @@ -15,6 +15,31 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; /// Global navigator key for go_router final GlobalKey rootNavigatorKey = GlobalKey(); +int _parseHomeSectionIndex(String? tabParam) { + if (tabParam == null || tabParam.isEmpty) { + return 0; + } + + switch (tabParam.toLowerCase()) { + case 'overview': + return 0; + case 'devices': + return 1; + case 'sensors': + return 2; + case 'apps': + return 3; + case 'settings': + return 4; + default: + final parsed = int.tryParse(tabParam); + if (parsed == null || parsed < 0 || parsed > 4) { + return 0; + } + return parsed; + } +} + /// Router configuration for the app final GoRouter router = GoRouter( navigatorKey: rootNavigatorKey, @@ -23,10 +48,15 @@ final GoRouter router = GoRouter( GoRoute( path: '/', name: 'home', - builder: (context, state) => const HeroMode( - enabled: false, - child: HomePage(), - ), + builder: (context, state) { + final initialSection = _parseHomeSectionIndex( + state.uri.queryParameters['tab'], + ); + return HeroMode( + enabled: false, + child: HomePage(initialSectionIndex: initialSection), + ); + }, ), GoRoute( path: '/connect-devices', diff --git a/open_wearable/lib/theme/app_theme.dart b/open_wearable/lib/theme/app_theme.dart new file mode 100644 index 00000000..146ba043 --- /dev/null +++ b/open_wearable/lib/theme/app_theme.dart @@ -0,0 +1,320 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static const Color _brand = Color(0xFF9A6F6B); + static const Color _onBrand = Color(0xFFFFFFFF); + static const Color _brandInk = Color(0xFF5A3D3A); + static const Color _lightBackground = Color(0xFFF4F7FB); + static const Color _darkBackground = Color(0xFF0B1117); + + static ThemeData lightTheme() { + final colorScheme = ColorScheme.fromSeed( + seedColor: _brand, + brightness: Brightness.light, + ).copyWith( + primary: _brand, + onPrimary: _onBrand, + secondary: const Color(0xFFAA807C), + onSecondary: _onBrand, + tertiary: const Color(0xFFBB938F), + onTertiary: _onBrand, + surface: Colors.white, + ); + + return _buildTheme( + colorScheme: colorScheme, + scaffoldBackgroundColor: _lightBackground, + ); + } + + static ThemeData darkTheme() { + final colorScheme = ColorScheme.fromSeed( + seedColor: _brand, + brightness: Brightness.dark, + ).copyWith( + primary: const Color(0xFFC79F9B), + secondary: const Color(0xFFD1ACA8), + tertiary: const Color(0xFFE0C1BE), + surface: const Color(0xFF111A22), + ); + + return _buildTheme( + colorScheme: colorScheme, + scaffoldBackgroundColor: _darkBackground, + ); + } + + static ThemeData _buildTheme({ + required ColorScheme colorScheme, + required Color scaffoldBackgroundColor, + }) { + final base = ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: scaffoldBackgroundColor, + canvasColor: scaffoldBackgroundColor, + ); + + final outlineColor = colorScheme.outline.withValues(alpha: 0.2); + final buttonTextStyle = const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + letterSpacing: 0.2, + ); + final shapeLarge = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide(color: outlineColor), + ); + final shapeMedium = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ); + final shapeSmall = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ); + + return base.copyWith( + textTheme: _textTheme(base.textTheme, colorScheme), + appBarTheme: AppBarTheme( + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: scaffoldBackgroundColor, + foregroundColor: colorScheme.onSurface, + surfaceTintColor: Colors.transparent, + centerTitle: false, + ), + cardTheme: CardThemeData( + color: colorScheme.surface, + elevation: 0, + surfaceTintColor: Colors.transparent, + shape: shapeLarge, + ), + listTileTheme: ListTileThemeData( + shape: shapeMedium, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 4, + ), + iconColor: colorScheme.primary, + titleTextStyle: base.textTheme.titleSmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + subtitleTextStyle: base.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + dividerTheme: DividerThemeData( + color: outlineColor, + thickness: 1, + space: 1, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surface.withValues(alpha: 0.9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: outlineColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: outlineColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: colorScheme.primary, + width: 1.6, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.error), + ), + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + type: BottomNavigationBarType.fixed, + backgroundColor: colorScheme.surface, + selectedItemColor: colorScheme.primary, + unselectedItemColor: colorScheme.onSurfaceVariant.withValues( + alpha: 0.92, + ), + selectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 12, + letterSpacing: 0.1, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + letterSpacing: 0.1, + ), + elevation: 0, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: colorScheme.surface, + indicatorColor: colorScheme.primaryContainer.withValues(alpha: 0.72), + elevation: 0, + surfaceTintColor: Colors.transparent, + height: 74, + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final selected = states.contains(WidgetState.selected); + return TextStyle( + color: + selected ? colorScheme.primary : colorScheme.onSurfaceVariant, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + fontSize: 12, + ); + }), + iconTheme: WidgetStateProperty.resolveWith((states) { + final selected = states.contains(WidgetState.selected); + return IconThemeData( + color: + selected ? colorScheme.primary : colorScheme.onSurfaceVariant, + ); + }), + ), + navigationRailTheme: NavigationRailThemeData( + backgroundColor: colorScheme.surface, + useIndicator: true, + indicatorColor: colorScheme.primaryContainer.withValues(alpha: 0.72), + selectedLabelTextStyle: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + unselectedLabelTextStyle: TextStyle( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + selectedIconTheme: IconThemeData(color: colorScheme.primary), + unselectedIconTheme: IconThemeData(color: colorScheme.onSurfaceVariant), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 0, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + disabledBackgroundColor: + colorScheme.onSurface.withValues(alpha: 0.12), + disabledForegroundColor: colorScheme.onSurface.withValues(alpha: 0.5), + minimumSize: const Size(0, 46), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: shapeSmall, + textStyle: buttonTextStyle, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + disabledBackgroundColor: + colorScheme.onSurface.withValues(alpha: 0.12), + disabledForegroundColor: colorScheme.onSurface.withValues(alpha: 0.5), + minimumSize: const Size(0, 46), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: shapeSmall, + textStyle: buttonTextStyle, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: _brandInk, + minimumSize: const Size(0, 46), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: shapeSmall, + side: BorderSide(color: outlineColor), + textStyle: buttonTextStyle, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: _brandInk, + shape: shapeSmall, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + textStyle: buttonTextStyle, + ), + ), + chipTheme: base.chipTheme.copyWith( + backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.5), + side: BorderSide.none, + shape: const StadiumBorder(), + labelStyle: base.textTheme.labelMedium?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + ), + ), + dialogTheme: DialogThemeData( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + shape: shapeLarge, + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + progressIndicatorTheme: ProgressIndicatorThemeData( + color: colorScheme.primary, + ), + sliderTheme: base.sliderTheme.copyWith( + activeTrackColor: colorScheme.primary, + inactiveTrackColor: colorScheme.primary.withValues(alpha: 0.2), + thumbColor: colorScheme.primary, + overlayColor: colorScheme.primary.withValues(alpha: 0.14), + ), + switchTheme: SwitchThemeData( + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colorScheme.primary.withValues(alpha: 0.4); + } + return colorScheme.outline.withValues(alpha: 0.28); + }), + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colorScheme.primary; + } + return colorScheme.surface; + }), + ), + ); + } + + static TextTheme _textTheme(TextTheme base, ColorScheme colorScheme) { + return base + .copyWith( + headlineSmall: base.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + titleLarge: base.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + titleMedium: base.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + ), + titleSmall: base.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + bodyLarge: base.bodyLarge?.copyWith( + height: 1.32, + ), + bodyMedium: base.bodyMedium?.copyWith( + height: 1.32, + ), + labelLarge: base.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ) + .apply( + bodyColor: colorScheme.onSurface, + displayColor: colorScheme.onSurface, + ); + } +} diff --git a/open_wearable/lib/view_models/sensor_configuration_provider.dart b/open_wearable/lib/view_models/sensor_configuration_provider.dart index f63bce67..2613cf4b 100644 --- a/open_wearable/lib/view_models/sensor_configuration_provider.dart +++ b/open_wearable/lib/view_models/sensor_configuration_provider.dart @@ -5,6 +5,22 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import '../models/logger.dart'; +class SensorConfigurationRestoreResult { + final int restoredCount; + final int requestedCount; + final int skippedCount; + final int unknownConfigCount; + + const SensorConfigurationRestoreResult({ + required this.restoredCount, + required this.requestedCount, + required this.skippedCount, + required this.unknownConfigCount, + }); + + bool get hasRestoredValues => restoredCount > 0; +} + class SensorConfigurationProvider with ChangeNotifier { final SensorConfigurationManager _sensorConfigurationManager; @@ -201,29 +217,59 @@ class SensorConfigurationProvider with ChangeNotifier { ); } - Future restoreFromJson(Map jsonMap) async { - Map restoredConfigurations = - {}; - for (final config in _sensorConfigurations.keys) { + Future restoreFromJson( + Map jsonMap, + ) async { + final restoredConfigurations = + {}; + int requestedCount = 0; + int skippedCount = 0; + + final knownConfigurations = + _sensorConfigurationManager.sensorConfigurations.toList(); + final knownConfigNames = + knownConfigurations.map((config) => config.name).toSet(); + + for (final config in knownConfigurations) { final selectedKey = jsonMap[config.name]; if (selectedKey == null) continue; - try { - final SensorConfigurationValue matchingValue = config.values.firstWhere( - (v) => v.key == selectedKey, + requestedCount += 1; + + final matchingValue = config.values + .where((value) => value.key == selectedKey) + .cast() + .firstOrNull; + + if (matchingValue == null) { + skippedCount += 1; + logger.w( + 'Skipped restoring "${config.name}" because value "$selectedKey" is no longer available.', ); - restoredConfigurations[config] = matchingValue; - } on StateError { - logger.e("Failed to restore configuration for ${config.name}"); - return false; + continue; } + + restoredConfigurations[config] = matchingValue; } + for (final config in restoredConfigurations.keys) { _sensorConfigurations[config] = restoredConfigurations[config]!; _updateSelectedOptions(config); } - notifyListeners(); - return true; + + if (restoredConfigurations.isNotEmpty) { + notifyListeners(); + } + + final unknownConfigCount = + jsonMap.keys.where((name) => !knownConfigNames.contains(name)).length; + + return SensorConfigurationRestoreResult( + restoredCount: restoredConfigurations.length, + requestedCount: requestedCount, + skippedCount: skippedCount, + unknownConfigCount: unknownConfigCount, + ); } @override diff --git a/open_wearable/lib/view_models/sensor_configuration_storage.dart b/open_wearable/lib/view_models/sensor_configuration_storage.dart index 28586981..ada18289 100644 --- a/open_wearable/lib/view_models/sensor_configuration_storage.dart +++ b/open_wearable/lib/view_models/sensor_configuration_storage.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; class SensorConfigurationStorage { + static const String _scopeSeparator = '__'; + /// Returns the directory where sensor configurations are stored. /// Creates the directory if it does not exist. static Future _getConfigDirectory() async { @@ -18,9 +20,13 @@ class SensorConfigurationStorage { /// Each file is expected to be a JSON file with a specific configuration. static Future> _getAllConfigFiles() async { final configDir = await _getConfigDirectory(); - return configDir.list().where((file) => - file is File && file.path.endsWith('.json'), - ).cast().toList(); + return configDir + .list() + .where( + (file) => file is File && file.path.endsWith('.json'), + ) + .cast() + .toList(); } /// Returns the file for a specific configuration key. @@ -33,7 +39,10 @@ class SensorConfigurationStorage { /// Saves a configuration for a specific key. /// If the file already exists, it will be overwritten. /// The configuration is expected to be a map of string key-value pairs. - static Future saveConfiguration(String key, Map config) async { + static Future saveConfiguration( + String key, + Map config, + ) async { final File file = await _getConfigFile(key); await file.writeAsString(jsonEncode(config)); } @@ -54,7 +63,8 @@ class SensorConfigurationStorage { final configFiles = await _getAllConfigFiles(); for (final file in configFiles) { final contents = await file.readAsString(); - allConfigs[_getKeyFromFile(file)] = Map.from(jsonDecode(contents)); + allConfigs[_getKeyFromFile(file)] = + Map.from(jsonDecode(contents)); } return allConfigs; } @@ -77,5 +87,33 @@ class SensorConfigurationStorage { } } - static String sanitizeKey(String key) => key.replaceAll(RegExp(r'[^\w\-]'), '_'); + static String scopedPrefix(String scope) => + '${sanitizeKey(scope)}$_scopeSeparator'; + + static String buildScopedKey({ + required String scope, + required String name, + }) { + final sanitizedName = sanitizeKey(name.trim()); + return '${scopedPrefix(scope)}$sanitizedName'; + } + + static bool keyMatchesScope(String key, String scope) { + return key.startsWith(scopedPrefix(scope)); + } + + static String displayNameFromScopedKey( + String key, { + required String scope, + }) { + if (!keyMatchesScope(key, scope)) { + return key.replaceAll('_', ' '); + } + return key.substring(scopedPrefix(scope).length).replaceAll('_', ' '); + } + + static bool isLegacyUnscopedKey(String key) => !key.contains(_scopeSeparator); + + static String sanitizeKey(String key) => + key.replaceAll(RegExp(r'[^\w\-]'), '_'); } diff --git a/open_wearable/lib/view_models/sensor_data_provider.dart b/open_wearable/lib/view_models/sensor_data_provider.dart index 121715cd..6295558a 100644 --- a/open_wearable/lib/view_models/sensor_data_provider.dart +++ b/open_wearable/lib/view_models/sensor_data_provider.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/sensor_streams.dart'; class SensorDataProvider with ChangeNotifier { final Sensor sensor; @@ -21,12 +22,14 @@ class SensorDataProvider with ChangeNotifier { required this.sensor, this.timeWindow = 5, }) { - _timestampCutoffMs = pow(10, -sensor.timestampExponent).toInt() * timeWindow; + _timestampCutoffMs = + pow(10, -sensor.timestampExponent).toInt() * timeWindow; _listenToStream(); } void _listenToStream() { - _sensorStreamSubscription = sensor.sensorStream.listen((sensorValue) { + _sensorStreamSubscription = + SensorStreams.shared(sensor).listen((sensorValue) { sensorValues.add(sensorValue); final cutoff = sensorValue.timestamp - _timestampCutoffMs; sensorValues.removeWhere((v) => v.timestamp < cutoff); diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index 11ff9909..5db2df35 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import '../models/logger.dart'; +import '../models/sensor_streams.dart'; class SensorRecorderProvider with ChangeNotifier { final Map> _recorders = {}; @@ -75,7 +76,8 @@ class SensorRecorderProvider with ChangeNotifier { }); if (wearable.hasCapability()) { - for (Sensor sensor in wearable.requireCapability().sensors) { + for (Sensor sensor + in wearable.requireCapability().sensors) { if (!_recorders[wearable]!.containsKey(sensor)) { _recorders[wearable]![sensor] = Recorder(columns: sensor.axisNames); } @@ -147,7 +149,7 @@ class SensorRecorderProvider with ChangeNotifier { File file = await recorder.start( filepath: filepath, - inputStream: sensor.sensorStream, + inputStream: SensorStreams.shared(sensor), ); logger.i( diff --git a/open_wearable/lib/view_models/wearables_provider.dart b/open_wearable/lib/view_models/wearables_provider.dart index 1eb50be0..367a4116 100644 --- a/open_wearable/lib/view_models/wearables_provider.dart +++ b/open_wearable/lib/view_models/wearables_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.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 '../models/logger.dart'; @@ -83,10 +84,43 @@ class WearablesProvider with ChangeNotifier { final List _wearables = []; final Map _sensorConfigurationProviders = {}; + final Set _splitStereoPairKeys = {}; List get wearables => _wearables; Map get sensorConfigurationProviders => _sensorConfigurationProviders; + bool isStereoPairCombined({ + required Wearable first, + required Wearable second, + }) { + final pairKey = WearableDisplayGroup.stereoPairKeyForDevices(first, second); + return !_splitStereoPairKeys.contains(pairKey); + } + + bool isStereoPairKeyCombined(String pairKey) { + return !_splitStereoPairKeys.contains(pairKey); + } + + void setStereoPairCombined({ + required Wearable first, + required Wearable second, + required bool combined, + }) { + final pairKey = WearableDisplayGroup.stereoPairKeyForDevices(first, second); + setStereoPairKeyCombined(pairKey: pairKey, combined: combined); + } + + void setStereoPairKeyCombined({ + required String pairKey, + required bool combined, + }) { + final changed = combined + ? _splitStereoPairKeys.remove(pairKey) + : _splitStereoPairKeys.add(pairKey); + if (changed) { + notifyListeners(); + } + } final _unsupportedFirmwareEventsController = StreamController.broadcast(); @@ -132,11 +166,35 @@ class WearablesProvider with ChangeNotifier { }); } + Future _wearableNameWithSide(Wearable wearable) async { + if (!wearable.hasCapability()) { + return wearable.name; + } + + try { + final position = await wearable.requireCapability().position; + return switch (position) { + DevicePosition.left => '${wearable.name} (Left)', + DevicePosition.right => '${wearable.name} (Right)', + _ => wearable.name, + }; + } catch (_) { + return wearable.name; + } + } + Future _syncTimeAndEmit({ required Wearable wearable, - required String successDescription, - required String failureDescription, + required bool fromCapabilityChange, }) async { + final wearableLabel = await _wearableNameWithSide(wearable); + final successDescription = fromCapabilityChange + ? 'Time synchronized for $wearableLabel after capability update' + : 'Time synchronized for $wearableLabel'; + final failureDescription = fromCapabilityChange + ? 'Failed to synchronize time for $wearableLabel after capability update' + : 'Failed to synchronize time for $wearableLabel'; + try { logger.d('Synchronizing time for wearable ${wearable.name}'); await (wearable.requireCapability()) @@ -154,7 +212,7 @@ class WearablesProvider with ChangeNotifier { ); _emitWearableError( wearable: wearable, - errorMessage: 'Failed to synchronize time with ${wearable.name}: $e', + errorMessage: 'Failed to synchronize time with $wearableLabel: $e', description: failureDescription, ); } @@ -191,8 +249,7 @@ class WearablesProvider with ChangeNotifier { _scheduleMicrotask( () => _syncTimeAndEmit( wearable: wearable, - successDescription: 'Time synchronized for ${wearable.name}', - failureDescription: 'Failed to synchronize time for ${wearable.name}', + fromCapabilityChange: false, ), ); } @@ -349,6 +406,12 @@ class WearablesProvider with ChangeNotifier { } void removeWearable(Wearable wearable) { + _splitStereoPairKeys.removeWhere( + (key) => WearableDisplayGroup.stereoPairKeyContainsDevice( + key, + wearable.deviceId, + ), + ); _wearables.remove(wearable); _sensorConfigurationProviders.remove(wearable); _capabilitySubscriptions.remove(wearable)?.cancel(); @@ -377,10 +440,7 @@ class WearablesProvider with ChangeNotifier { _scheduleMicrotask( () => _syncTimeAndEmit( wearable: wearable, - successDescription: - 'Time synchronized for ${wearable.name} after capability change', - failureDescription: - 'Failed to synchronize time for ${wearable.name} after capability change', + fromCapabilityChange: true, ), ); } diff --git a/open_wearable/lib/widgets/app_banner.dart b/open_wearable/lib/widgets/app_banner.dart index 3d5a105e..fa2a1904 100644 --- a/open_wearable/lib/widgets/app_banner.dart +++ b/open_wearable/lib/widgets/app_banner.dart @@ -1,53 +1,70 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../view_models/app_banner_controller.dart'; class AppBanner extends StatelessWidget { final Widget content; final Color backgroundColor; + final Color? foregroundColor; + final IconData? leadingIcon; const AppBanner({ super.key, required this.content, this.backgroundColor = Colors.blue, + this.foregroundColor, + this.leadingIcon, }); @override Widget build(BuildContext context) { - return Stack( - clipBehavior: Clip.none, - children: [ - Card( - elevation: 3, + final resolvedForeground = foregroundColor ?? Colors.white; + final borderColor = resolvedForeground.withValues(alpha: 0.22); + + return Material( + color: Colors.transparent, + child: DecoratedBox( + decoration: BoxDecoration( color: backgroundColor, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 40, 16), - child: content, - ), - ), - Positioned( - top: 8, - right: 8, - child: IconButton( - padding: const EdgeInsets.all(0), - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.11), + blurRadius: 14, + offset: const Offset(0, 4), ), - style: ButtonStyle( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: resolvedForeground, + fontWeight: FontWeight.w600, + ) ?? + TextStyle( + color: resolvedForeground, + fontWeight: FontWeight.w600, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (leadingIcon != null) ...[ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + leadingIcon, + size: 18, + color: resolvedForeground, + ), + ), + const SizedBox(width: 10), + ], + Expanded(child: content), + ], ), - splashRadius: 18, - icon: const Icon(Icons.close, size: 20, color: Colors.white), - onPressed: () { - final controller = - Provider.of(context, listen: false); - controller.hideBanner(this); - }, ), ), - ], + ), ); } } diff --git a/open_wearable/lib/widgets/devices/battery_state.dart b/open_wearable/lib/widgets/devices/battery_state.dart index cbaaa3e7..2fa77107 100644 --- a/open_wearable/lib/widgets/devices/battery_state.dart +++ b/open_wearable/lib/widgets/devices/battery_state.dart @@ -1,95 +1,224 @@ import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; -class BatteryStateView extends StatelessWidget { - final Wearable _device; +class BatteryStateView extends StatefulWidget { + final Wearable device; + final bool showBackground; - const BatteryStateView({super.key, required Wearable device}) - : _device = device; + const BatteryStateView({ + super.key, + required this.device, + this.showBackground = true, + }); + + @override + State createState() => _BatteryStateViewState(); +} + +class _BatteryStateViewState extends State { + bool _hasBatteryLevel = false; + bool _hasPowerStatus = false; + Stream? _batteryPercentageStream; + Stream? _powerStatusStream; + + @override + void initState() { + super.initState(); + _resolveBatteryStreams(); + } + + @override + void didUpdateWidget(covariant BatteryStateView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.device != widget.device) { + _resolveBatteryStreams(); + } + } + + void _resolveBatteryStreams() { + _hasBatteryLevel = widget.device.hasCapability(); + _hasPowerStatus = widget.device.hasCapability(); + + _batteryPercentageStream = _hasBatteryLevel + ? widget.device + .requireCapability() + .batteryPercentageStream + : null; + + _powerStatusStream = _hasPowerStatus + ? widget.device + .requireCapability() + .powerStatusStream + : null; + } @override Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_device.hasCapability()) - StreamBuilder( - stream: _device.requireCapability().batteryPercentageStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - return PlatformText("${snapshot.data}%"); - } else { - return PlatformCircularProgressIndicator(); - } - }, - ), - if (_device.hasCapability()) - StreamBuilder( - stream: _device.requireCapability().powerStatusStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - if (!snapshot.data!.batteryPresent) { - return Icon(Icons.battery_unknown_rounded); - } - - if (snapshot.data!.chargeState == ChargeState.charging) { - return Icon(Icons.battery_charging_full_rounded); - } - - switch (snapshot.data!.chargeLevel) { - case BatteryChargeLevel.good: - return Icon(Icons.battery_full); - case BatteryChargeLevel.low: - return Icon(Icons.battery_3_bar_rounded); - case BatteryChargeLevel.critical: - return Icon(Icons.battery_1_bar_rounded); - case BatteryChargeLevel.unknown: - return Icon(Icons.battery_unknown); - } - } else { - return PlatformCircularProgressIndicator(); - } - }, - ) - else if (_device.hasCapability()) - StreamBuilder( - stream: _device.requireCapability().batteryPercentageStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - return Icon(getBatteryIcon(snapshot.data!)); - } else { - return PlatformCircularProgressIndicator(); - } + if (!_hasBatteryLevel && !_hasPowerStatus) { + return const SizedBox.shrink(); + } + + if (_hasBatteryLevel && _hasPowerStatus) { + return StreamBuilder( + stream: _batteryPercentageStream, + builder: (context, batterySnapshot) { + return StreamBuilder( + stream: _powerStatusStream, + builder: (context, powerSnapshot) { + return _BatteryBadge( + batteryLevel: batterySnapshot.data, + powerStatus: powerSnapshot.data, + isLoading: !batterySnapshot.hasData && !powerSnapshot.hasData, + showBackground: widget.showBackground, + ); }, - ), - ], + ); + }, + ); + } + + if (_hasPowerStatus) { + return StreamBuilder( + stream: _powerStatusStream, + builder: (context, snapshot) { + return _BatteryBadge( + batteryLevel: null, + powerStatus: snapshot.data, + isLoading: !snapshot.hasData, + showBackground: widget.showBackground, + ); + }, + ); + } + + return StreamBuilder( + stream: _batteryPercentageStream, + builder: (context, snapshot) { + return _BatteryBadge( + batteryLevel: snapshot.data, + isLoading: !snapshot.hasData, + showBackground: widget.showBackground, + ); + }, ); } +} + +class _BatteryBadge extends StatelessWidget { + final int? batteryLevel; + final BatteryPowerStatus? powerStatus; + final bool isLoading; + final bool showBackground; + + const _BatteryBadge({ + required this.batteryLevel, + this.powerStatus, + this.isLoading = false, + this.showBackground = true, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final normalizedLevel = batteryLevel?.clamp(0, 100); + + final batteryPresent = powerStatus?.batteryPresent ?? true; + final charging = powerStatus?.chargeState == ChargeState.charging; + final Color foregroundColor = colors.primary; + + final Color backgroundColor = + showBackground ? colors.surface : Colors.transparent; + final Color borderColor = foregroundColor.withValues(alpha: 0.42); - IconData getBatteryIcon(int batteryLevel) { - int batteryBars = (batteryLevel / 12.5).toInt(); - - switch (batteryBars) { - case 0: - return Icons.battery_0_bar_rounded; - case 1: - return Icons.battery_1_bar_rounded; - case 2: - return Icons.battery_2_bar_rounded; - case 3: - return Icons.battery_3_bar_rounded; - case 4: - return Icons.battery_4_bar_rounded; - case 5: - return Icons.battery_5_bar_rounded; - case 6: - return Icons.battery_6_bar_rounded; - case 7: - case 8: - return Icons.battery_full_rounded; + final IconData icon; + final String label; + + if (normalizedLevel != null) { + icon = charging + ? Icons.battery_charging_full_rounded + : _batteryIconForPercent(normalizedLevel); + label = "$normalizedLevel%"; + } else if (!batteryPresent) { + icon = Icons.battery_unknown_rounded; + label = "No battery"; + } else if (charging) { + icon = Icons.battery_charging_full_rounded; + label = "Charging"; + } else { + icon = _batteryIconForChargeLevel(powerStatus?.chargeLevel); + label = switch (powerStatus?.chargeLevel) { + BatteryChargeLevel.critical => "Critical", + BatteryChargeLevel.low => "Low", + BatteryChargeLevel.good => "Battery", + _ => "--", + }; } - return Icons.battery_unknown_rounded; + final showLoadingPlaceholder = + isLoading && batteryLevel == null && powerStatus == null; + final displayIcon = + showLoadingPlaceholder ? Icons.battery_unknown_rounded : icon; + final displayLabel = showLoadingPlaceholder ? "..." : label; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(displayIcon, size: 15, color: foregroundColor), + const SizedBox(width: 6), + Text( + displayLabel, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ], + ), + ); + } +} + +IconData _batteryIconForChargeLevel(BatteryChargeLevel? chargeLevel) { + return switch (chargeLevel) { + BatteryChargeLevel.good => Icons.battery_full_rounded, + BatteryChargeLevel.low => Icons.battery_3_bar_rounded, + BatteryChargeLevel.critical => Icons.battery_1_bar_rounded, + _ => Icons.battery_unknown_rounded, + }; +} + +IconData _batteryIconForPercent(int batteryLevel) { + final batteryBars = (batteryLevel / 12.5).toInt(); + + switch (batteryBars) { + case 0: + return Icons.battery_0_bar_rounded; + case 1: + return Icons.battery_1_bar_rounded; + case 2: + return Icons.battery_2_bar_rounded; + case 3: + return Icons.battery_3_bar_rounded; + case 4: + return Icons.battery_4_bar_rounded; + case 5: + return Icons.battery_5_bar_rounded; + case 6: + return Icons.battery_6_bar_rounded; + case 7: + case 8: + return Icons.battery_full_rounded; + default: + return Icons.battery_unknown_rounded; } } diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index 96bc20ef..eff52a22 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -3,7 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.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/wearables_provider.dart'; +import 'package:open_wearable/widgets/devices/devices_page.dart'; +import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:provider/provider.dart'; import '../../models/logger.dart'; @@ -22,10 +25,14 @@ class ConnectDevicesPage extends StatefulWidget { class _ConnectDevicesPageState extends State { final WearableManager _wearableManager = WearableManager(); - StreamSubscription? _scanSubscription; + StreamSubscription? _scanSubscription; + Timer? _scanIndicatorTimer; - List discoveredDevices = []; - Map connectingDevices = {}; + final List _discoveredDevices = []; + final Map _connectingDevices = {}; + + bool _isScanning = false; + DateTime? _lastScanStartedAt; @override void initState() { @@ -35,56 +42,134 @@ class _ConnectDevicesPageState extends State { @override Widget build(BuildContext context) { - final WearablesProvider wearablesProvider = - Provider.of(context); - - List connectedDevicesWidgets = - wearablesProvider.wearables.map((wearable) { - return PlatformListTile( - title: PlatformText(wearable.name), - subtitle: PlatformText(wearable.deviceId), - trailing: Icon(PlatformIcons(context).checkMark), - ); - }).toList(); - List discoveredDevicesWidgets = discoveredDevices.map((device) { - return PlatformListTile( - title: PlatformText(device.name), - subtitle: PlatformText(device.id), - trailing: _buildTrailingWidget(device.id), - onTap: () { - _connectToDevice(device, context); - }, - ); - }).toList(); + final wearablesProvider = context.watch(); + final connectedWearables = wearablesProvider.wearables; + final connectedDeviceIds = + connectedWearables.map((wearable) => wearable.deviceId).toSet(); + final connectedGroups = _orderGroupsForOverview( + connectedWearables + .map( + (wearable) => WearableDisplayGroup.single( + wearable: wearable, + ), + ) + .toList(), + ); + + final availableDevices = _discoveredDevices + .where((device) => !connectedDeviceIds.contains(device.id)) + .toList() + ..sort((a, b) { + final nameCompare = _deviceName(a) + .toLowerCase() + .compareTo(_deviceName(b).toLowerCase()); + if (nameCompare != 0) return nameCompare; + return a.id.compareTo(b.id); + }); return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText('Connect Devices'), + title: const Text('Connect Devices'), + trailingActions: [ + const AppBarRecordingIndicator(), + PlatformIconButton( + icon: _isScanning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.bluetooth_searching), + onPressed: + _isScanning ? null : () => _startScanning(clearPrevious: true), + ), + ], ), - body: Padding( - padding: const EdgeInsets.all(10.0), + body: RefreshIndicator( + onRefresh: () => _startScanning(clearPrevious: true), child: ListView( - shrinkWrap: true, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(12, 10, 12, 16), children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: PlatformText( - 'Connected Devices', - style: Theme.of(context).textTheme.titleSmall, - ), + _buildScanStatusCard( + context, + connectedCount: connectedWearables.length, + discoveredCount: availableDevices.length, + ), + const SizedBox(height: 12), + _buildSectionHeader( + context, + title: 'Connected', + count: connectedWearables.length, ), - ...connectedDevicesWidgets, - Padding( - padding: const EdgeInsets.all(8.0), - child: PlatformText( - 'Discovered Devices', - style: Theme.of(context).textTheme.titleSmall, + if (connectedWearables.isEmpty) + _buildEmptyCard( + context, + title: 'No devices connected', + subtitle: 'Tap a discovered device below to connect.', + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < connectedGroups.length; i++) ...[ + DeviceRow(group: connectedGroups[i]), + if (i < connectedGroups.length - 1) + const SizedBox(height: 8), + ], + ], ), + const SizedBox(height: 12), + _buildSectionHeader( + context, + title: 'Available', + count: availableDevices.length, ), - ...discoveredDevicesWidgets, + if (availableDevices.isEmpty) + _buildEmptyCard( + context, + title: _isScanning + ? 'Scanning for devices...' + : 'No devices found yet', + subtitle: _isScanning + ? 'Make sure your wearable is turned on and nearby.' + : 'Press scan again or pull to refresh.', + ) + else + ...availableDevices.map( + (device) => Card( + margin: const EdgeInsets.only(bottom: 8), + child: PlatformListTile( + leading: const Icon(Icons.bluetooth), + title: PlatformText(_deviceName(device)), + subtitle: PlatformText(device.id), + trailing: _buildTrailingWidget(device), + onTap: _connectingDevices[device.id] == true + ? null + : () => _connectToDevice(device, context), + ), + ), + ), + const SizedBox(height: 10), PlatformElevatedButton( - onPressed: _startScanning, - child: PlatformText('Scan'), + onPressed: _isScanning + ? null + : () => _startScanning(clearPrevious: true), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isScanning) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + const Icon(Icons.bluetooth_searching), + const SizedBox(width: 8), + Text(_isScanning ? 'Scanning...' : 'Scan Again'), + ], + ), ), ], ), @@ -92,73 +177,312 @@ class _ConnectDevicesPageState extends State { ); } - Widget _buildTrailingWidget(String id) { - if (connectingDevices[id] == true) { + Widget _buildScanStatusCard( + BuildContext context, { + required int connectedCount, + required int discoveredCount, + }) { + final statusText = + _isScanning ? 'Scanning for nearby devices' : 'Ready to scan'; + final helperText = _lastScanStartedAt == null + ? 'Use Scan to discover nearby wearables.' + : 'Last scan: ${_formatScanTime(_lastScanStartedAt!)}'; + + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _isScanning ? Icons.radar : Icons.bluetooth_searching, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + statusText, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + helperText, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _StatusPill(label: '$connectedCount connected'), + _StatusPill(label: '$discoveredCount available'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildSectionHeader( + BuildContext context, { + required String title, + required int count, + }) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: Row( + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + _StatusPill(label: '$count'), + ], + ), + ); + } + + Widget _buildEmptyCard( + BuildContext context, { + required String title, + required String subtitle, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: const Icon(Icons.info_outline), + title: Text(title), + subtitle: Text(subtitle), + ), + ); + } + + Widget _buildTrailingWidget(DiscoveredDevice device) { + if (_connectingDevices[device.id] == true) { return SizedBox( height: 24, width: 24, child: PlatformCircularProgressIndicator(), ); } - return const SizedBox.shrink(); + return PlatformTextButton( + onPressed: () => _connectToDevice(device, context), + child: const Text('Connect'), + ); + } + + String _deviceName(DiscoveredDevice device) { + final name = device.name.trim(); + if (name.isEmpty) return 'Unnamed device'; + return name; + } + + List _orderGroupsForOverview( + List groups, + ) { + final indexed = groups.asMap().entries.toList(); + + int rank(WearableDisplayGroup group) { + if (group.isCombined) { + return 0; + } + if (group.primaryPosition == DevicePosition.left) { + return 1; + } + if (group.primaryPosition == DevicePosition.right) { + return 2; + } + return 3; + } + + indexed.sort((a, b) { + final rankA = rank(a.value); + final rankB = rank(b.value); + if (rankA != rankB) { + return rankA.compareTo(rankB); + } + + if (rankA <= 2) { + final byName = a.value.displayName + .toLowerCase() + .compareTo(b.value.displayName.toLowerCase()); + if (byName != 0) { + return byName; + } + } + + return a.key.compareTo(b.key); + }); + + return indexed.map((entry) => entry.value).toList(); } - void _startScanning() async { - _wearableManager.startScan(); + String _formatScanTime(DateTime startedAt) { + final elapsed = DateTime.now().difference(startedAt); + if (elapsed.inSeconds < 10) return 'just now'; + if (elapsed.inMinutes < 1) return '${elapsed.inSeconds}s ago'; + if (elapsed.inHours < 1) return '${elapsed.inMinutes}m ago'; + return '${elapsed.inHours}h ago'; + } + + void _stopScanning({ + bool clearDiscovered = false, + bool updateUi = true, + }) { + _scanIndicatorTimer?.cancel(); + _scanIndicatorTimer = null; _scanSubscription?.cancel(); - _scanSubscription = _wearableManager.scanStream.listen((incomingDevice) { - if (incomingDevice.name.isNotEmpty && - !discoveredDevices.any((device) => device.id == incomingDevice.id)) { - logger.d('Discovered device: ${incomingDevice.name}'); - setState(() { - discoveredDevices.add(incomingDevice); - }); + _scanSubscription = null; + + if (!updateUi || !mounted) { + _isScanning = false; + if (clearDiscovered) { + _discoveredDevices.clear(); + } + return; + } + + setState(() { + _isScanning = false; + if (clearDiscovered) { + _discoveredDevices.clear(); } }); } + Future _startScanning({bool clearPrevious = false}) async { + _scanIndicatorTimer?.cancel(); + + if (mounted) { + setState(() { + if (clearPrevious) { + _discoveredDevices.clear(); + } + _isScanning = true; + _lastScanStartedAt = DateTime.now(); + }); + } + + await _scanSubscription?.cancel(); + _scanSubscription = _wearableManager.scanStream.listen( + (incomingDevice) { + if (incomingDevice.name.isEmpty) return; + + if (_discoveredDevices + .any((device) => device.id == incomingDevice.id)) { + return; + } + + logger.d('Discovered device: ${incomingDevice.name}'); + if (mounted) { + setState(() { + _discoveredDevices.add(incomingDevice); + }); + } + }, + onError: (error, stackTrace) { + logger.w('Device scan stream error: $error\n$stackTrace'); + _stopScanning(); + }, + ); + + try { + await _wearableManager.startScan(); + } catch (error, stackTrace) { + logger.w('Failed to start scan: $error\n$stackTrace'); + _stopScanning(); + return; + } + + _scanIndicatorTimer = Timer(const Duration(seconds: 8), _stopScanning); + } + Future _connectToDevice( DiscoveredDevice device, BuildContext context, ) async { + if (_connectingDevices[device.id] == true) return; + setState(() { - connectingDevices[device.id] = true; + _connectingDevices[device.id] = true; }); try { - WearableConnector connector = context.read(); + final connector = context.read(); await connector.connect(device); - setState(() { - discoveredDevices.remove(device); - }); + if (mounted) { + setState(() { + _discoveredDevices.removeWhere((d) => d.id == device.id); + }); + } } catch (e) { - String message = _wearableManager.deviceErrorMessage(e, device.name); + final message = _wearableManager.deviceErrorMessage(e, device.name); logger.e('Failed to connect to device: ${device.name}, error: $message'); if (context.mounted) { showPlatformDialog( context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Connection Error'), - content: PlatformText(message), + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Connection Error'), + content: Text(message), actions: [ PlatformDialogAction( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('OK'), + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), ), ], ), ); } } finally { - setState(() { - connectingDevices.remove(device.id); - }); + if (mounted) { + setState(() { + _connectingDevices.remove(device.id); + }); + } } } @override void dispose() { - _scanSubscription?.cancel(); + _stopScanning(updateUi: false); super.dispose(); } } + +class _StatusPill extends StatelessWidget { + final String label; + + const _StatusPill({ + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer.withValues( + alpha: 0.65, + ), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ); + } +} diff --git a/open_wearable/lib/widgets/devices/device_detail/audio_mode_widget.dart b/open_wearable/lib/widgets/devices/device_detail/audio_mode_widget.dart index 9dcd3ec8..25a6f5f2 100644 --- a/open_wearable/lib/widgets/devices/device_detail/audio_mode_widget.dart +++ b/open_wearable/lib/widgets/devices/device_detail/audio_mode_widget.dart @@ -1,14 +1,22 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:provider/provider.dart'; + +enum AudioModeApplyScope { + userSelectable, + individualOnly, + pairOnly, +} class AudioModeWidget extends StatefulWidget { - final AudioModeManager device; + final Wearable device; + final AudioModeApplyScope applyScope; const AudioModeWidget({ super.key, required this.device, + this.applyScope = AudioModeApplyScope.userSelectable, }); @override @@ -17,55 +25,681 @@ class AudioModeWidget extends StatefulWidget { class _AudioModeWidgetState extends State { AudioMode? _selectedAudioMode; + AudioMode? _primaryAudioMode; + AudioMode? _pairedAudioMode; + AudioModeManager? _pairedAudioModeManager; + Wearable? _pairedWearable; + String _primarySideBadge = 'L'; + String _pairedSideBadge = 'R'; + bool _pairModesDiffer = false; + bool _isLoading = true; + bool _isApplying = false; + bool _applyToStereoPair = false; + String? _errorText; + + AudioModeManager get _audioModeManager => + widget.device.requireCapability(); @override void initState() { super.initState(); - _getSelectedAudioMode(); + _loadState(); + } + + @override + void didUpdateWidget(covariant AudioModeWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.device != widget.device) { + _loadState(); + } + } + + Future _loadState() async { + setState(() { + _isLoading = true; + _isApplying = false; + _errorText = null; + }); + + final wearables = context.read().wearables; + + try { + final selectedMode = await _audioModeManager.getAudioMode(); + final pairedWearable = await _findPairedWearable(wearables: wearables); + + AudioModeManager? pairedAudioModeManager; + AudioMode? pairedMode; + if (pairedWearable != null && + pairedWearable.hasCapability()) { + pairedAudioModeManager = + pairedWearable.requireCapability(); + pairedMode = await pairedAudioModeManager.getAudioMode(); + } + final positions = await Future.wait([ + _readStereoPosition(widget.device), + if (pairedWearable != null) _readStereoPosition(pairedWearable), + ]); + final primaryPosition = positions.isNotEmpty ? positions.first : null; + final pairedPosition = positions.length > 1 ? positions[1] : null; + + final primarySideLabel = _sideBadgeForPosition( + primaryPosition, + fallback: 'L', + ); + final pairedSideLabel = _sideBadgeForPosition( + pairedPosition, + fallback: 'R', + ); + + final pairModesDiffer = + pairedMode != null && !_modesEqualByKey(selectedMode, pairedMode); + + if (!mounted) { + return; + } + + setState(() { + _selectedAudioMode = + pairModesDiffer && widget.applyScope == AudioModeApplyScope.pairOnly + ? null + : selectedMode; + _primaryAudioMode = selectedMode; + _pairedAudioMode = pairedMode; + _pairedWearable = pairedWearable; + _pairedAudioModeManager = pairedAudioModeManager; + _primarySideBadge = primarySideLabel; + _pairedSideBadge = pairedSideLabel; + _pairModesDiffer = pairModesDiffer; + _applyToStereoPair = switch (widget.applyScope) { + AudioModeApplyScope.pairOnly => pairedAudioModeManager != null, + AudioModeApplyScope.individualOnly => false, + AudioModeApplyScope.userSelectable => pairedAudioModeManager != null, + }; + _isLoading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _errorText = 'Failed to load listening mode: ${_describeError(error)}'; + _isLoading = false; + }); + } + } + + Future _findPairedWearable({ + required List wearables, + }) async { + if (!widget.device.hasCapability()) { + return null; + } + + final pairedStereo = + await widget.device.requireCapability().pairedDevice; + if (pairedStereo == null) { + return null; + } + + for (final candidate in wearables) { + if (candidate.deviceId == widget.device.deviceId) { + continue; + } + if (!candidate.hasCapability()) { + continue; + } + if (identical( + candidate.requireCapability(), + pairedStereo, + )) { + return candidate; + } + } + + return null; + } + + Future _readStereoPosition(Wearable wearable) async { + if (!wearable.hasCapability()) { + return null; + } + try { + return await wearable.requireCapability().position; + } catch (_) { + return null; + } } - Future _getSelectedAudioMode() async { - final mode = await widget.device.getAudioMode(); + Future _onModeSelected(AudioMode mode) async { + if (_isApplying || _isLoading) { + return; + } + + final previousMode = _selectedAudioMode; + final previousPrimaryMode = _primaryAudioMode; + final previousPairedMode = _pairedAudioMode; + final previousPairModesDiffer = _pairModesDiffer; + final shouldApplyToPair = switch (widget.applyScope) { + AudioModeApplyScope.pairOnly => true, + AudioModeApplyScope.individualOnly => false, + AudioModeApplyScope.userSelectable => _applyToStereoPair, + }; + final pairedManager = shouldApplyToPair ? _pairedAudioModeManager : null; + setState(() { _selectedAudioMode = mode; + _primaryAudioMode = mode; + if (pairedManager != null) { + _pairedAudioMode = mode; + } + _pairModesDiffer = false; + _isApplying = true; + _errorText = null; }); + + bool primaryApplied = false; + try { + await Future.sync(() => _audioModeManager.setAudioMode(mode)); + primaryApplied = true; + + if (pairedManager != null) { + if (!_audioModeManagerSupportsMode(pairedManager, mode)) { + throw StateError( + 'Paired device does not support ${_labelForMode(mode)}.', + ); + } + await Future.sync(() => pairedManager.setAudioMode(mode)); + } + } catch (error) { + if (!mounted) { + return; + } + + setState(() { + if (!primaryApplied || pairedManager == null) { + _selectedAudioMode = previousMode; + _primaryAudioMode = previousPrimaryMode; + _pairedAudioMode = previousPairedMode; + _pairModesDiffer = previousPairModesDiffer; + } else if (widget.applyScope == AudioModeApplyScope.pairOnly) { + _selectedAudioMode = null; + _primaryAudioMode = mode; + _pairedAudioMode = previousPairedMode; + _pairModesDiffer = true; + } + _errorText = _buildApplyError( + error: error, + primaryApplied: primaryApplied, + appliedToPair: pairedManager != null, + ); + }); + } finally { + if (mounted) { + setState(() { + _isApplying = false; + }); + } + } + } + + String _buildApplyError({ + required Object error, + required bool primaryApplied, + required bool appliedToPair, + }) { + final detail = _describeError(error); + if (primaryApplied && appliedToPair) { + return 'Applied to this device, but failed on paired device: $detail'; + } + return 'Failed to apply listening mode: $detail'; + } + + bool _modesEqualByKey(AudioMode? a, AudioMode? b) { + if (a == null || b == null) { + return false; + } + return _normalizedModeKey(a) == _normalizedModeKey(b); + } + + bool _audioModeManagerSupportsMode(AudioModeManager manager, AudioMode mode) { + return manager.availableAudioModes.any( + (candidate) => _modesEqualByKey(candidate, mode), + ); + } + + String _sideBadgeForPosition( + DevicePosition? position, { + required String fallback, + }) { + return switch (position) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => fallback, + }; + } + + String _labelForMode(AudioMode mode) { + final normalized = _normalizedModeKey(mode); + if (normalized.contains('noise') || normalized.contains('anc')) { + return 'Noise Cancellation'; + } + if (normalized.contains('transparen') || + normalized.contains('ambient') || + normalized.contains('passthrough')) { + return 'Transparency'; + } + if (normalized.contains('normal') || + normalized.contains('off') || + normalized.contains('default')) { + return 'Standard'; + } + return _toTitleCase(mode.key); + } + + String _subtitleForMode(AudioMode mode) { + final normalized = _normalizedModeKey(mode); + if (normalized.contains('noise') || normalized.contains('anc')) { + return 'Reduce background sound'; + } + if (normalized.contains('transparen') || + normalized.contains('ambient') || + normalized.contains('passthrough')) { + return 'Let surrounding sound in'; + } + if (normalized.contains('normal') || + normalized.contains('off') || + normalized.contains('default')) { + return 'No noise cancellation or transparency'; + } + return 'Custom listening profile'; + } + + bool _isNoiseCancellationMode(AudioMode mode) { + final normalized = _normalizedModeKey(mode); + return normalized.contains('noise') || normalized.contains('anc'); + } + + IconData _iconForMode(AudioMode mode) { + final normalized = _normalizedModeKey(mode); + if (normalized.contains('noise') || normalized.contains('anc')) { + return Icons.volume_off_rounded; + } + if (normalized.contains('transparen') || + normalized.contains('ambient') || + normalized.contains('passthrough')) { + return Icons.hearing_rounded; } + if (normalized.contains('normal') || + normalized.contains('off') || + normalized.contains('default')) { + return Icons.equalizer_rounded; + } + return Icons.graphic_eq_rounded; + } + + String _normalizedModeKey(AudioMode mode) { + return mode.key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + } + + String _toTitleCase(String value) { + final spaced = value + .replaceAllMapped( + RegExp(r'([a-z])([A-Z])'), + (match) => '${match.group(1)} ${match.group(2)}', + ) + .replaceAll(RegExp(r'[_-]+'), ' ') + .trim(); + + if (spaced.isEmpty) { + return value; + } + + return spaced.split(RegExp(r'\s+')).map((word) { + if (word.isEmpty) { + return word; + } + if (word.length == 1) { + return word.toUpperCase(); + } + return '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}'; + }).join(' '); + } + + String _describeError(Object error) { + final text = error.toString().trim(); + if (text.startsWith('Exception: ')) { + return text.substring('Exception: '.length); + } + if (text.startsWith('StateError: ')) { + return text.substring('StateError: '.length); + } + return text; + } + + Widget _buildModeOptions(List modes) { + return LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final columns = constraints.maxWidth >= 520 ? 3 : 1; + final itemWidth = columns == 1 + ? constraints.maxWidth + : (constraints.maxWidth - (spacing * (columns - 1))) / columns; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: modes.map((mode) { + final selected = _selectedAudioMode != null && + _modesEqualByKey(_selectedAudioMode, mode); + final showPairSideBadges = + widget.applyScope == AudioModeApplyScope.pairOnly && + _pairModesDiffer; + final sideBadges = [ + if (showPairSideBadges && + _primaryAudioMode != null && + _modesEqualByKey(_primaryAudioMode, mode)) + _primarySideBadge, + if (showPairSideBadges && + _pairedAudioMode != null && + _modesEqualByKey(_pairedAudioMode, mode)) + _pairedSideBadge, + ]; + + return SizedBox( + width: itemWidth, + child: _AudioModeOptionButton( + label: _labelForMode(mode), + subtitle: _subtitleForMode(mode), + badgeText: _isNoiseCancellationMode(mode) ? 'BETA' : null, + icon: _iconForMode(mode), + selected: selected, + sideBadges: sideBadges, + enabled: !_isApplying && !_isLoading, + onTap: () => _onModeSelected(mode), + ), + ); + }).toList(), + ); + }, + ); + } @override Widget build(BuildContext context) { - return PlatformWidget( - cupertino:(context, platform) => CupertinoSlidingSegmentedControl( - children: { - for (var item in widget.device.availableAudioModes) - item : PlatformText(item.key), - }, - onValueChanged: (AudioMode? mode) { - if (mode == null) return; - widget.device.setAudioMode(mode); - setState(() { - _selectedAudioMode = mode; - }); - }, - groupValue: _selectedAudioMode, + final modes = _audioModeManager.availableAudioModes.toList(); + if (modes.isEmpty) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + final pairName = _pairedWearable?.name; + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Listening Mode', + style: theme.textTheme.titleSmall, + ), + ), + if (_isLoading) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Choose how much surrounding sound to let in.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + if (_pairedAudioModeManager != null && + widget.applyScope == AudioModeApplyScope.userSelectable) ...[ + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + dense: true, + value: _applyToStereoPair, + onChanged: _isApplying || _isLoading + ? null + : (value) { + setState(() { + _applyToStereoPair = value; + }); + }, + title: const Text('Apply to stereo pair'), + subtitle: Text( + _applyToStereoPair + ? pairName == null + ? 'Left and right devices change together.' + : 'Also update $pairName.' + : 'Only update this device.', + ), + ), + ], + const SizedBox(height: 6), + _buildModeOptions(modes), + if (_isApplying) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(minHeight: 2), + ], + if (_errorText != null) ...[ + const SizedBox(height: 8), + Text( + _errorText!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ], ), - material: (context, platform) => SegmentedButton( - segments: - widget.device.availableAudioModes.map((item) { - return ButtonSegment( - value: item, - label: PlatformText(item.key), - ); - }).toList(), - onSelectionChanged: (Set selected) { - if (selected.isEmpty) return; - widget.device.setAudioMode(selected.first); - setState(() { - _selectedAudioMode = selected.first; - }); - }, - selected: _selectedAudioMode != null ? { _selectedAudioMode! } : {}, - emptySelectionAllowed: true, + ); + } +} + +class _AudioModeOptionButton extends StatelessWidget { + final String label; + final String subtitle; + final String? badgeText; + final IconData icon; + final bool selected; + final List sideBadges; + final bool enabled; + final VoidCallback onTap; + + const _AudioModeOptionButton({ + required this.label, + required this.subtitle, + this.badgeText, + required this.icon, + required this.selected, + this.sideBadges = const [], + required this.enabled, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final foregroundColor = + selected ? colorScheme.primary : colorScheme.onSurfaceVariant; + final iconColor = + enabled ? foregroundColor : foregroundColor.withValues(alpha: 0.55); + final titleColor = enabled + ? (selected ? colorScheme.primary : colorScheme.onSurface) + : colorScheme.onSurface.withValues(alpha: 0.55); + final subtitleColor = enabled + ? colorScheme.onSurfaceVariant + : colorScheme.onSurfaceVariant.withValues(alpha: 0.55); + + final backgroundColor = selected + ? colorScheme.primaryContainer.withValues(alpha: 0.44) + : colorScheme.surface; + final borderColor = selected + ? colorScheme.primary.withValues(alpha: 0.7) + : colorScheme.outlineVariant.withValues(alpha: 0.7); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: enabled ? onTap : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + Icon( + icon, + size: 18, + color: iconColor, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: titleColor, + fontWeight: FontWeight.w700, + ), + ), + ), + if (badgeText != null) ...[ + const SizedBox(width: 6), + _ModePillBadge(label: badgeText!), + ], + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: subtitleColor, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + if (sideBadges.isNotEmpty) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < sideBadges.length; i++) ...[ + if (i > 0) const SizedBox(width: 4), + _ModeSideBadge(label: sideBadges[i]), + ], + ], + ) + else + SizedBox( + width: 18, + height: 18, + child: selected + ? Icon( + Icons.check_circle_rounded, + size: 18, + color: colorScheme.primary, + ) + : null, + ), + ], + ), + ), + ), + ); + } +} + +class _ModePillBadge extends StatelessWidget { + final String label; + + const _ModePillBadge({required this.label}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.secondary.withValues(alpha: 0.6), ), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + ); + } +} + +class _ModeSideBadge extends StatelessWidget { + final String label; + + const _ModeSideBadge({required this.label}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.24), + ), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), ); } } diff --git a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart index 677d36d0..007df08e 100644 --- a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart +++ b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart @@ -1,6 +1,7 @@ - +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; @@ -26,350 +27,1106 @@ class DeviceDetailPage extends StatefulWidget { } class _DeviceDetailPageState extends State { - bool showStatusLED = true; - Microphone? selectedMicrophone; + static const MethodChannel _systemSettingsChannel = MethodChannel( + 'edu.kit.teco.open_wearable/system_settings', + ); + + Future? _deviceIdentifierFuture; + Future? _firmwareVersionFuture; + Future? _firmwareSupportFuture; + Future? _hardwareVersionFuture; @override void initState() { super.initState(); - _initSelectedMicrophone(); + _prepareAsyncData(); } - Future _initSelectedMicrophone() async { - if (widget.device.hasCapability()) { - final mic = await widget.device.requireCapability().getMicrophone(); - setState(() { - selectedMicrophone = mic; - }); + @override + void didUpdateWidget(covariant DeviceDetailPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.device != widget.device) { + _prepareAsyncData(); } } + void _prepareAsyncData() { + _deviceIdentifierFuture = widget.device.hasCapability() + ? widget.device + .requireCapability() + .readDeviceIdentifier() + : null; + + if (widget.device.hasCapability()) { + final firmware = widget.device.requireCapability(); + _firmwareVersionFuture = firmware.readDeviceFirmwareVersion(); + _firmwareSupportFuture = firmware.checkFirmwareSupport(); + } else { + _firmwareVersionFuture = null; + _firmwareSupportFuture = null; + } + + _hardwareVersionFuture = + widget.device.hasCapability() + ? widget.device + .requireCapability() + .readDeviceHardwareVersion() + : null; + } + + bool get _canForgetDevice { + return widget.device.hasCapability() && + widget.device.requireCapability().isConnectedViaSystem; + } + + bool get _opensBluetoothScreenDirectly { + return defaultTargetPlatform == TargetPlatform.android; + } + + Future _openBluetoothSettings() async { + bool opened = false; + try { + opened = await _systemSettingsChannel.invokeMethod( + 'openBluetoothSettings', + ) ?? + false; + } catch (_) { + opened = false; + } + + if (!mounted || opened) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _opensBluetoothScreenDirectly + ? 'Could not open Bluetooth settings.' + : 'Could not open Settings.', + ), + ), + ); + } + + void _showForgetDialog() { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: const Text('Forget device'), + content: Text( + _opensBluetoothScreenDirectly + ? 'To fully forget this device, remove it in your phone Bluetooth settings. ' + 'You can open Bluetooth settings directly from here.' + : 'To fully forget this device, remove it in your phone Bluetooth settings. ' + 'You can open Settings from here.', + ), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + PlatformDialogAction( + cupertino: (_, __) => CupertinoDialogActionData( + isDefaultAction: true, + ), + child: Text( + _opensBluetoothScreenDirectly + ? 'Open Bluetooth Settings' + : 'Open Settings', + ), + onPressed: () { + Navigator.of(context).pop(); + _openBluetoothSettings(); + }, + ), + ], + ), + ); + } + + void _disconnectDevice() { + widget.device.disconnect(); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } + + void _openFirmwareUpdate() { + Provider.of( + context, + listen: false, + ).setSelectedPeripheral(widget.device); + context.push('/fota'); + } + @override Widget build(BuildContext context) { - String? wearableIconPath = widget.device.getWearableIconPath(); + final sections = [ + _buildHeaderCard(context), + if (widget.device.hasCapability()) + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: AudioModeWidget( + device: widget.device, + applyScope: AudioModeApplyScope.individualOnly, + ), + ), + ), + if (widget.device.hasCapability()) + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: MicrophoneSelectionWidget( + device: widget.device.requireCapability(), + ), + ), + ), + _buildInfoCard(context), + if (widget.device.hasCapability() && + widget.device.hasCapability()) + _SectionCard( + title: 'Status LED', + subtitle: 'Customize the status indicator behavior.', + child: StatusLEDControlWidget( + statusLED: widget.device.requireCapability(), + rgbLed: widget.device.requireCapability(), + ), + ) + else if (widget.device.hasCapability()) + _SectionCard( + title: 'RGB LED', + subtitle: 'Set a custom color for the RGB LED.', + child: _ActionSurface( + title: 'LED Color', + subtitle: 'Choose the active color shown on the device.', + trailing: RgbControlView( + rgbLed: widget.device.requireCapability(), + ), + ), + ), + if (widget.device.hasCapability() || + widget.device.hasCapability()) + _buildBatteryCard(context), + ]; return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText("Device details"), + title: const Text('Device details'), ), - body: Padding( - padding: const EdgeInsets.all(10.0), - child: ListView( + body: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // MARK: Device name, icon and battery state - Column( + for (var i = 0; i < sections.length; i++) ...[ + sections[i], + if (i < sections.length - 1) const SizedBox(height: 10), + ], + ], + ), + ), + ); + } + + Widget _buildHeaderCard(BuildContext context) { + final theme = Theme.of(context); + final hasWearableIcon = widget.device.getWearableIconPath() != null; + + final statusPills = [ + if (widget.device.hasCapability()) + StereoPosLabel( + device: widget.device.requireCapability(), + ), + if (widget.device.hasCapability() || + widget.device.hasCapability()) + BatteryStateView(device: widget.device), + if (_firmwareVersionFuture != null) + _FirmwareMetadataBubble( + versionFuture: _firmwareVersionFuture!, + supportFuture: _firmwareSupportFuture, + ), + if (_hardwareVersionFuture != null) + _HardwareMetadataBubble( + versionFuture: _hardwareVersionFuture!, + ), + ]; + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - PlatformText( - widget.device.name, - style: Theme.of(context).textTheme.titleLarge, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - BatteryStateView(device: widget.device), - if (widget.device.hasCapability()) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: StereoPosLabel(device: widget.device.requireCapability()), + if (hasWearableIcon) + SizedBox( + width: 56, + height: 56, + child: _DeviceHeaderWearableIcon(device: widget.device), + ), + if (hasWearableIcon) const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.device.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 170), + child: Text( + widget.device.deviceId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), - ], + if (statusPills.isNotEmpty) ...[ + const SizedBox(height: 8), + _buildHeaderPillLine(statusPills), + ], + ], + ), ), - if (wearableIconPath != null) - SvgPicture.asset(wearableIconPath, width: 100, height: 100), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (widget.device.hasCapability() && widget.device.requireCapability().isConnectedViaSystem) - PlatformElevatedButton( - child: PlatformText("Forget Device"), - onPressed: () { - // Show an alert that the device has to be ignored via the system settings - showPlatformDialog( - context: context, - builder: (_) => PlatformAlertDialog( - title: PlatformText('Forget'), - content: PlatformText('To disconnect this device permanently, please go to your system Bluetooth settings and ignore the device from there.'), - actions: [ - PlatformDialogAction( - cupertino: (_, __) => CupertinoDialogActionData(isDefaultAction: true), - child: PlatformText('OK'), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - }, + ], + ), + const SizedBox(height: 12), + Row( + children: [ + if (_canForgetDevice) ...[ + Expanded( + child: OutlinedButton.icon( + onPressed: _showForgetDialog, + icon: const Icon( + Icons.bluetooth_disabled_rounded, + size: 18, ), - PlatformElevatedButton( - child: PlatformText("Disconnect"), - onPressed: () { - widget.device.disconnect(); - Navigator.of(context).pop(); - }, + label: const Text('Forget'), ), - ], + ), + const SizedBox(width: 8), + ], + Expanded( + child: FilledButton.icon( + onPressed: _disconnectDevice, + icon: const Icon(Icons.link_off_rounded, size: 18), + label: const Text('Disconnect'), + ), ), ], ), - // MARK: Audio Mode - if (widget.device.hasCapability()) - AudioModeWidget(device: widget.device.requireCapability()), - // MARK: Microphone Control - if (widget.device.hasCapability()) - MicrophoneSelectionWidget( - device: widget.device.requireCapability(), - ), - // MARK: Device info - PlatformText("Device Info", style: Theme.of(context).textTheme.titleSmall), - PlatformListTile( - title: PlatformText( - "Bluetooth Address", - style: Theme.of(context).textTheme.bodyLarge, + ], + ), + ), + ); + } + + Widget _buildHeaderPillLine(List pills) { + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Row( + children: [ + for (var i = 0; i < pills.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + pills[i], + ], + ], + ), + ), + ), + ); + } + + Widget _buildInfoCard(BuildContext context) { + final hasIdentifier = _deviceIdentifierFuture != null; + final hasFirmware = _firmwareVersionFuture != null; + final hasHardware = _hardwareVersionFuture != null; + + return _SectionCard( + title: 'Device Information', + subtitle: 'Identifiers and software versions.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DetailInfoRow( + label: 'Bluetooth Address', + value: Text(widget.device.deviceId), + showDivider: hasIdentifier || hasFirmware || hasHardware, + ), + if (hasIdentifier) + _DetailInfoRow( + label: 'Device Identifier', + value: _AsyncValueText( + future: _deviceIdentifierFuture!, ), - subtitle: PlatformText(widget.device.deviceId), + showDivider: hasFirmware || hasHardware, ), - // MARK: Device Identifier - if (widget.device.hasCapability()) - PlatformListTile( - title: PlatformText( - "Device Identifier", - style: Theme.of(context).textTheme.bodyLarge, - ), - subtitle: FutureBuilder( - future: widget.device.requireCapability() - .readDeviceIdentifier(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return PlatformText(snapshot.data.toString()); - } else { - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: 20, - height: 20, - child: PlatformCircularProgressIndicator(), - ), - ); - } - }, - ), - ), - // MARK: Device Firmware Version - if (widget.device.hasCapability()) - PlatformListTile( - title: PlatformText( - "Firmware Version", - style: Theme.of(context).textTheme.bodyLarge, - ), - subtitle: Row(children: [ - FutureBuilder( - future: widget.device.requireCapability() - .readDeviceFirmwareVersion(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return PlatformText(snapshot.data.toString()); - } else { - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: 20, - height: 20, - child: PlatformCircularProgressIndicator(), - ), - ); - } - }, - ), - FutureBuilder( - future: widget.device.requireCapability() - .checkFirmwareSupport(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - switch (snapshot.data) { - case FirmwareSupportStatus.supported: - return SizedBox.shrink(); - case FirmwareSupportStatus.tooNew: - case FirmwareSupportStatus.tooOld: - return Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon(Icons.warning, color: Colors.orange), - ); - case FirmwareSupportStatus.unknown: - return Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon(Icons.help, color: Colors.grey), - ); - default: - return Container(); - } - } else { - return Container(); - } - }, - ), - ],), - trailing: PlatformIconButton( - icon: Icon(Icons.upload), - onPressed: () { - Provider.of( - context, - listen: false, - ).setSelectedPeripheral(widget.device); - // Show the firmware update dialog - // Navigate to your firmware update screen - context.push('/fota'); - }, - ), + if (hasFirmware) + _DetailInfoRow( + label: 'Firmware Version', + value: _buildFirmwareVersionValue(), + trailing: _FirmwareTableUpdateHint( + onTap: _openFirmwareUpdate, ), - // MARK: Device Hardware Version - if (widget.device.hasCapability()) - PlatformListTile( - title: PlatformText( - "Hardware Version", - style: Theme.of(context).textTheme.bodyLarge, - ), - subtitle: FutureBuilder( - future: widget.device.requireCapability() - .readDeviceHardwareVersion(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return PlatformText(snapshot.data.toString()); - } else { - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: 20, - height: 20, - child: PlatformCircularProgressIndicator(), - ), - ); - } - }, - ), + showDivider: hasHardware, + ), + if (hasHardware) + _DetailInfoRow( + label: 'Hardware Version', + value: _AsyncValueText( + future: _hardwareVersionFuture!, ), + showDivider: false, + ), + ], + ), + ); + } - // MARK: Status LED control - if (widget.device.hasCapability() && widget.device.hasCapability()) ...[ - PlatformText( - "Control Status LED", - style: Theme.of(context).textTheme.titleSmall, - ), - StatusLEDControlWidget( - statusLED: widget.device.requireCapability(), - rgbLed: widget.device.requireCapability(), + Widget _buildFirmwareVersionValue() { + return Row( + children: [ + Flexible( + child: _AsyncValueText( + future: _firmwareVersionFuture!, + ), + ), + if (_firmwareSupportFuture != null) ...[ + const SizedBox(width: 6), + _FirmwareSupportIndicator( + supportFuture: _firmwareSupportFuture!, + ), + ], + ], + ); + } + + Widget _buildBatteryCard(BuildContext context) { + final hasEnergy = widget.device.hasCapability(); + final hasHealth = widget.device.hasCapability(); + + return _SectionCard( + title: 'Battery', + subtitle: hasEnergy && hasHealth + ? 'Live energy and health metrics.' + : hasEnergy + ? 'Live electrical measurements.' + : 'Lifecycle and thermal status.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasEnergy) + _buildBatteryEnergyContent( + showTrailingDivider: hasHealth, + ), + if (hasHealth) _buildBatteryHealthContent(), + ], + ), + ); + } + + Widget _buildBatteryEnergyContent({ + bool showTrailingDivider = false, + }) { + return StreamBuilder( + stream: widget.device + .requireCapability() + .energyStatusStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _InlineLoading(); + } + if (snapshot.hasError) { + return const _InlineError( + text: 'Unable to read battery energy status.', + ); + } + final energyStatus = snapshot.data; + if (energyStatus == null) { + return const _InlineHint(text: 'No battery energy data available.'); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DetailInfoRow( + label: 'Battery Voltage', + value: Text('${energyStatus.voltage.toStringAsFixed(1)} V'), + ), + _DetailInfoRow( + label: 'Charge Rate', + value: Text('${energyStatus.chargeRate.toStringAsFixed(3)} W'), + ), + _DetailInfoRow( + label: 'Battery Capacity', + value: Text( + '${energyStatus.availableCapacity.toStringAsFixed(2)} Wh', ), - ] else if (widget.device.hasCapability() && - !widget.device.hasCapability()) ...[ - PlatformText( - "Control RGB LED", - style: Theme.of(context).textTheme.titleSmall, + showDivider: showTrailingDivider, + ), + ], + ); + }, + ); + } + + Widget _buildBatteryHealthContent({ + bool showTrailingDivider = false, + }) { + return StreamBuilder( + stream: widget.device + .requireCapability() + .healthStatusStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _InlineLoading(); + } + if (snapshot.hasError) { + return const _InlineError( + text: 'Unable to read battery health status.', + ); + } + final healthStatus = snapshot.data; + if (healthStatus == null) { + return const _InlineHint(text: 'No battery health data available.'); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DetailInfoRow( + label: 'Battery Temperature', + value: Text('${healthStatus.currentTemperature} °C'), + showDivider: showTrailingDivider, + ), + ], + ); + }, + ); + } +} + +class _DeviceHeaderWearableIcon extends StatefulWidget { + final Wearable device; + + const _DeviceHeaderWearableIcon({required this.device}); + + @override + State<_DeviceHeaderWearableIcon> createState() => + _DeviceHeaderWearableIconState(); +} + +class _DeviceHeaderWearableIconState extends State<_DeviceHeaderWearableIcon> { + static final Expando> _positionFutureCache = + Expando>(); + + Future? _positionFuture; + + @override + void initState() { + super.initState(); + _configurePositionFuture(); + } + + @override + void didUpdateWidget(covariant _DeviceHeaderWearableIcon oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.device, widget.device)) { + _configurePositionFuture(); + } + } + + void _configurePositionFuture() { + if (!widget.device.hasCapability()) { + _positionFuture = null; + return; + } + + final stereoDevice = widget.device.requireCapability(); + _positionFuture = + _positionFutureCache[stereoDevice] ??= stereoDevice.position; + } + + WearableIconVariant _variantForPosition(DevicePosition? position) { + return switch (position) { + DevicePosition.left => WearableIconVariant.left, + DevicePosition.right => WearableIconVariant.right, + _ => WearableIconVariant.single, + }; + } + + String? _resolveIconPath(WearableIconVariant variant) { + final variantPath = widget.device.getWearableIconPath(variant: variant); + if (variantPath != null && variantPath.isNotEmpty) { + return variantPath; + } + + if (variant != WearableIconVariant.single) { + final fallbackPath = widget.device.getWearableIconPath(); + if (fallbackPath != null && fallbackPath.isNotEmpty) { + return fallbackPath; + } + } + + return null; + } + + Widget _buildIcon(WearableIconVariant variant) { + final iconPath = _resolveIconPath(variant); + if (iconPath == null) { + return const SizedBox.shrink(); + } + + if (iconPath.toLowerCase().endsWith('.svg')) { + return SvgPicture.asset(iconPath, fit: BoxFit.contain); + } + + return Image.asset( + iconPath, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Icon(Icons.watch_outlined), + ); + } + + @override + Widget build(BuildContext context) { + if (_positionFuture == null) { + return _buildIcon(WearableIconVariant.single); + } + + return FutureBuilder( + future: _positionFuture, + builder: (context, snapshot) { + return _buildIcon(_variantForPosition(snapshot.data)); + }, + ); + } +} + +class _SectionCard extends StatelessWidget { + final String title; + final String? subtitle; + final Widget child; + + const _SectionCard({ + required this.title, + this.subtitle, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, ), - PlatformListTile( - title: PlatformText( - "LED Color", - style: Theme.of(context).textTheme.bodyLarge, + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), - trailing: RgbControlView(rgbLed: widget.device.requireCapability()), ), ], + const SizedBox(height: 10), + child, + ], + ), + ), + ); + } +} - // MARK: Device Battery State - if (widget.device.hasCapability()) ...[ - PlatformText( - "Battery Energy Status", - style: Theme.of(context).textTheme.titleSmall, - ), - StreamBuilder( - stream: widget.device.requireCapability() - .energyStatusStream, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: 20, - height: 20, - child: PlatformCircularProgressIndicator(), - ), - ); - } else if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } else if (!snapshot.hasData) { - return PlatformText("No data available"); - } else { - final energyStatus = snapshot.data!; - return Column( - children: [ - PlatformListTile( - title: PlatformText("Battery Voltage"), - subtitle: PlatformText( - "${energyStatus.voltage.toStringAsFixed(1)} V", - ), - ), - PlatformListTile( - title: PlatformText("Charge Rate"), - subtitle: PlatformText( - "${energyStatus.chargeRate.toStringAsFixed(3)} W", - ), - ), - PlatformListTile( - title: PlatformText("Battery Capacity"), - subtitle: PlatformText( - "${energyStatus.availableCapacity.toStringAsFixed(2)} Wh", - ), - ), - ], - ); - } - }, - ), - ], +class _ActionSurface extends StatelessWidget { + final String title; + final String subtitle; + final Widget trailing; - // MARK: Battery Health - if (widget.device.hasCapability()) ...[ - PlatformText( - "Battery Health Status", - style: Theme.of(context).textTheme.titleSmall, - ), - StreamBuilder( - stream: widget.device.requireCapability() - .healthStatusStream, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: 20, - height: 20, - child: PlatformCircularProgressIndicator(), + const _ActionSurface({ + required this.title, + required this.subtitle, + required this.trailing, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + trailing, + ], + ), + ); + } +} + +class _DetailInfoRow extends StatelessWidget { + final String label; + final Widget value; + final Widget? trailing; + final bool showDivider; + + const _DetailInfoRow({ + required this.label, + required this.value, + this.trailing, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, ), - ); - } else if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } else if (!snapshot.hasData) { - return PlatformText("No data available"); - } else { - final healthStatus = snapshot.data!; - return Column( - children: [ - PlatformListTile( - title: PlatformText("Battery Temperature"), - subtitle: - PlatformText("${healthStatus.currentTemperature} °C"), - ), - PlatformListTile( - title: PlatformText("Battery Cycle Count"), - subtitle: PlatformText("${healthStatus.cycleCount} cycles"), - ), - ], - ); - } - }, + ), + const SizedBox(height: 2), + DefaultTextStyle( + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ) ?? + const TextStyle(), + child: value, + ), + ], + ), ), + if (trailing != null) ...[ + const SizedBox(width: 10), + trailing!, + ], ], + ), + if (showDivider) ...[ + const SizedBox(height: 8), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), ], + ], + ), + ); + } +} + +class _AsyncValueText extends StatelessWidget { + final Future future; + + const _AsyncValueText({ + required this.future, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ); + } + + final valueText = + snapshot.hasError ? '--' : (snapshot.data?.toString() ?? '--'); + return Text( + valueText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ); + }, + ); + } +} + +class _InlineLoading extends StatelessWidget { + const _InlineLoading(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + ); + } +} + +class _InlineHint extends StatelessWidget { + final String text; + + const _InlineHint({required this.text}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } +} + +class _InlineError extends StatelessWidget { + final String text; + + const _InlineError({required this.text}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ); + } +} + +class _FirmwareTableUpdateHint extends StatelessWidget { + final VoidCallback onTap; + + const _FirmwareTableUpdateHint({required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return FilledButton.icon( + onPressed: onTap, + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + minimumSize: const Size(0, 34), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + backgroundColor: colorScheme.primary, + foregroundColor: Colors.white, + ), + icon: Icon( + Icons.system_update_alt_rounded, + size: 15, + color: Colors.white, + ), + label: Text( + 'Update', + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, ), ), ); } } + +class _FirmwareSupportIndicator extends StatelessWidget { + final Future supportFuture; + + const _FirmwareSupportIndicator({required this.supportFuture}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: supportFuture, + builder: (context, snapshot) { + final support = snapshot.data; + if (support == null || support == FirmwareSupportStatus.supported) { + return const SizedBox.shrink(); + } + + final colorScheme = Theme.of(context).colorScheme; + + IconData icon = Icons.help_rounded; + Color color = colorScheme.onSurfaceVariant; + String tooltip = 'Firmware support status is unknown'; + + switch (support) { + case FirmwareSupportStatus.tooOld: + icon = Icons.warning_rounded; + color = Colors.orange; + tooltip = 'Firmware is too old'; + break; + case FirmwareSupportStatus.tooNew: + icon = Icons.warning_rounded; + color = Colors.orange; + tooltip = 'Firmware is newer than supported'; + break; + case FirmwareSupportStatus.unknown: + icon = Icons.help_rounded; + color = colorScheme.onSurfaceVariant; + tooltip = 'Firmware support is unknown'; + break; + case FirmwareSupportStatus.unsupported: + icon = Icons.error_outline_rounded; + color = colorScheme.error; + tooltip = 'Firmware is unsupported'; + break; + case FirmwareSupportStatus.supported: + return const SizedBox.shrink(); + } + + return Tooltip( + message: tooltip, + child: Icon( + icon, + size: 16, + color: color, + ), + ); + }, + ); + } +} + +class _FirmwareMetadataBubble extends StatelessWidget { + final Future versionFuture; + final Future? supportFuture; + + const _FirmwareMetadataBubble({ + required this.versionFuture, + required this.supportFuture, + }); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: versionFuture, + builder: (context, versionSnapshot) { + if (versionSnapshot.connectionState == ConnectionState.waiting) { + return const _MetadataBubble(label: 'FW', isLoading: true); + } + + final versionText = versionSnapshot.hasError + ? '--' + : (versionSnapshot.data?.toString() ?? '--'); + + if (supportFuture == null) { + return _MetadataBubble( + label: 'FW', + value: versionText, + ); + } + + return FutureBuilder( + future: supportFuture, + builder: (context, supportSnapshot) { + IconData? statusIcon; + Color? statusColor; + + switch (supportSnapshot.data) { + case FirmwareSupportStatus.tooOld: + case FirmwareSupportStatus.tooNew: + statusIcon = Icons.warning_rounded; + statusColor = Colors.orange; + break; + case FirmwareSupportStatus.unknown: + statusIcon = Icons.help_rounded; + statusColor = Theme.of(context).colorScheme.onSurfaceVariant; + break; + default: + break; + } + + return _MetadataBubble( + label: 'FW', + value: versionText, + trailingIcon: statusIcon, + foregroundColor: statusColor, + ); + }, + ); + }, + ); + } +} + +class _HardwareMetadataBubble extends StatelessWidget { + final Future versionFuture; + + const _HardwareMetadataBubble({required this.versionFuture}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: versionFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _MetadataBubble(label: 'HW', isLoading: true); + } + + final versionText = + snapshot.hasError ? '--' : (snapshot.data?.toString() ?? '--'); + + return _MetadataBubble( + label: 'HW', + value: versionText, + ); + }, + ); + } +} + +class _MetadataBubble extends StatelessWidget { + final String label; + final String? value; + final bool isLoading; + final IconData? trailingIcon; + final Color? foregroundColor; + + const _MetadataBubble({ + required this.label, + this.value, + this.isLoading = false, + this.trailingIcon, + this.foregroundColor, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final defaultForeground = colorScheme.primary; + final resolvedForeground = foregroundColor ?? defaultForeground; + final backgroundColor = colorScheme.surface; + final borderColor = resolvedForeground.withValues(alpha: 0.42); + final displayText = + isLoading ? '$label ...' : (value == null ? label : '$label $value'); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isLoading && trailingIcon != null) + Icon( + trailingIcon, + size: 14, + color: resolvedForeground, + ), + if (!isLoading && trailingIcon != null) const SizedBox(width: 6), + Text( + displayText, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: resolvedForeground, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ], + ), + ); + } +} diff --git a/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart b/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart index df93921b..9dd3c2b9 100644 --- a/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart +++ b/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart @@ -1,6 +1,4 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; class MicrophoneSelectionWidget extends StatefulWidget { @@ -12,60 +10,363 @@ class MicrophoneSelectionWidget extends StatefulWidget { }); @override - State createState() => _MicrophoneSelectionWidgetState(); + State createState() => + _MicrophoneSelectionWidgetState(); } class _MicrophoneSelectionWidgetState extends State { Microphone? _selectedMicrophone; + bool _isLoading = true; + bool _isApplying = false; + String? _errorText; @override void initState() { super.initState(); - _getSelectedMicrophone(); + _loadSelectedMicrophone(); } - Future _getSelectedMicrophone() async { - final mode = await widget.device.getMicrophone(); + @override + void didUpdateWidget(covariant MicrophoneSelectionWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.device != widget.device) { + _loadSelectedMicrophone(); + } + } + + Future _loadSelectedMicrophone() async { + setState(() { + _isLoading = true; + _isApplying = false; + _errorText = null; + }); + + try { + final microphone = await widget.device.getMicrophone(); + if (!mounted) { + return; + } + setState(() { + _selectedMicrophone = microphone; + _isLoading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _errorText = + 'Failed to read LE Audio stream source: ${_describeError(error)}'; + _isLoading = false; + }); + } + } + + Future _onMicrophoneSelected(Microphone microphone) async { + if (_isApplying || _isLoading) { + return; + } + + final previousMicrophone = _selectedMicrophone; setState(() { - _selectedMicrophone = mode; + _selectedMicrophone = microphone; + _isApplying = true; + _errorText = null; }); + + try { + await Future.sync(() => widget.device.setMicrophone(microphone)); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _selectedMicrophone = previousMicrophone; + _errorText = + 'Failed to apply LE Audio stream source: ${_describeError(error)}'; + }); + } finally { + if (mounted) { + setState(() { + _isApplying = false; + }); + } + } + } + + String _normalizedMicrophoneKey(Microphone microphone) { + return microphone.key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + } + + bool _microphonesEqualByKey(Microphone? a, Microphone b) { + if (a == null) { + return false; + } + return _normalizedMicrophoneKey(a) == _normalizedMicrophoneKey(b); + } + + String _labelForMicrophone(Microphone microphone) { + final normalized = _normalizedMicrophoneKey(microphone); + if (normalized.contains('inner') || normalized.contains('internal')) { + return 'Inner (In-Ear Sounds)'; + } + if (normalized.contains('outer') || normalized.contains('external')) { + return 'Outer (Ambient Sounds)'; + } + return _toTitleCase(microphone.key); + } + + String _subtitleForMicrophone(Microphone microphone) { + final normalized = _normalizedMicrophoneKey(microphone); + if (normalized.contains('inner') || normalized.contains('internal')) { + return 'Stream the inner mic over LE Audio'; + } + if (normalized.contains('outer') || normalized.contains('external')) { + return 'Stream the outer mic over LE Audio'; + } + return 'Microphone source for LE Audio stream'; + } + + IconData _iconForMicrophone(Microphone microphone) { + final normalized = _normalizedMicrophoneKey(microphone); + if (normalized.contains('inner') || normalized.contains('internal')) { + return Icons.hearing_rounded; + } + if (normalized.contains('outer') || normalized.contains('external')) { + return Icons.surround_sound_rounded; + } + return Icons.mic_rounded; + } + + String _toTitleCase(String value) { + final spaced = value + .replaceAllMapped( + RegExp(r'([a-z])([A-Z])'), + (match) => '${match.group(1)} ${match.group(2)}', + ) + .replaceAll(RegExp(r'[_-]+'), ' ') + .trim(); + + if (spaced.isEmpty) { + return value; + } + + return spaced.split(RegExp(r'\s+')).map((word) { + if (word.isEmpty) { + return word; + } + if (word.length == 1) { + return word.toUpperCase(); + } + return '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}'; + }).join(' '); + } + + String _describeError(Object error) { + final text = error.toString().trim(); + if (text.startsWith('Exception: ')) { + return text.substring('Exception: '.length); } + return text; + } + + Widget _buildMicrophoneOptions(List microphones) { + return LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final columns = constraints.maxWidth >= 420 ? 2 : 1; + final itemWidth = columns == 1 + ? constraints.maxWidth + : (constraints.maxWidth - spacing) / 2; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: microphones.map((microphone) { + final selected = _microphonesEqualByKey( + _selectedMicrophone, + microphone, + ); + return SizedBox( + width: itemWidth, + child: _MicrophoneOptionButton( + label: _labelForMicrophone(microphone), + subtitle: _subtitleForMicrophone(microphone), + icon: _iconForMicrophone(microphone), + selected: selected, + enabled: !_isApplying && !_isLoading, + onTap: () => _onMicrophoneSelected(microphone), + ), + ); + }).toList(), + ); + }, + ); + } @override Widget build(BuildContext context) { - return PlatformWidget( - cupertino:(context, platform) => CupertinoSlidingSegmentedControl( - children: { - for (var item in widget.device.availableMicrophones) - item : PlatformText(item.key), - }, - onValueChanged: (Microphone? mode) { - if (mode == null) return; - widget.device.setMicrophone(mode); - setState(() { - _selectedMicrophone = mode; - }); - }, - groupValue: _selectedMicrophone, + final microphones = widget.device.availableMicrophones.toList(); + if (microphones.isEmpty) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'LE Audio Microphone Stream', + style: theme.textTheme.titleSmall, + ), + ), + if (_isLoading) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Choose which microphone is streamed via LE Audio.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + _buildMicrophoneOptions(microphones), + if (_isApplying) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(minHeight: 2), + ], + if (_errorText != null) ...[ + const SizedBox(height: 8), + Text( + _errorText!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ], ), - material: (context, platform) => SegmentedButton( - segments: - widget.device.availableMicrophones.map((item) { - return ButtonSegment( - value: item, - label: PlatformText(item.key), - ); - }).toList(), - onSelectionChanged: (Set selected) { - if (selected.isEmpty) return; - widget.device.setMicrophone(selected.first); - setState(() { - _selectedMicrophone = selected.first; - }); - }, - selected: _selectedMicrophone != null ? { _selectedMicrophone! } : {}, - emptySelectionAllowed: true, + ); + } +} + +class _MicrophoneOptionButton extends StatelessWidget { + final String label; + final String subtitle; + final IconData icon; + final bool selected; + final bool enabled; + final VoidCallback onTap; + + const _MicrophoneOptionButton({ + required this.label, + required this.subtitle, + required this.icon, + required this.selected, + required this.enabled, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final foregroundColor = + selected ? colorScheme.primary : colorScheme.onSurfaceVariant; + final iconColor = + enabled ? foregroundColor : foregroundColor.withValues(alpha: 0.55); + final titleColor = enabled + ? (selected ? colorScheme.primary : colorScheme.onSurface) + : colorScheme.onSurface.withValues(alpha: 0.55); + final subtitleColor = enabled + ? colorScheme.onSurfaceVariant + : colorScheme.onSurfaceVariant.withValues(alpha: 0.55); + + final backgroundColor = selected + ? colorScheme.primaryContainer.withValues(alpha: 0.44) + : colorScheme.surface; + final borderColor = selected + ? colorScheme.primary.withValues(alpha: 0.7) + : colorScheme.outlineVariant.withValues(alpha: 0.7); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: enabled ? onTap : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + Icon( + icon, + size: 18, + color: iconColor, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: titleColor, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: subtitleColor, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 18, + height: 18, + child: selected + ? Icon( + Icons.check_circle_rounded, + size: 18, + color: colorScheme.primary, + ) + : null, + ), + ], + ), ), + ), ); } } diff --git a/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart b/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart index 42ebd6ee..29f194bd 100644 --- a/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart +++ b/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart @@ -38,9 +38,9 @@ class _RgbControlViewState extends State { child: PlatformText('Done'), onPressed: () { widget.rgbLed.writeLedColor( - r: (255 *_currentColor.r).round(), - g: (255 *_currentColor.g).round(), - b: (255 *_currentColor.b).round(), + r: (255 * _currentColor.r).round(), + g: (255 * _currentColor.g).round(), + b: (255 * _currentColor.b).round(), ); Navigator.of(context).pop(); }, @@ -53,15 +53,37 @@ class _RgbControlViewState extends State { @override Widget build(BuildContext context) { - return ElevatedButton( + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return OutlinedButton.icon( onPressed: _showColorPickerDialog, - style: ElevatedButton.styleFrom( - backgroundColor: _currentColor, - foregroundColor: _currentColor.computeLuminance() > 0.5 - ? Colors.black - : Colors.white, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + minimumSize: const Size(0, 34), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.65), + ), + foregroundColor: colorScheme.onSurface, + ), + icon: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: _currentColor, + shape: BoxShape.circle, + border: Border.all( + color: Colors.black.withValues(alpha: 0.18), + ), + ), + ), + label: PlatformText( + 'Color', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), - child: PlatformText('Color'), ); } } diff --git a/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart b/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart index a4502060..2092a6de 100644 --- a/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart +++ b/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'rgb_control.dart'; @@ -7,7 +6,11 @@ import 'rgb_control.dart'; class StatusLEDControlWidget extends StatefulWidget { final StatusLed statusLED; final RgbLed rgbLed; - const StatusLEDControlWidget({super.key, required this.statusLED, required this.rgbLed}); + const StatusLEDControlWidget({ + super.key, + required this.statusLED, + required this.rgbLed, + }); @override State createState() => _StatusLEDControlWidgetState(); @@ -15,27 +18,171 @@ class StatusLEDControlWidget extends StatefulWidget { class _StatusLEDControlWidgetState extends State { bool _overrideColor = false; + bool _disableLed = false; + + Future _setLedBlack() async { + try { + await widget.statusLED.showStatus(false); + await widget.rgbLed.writeLedColor(r: 0, g: 0, b: 0); + } catch (_) { + // LED control is best-effort and should not interrupt UI interactions. + } + } + + Future _resetLedOverride() async { + try { + await widget.statusLED.showStatus(true); + } catch (_) { + // LED control is best-effort and should not interrupt UI interactions. + } + } + + Future _onDisableLedChanged(bool value) async { + setState(() { + _disableLed = value; + if (value) { + _overrideColor = false; + } + }); + + if (value) { + await _setLedBlack(); + return; + } + await _resetLedOverride(); + } + + Future _onOverrideChanged(bool value) async { + setState(() { + _overrideColor = value; + if (value) { + _disableLed = false; + } + }); + + if (value) { + try { + await widget.statusLED.showStatus(false); + } catch (_) { + // LED control is best-effort and should not interrupt UI interactions. + } + return; + } + + await _resetLedOverride(); + } @override Widget build(BuildContext context) { - return PlatformListTile( - title: PlatformText("Override LED Color", style: Theme.of(context).textTheme.bodyLarge), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_overrideColor) - RgbControlView(rgbLed: widget.rgbLed), - PlatformSwitch( - value: _overrideColor, - onChanged: (value) async { - setState(() { - _overrideColor = value; - }); - widget.statusLED.showStatus(!value); - }, - ), - ], - ), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Disable LED', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Turn off LED output.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Switch.adaptive( + value: _disableLed, + onChanged: _onDisableLedChanged, + ), + ], + ), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Override status LED color', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Use a fixed color instead of the default status.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Switch.adaptive( + value: _overrideColor, + onChanged: _onOverrideChanged, + ), + ], + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 170), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _overrideColor + ? Padding( + padding: const EdgeInsets.only(top: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.35, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: 0.55, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + 'LED Color', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + RgbControlView(rgbLed: widget.rgbLed), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + ], ); } } diff --git a/open_wearable/lib/widgets/devices/device_detail/stereo_pos_label.dart b/open_wearable/lib/widgets/devices/device_detail/stereo_pos_label.dart index e48f950b..3dda1fc8 100644 --- a/open_wearable/lib/widgets/devices/device_detail/stereo_pos_label.dart +++ b/open_wearable/lib/widgets/devices/device_detail/stereo_pos_label.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; - -import '../../../models/logger.dart'; +import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; class StereoPosLabel extends StatelessWidget { final StereoDevice device; @@ -11,32 +9,6 @@ class StereoPosLabel extends StatelessWidget { @override Widget build(BuildContext context) { - return FutureBuilder( - future: device.position, - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return PlatformCircularProgressIndicator(); - } - if (snapshot.hasError) { - logger.e("Error fetching device position: ${snapshot.error}"); - return PlatformText("Error: ${snapshot.error}"); - } - if (!snapshot.hasData) { - return PlatformText("N/A"); - } - if (snapshot.data == null) { - return PlatformText("N/A"); - } - switch (snapshot.data) { - case DevicePosition.left: - return PlatformText("Left"); - case DevicePosition.right: - return PlatformText("Right"); - default: - return PlatformText("Unknown"); - } - }, - ); + return StereoPositionBadge(device: device); } } diff --git a/open_wearable/lib/widgets/devices/devices_page.dart b/open_wearable/lib/widgets/devices/devices_page.dart index b3b33cd1..f1fe28ce 100644 --- a/open_wearable/lib/widgets/devices/devices_page.dart +++ b/open_wearable/lib/widgets/devices/devices_page.dart @@ -4,14 +4,17 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/devices/battery_state.dart'; import 'package:open_wearable/widgets/devices/connect_devices_page.dart'; +import 'package:open_wearable/widgets/devices/device_detail/audio_mode_widget.dart'; import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart'; +import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; +import 'package:open_wearable/widgets/recording_activity_indicator.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:provider/provider.dart'; -import 'device_detail/stereo_pos_label.dart'; - /// On this page the user can see all connected devices. /// /// Tapping on a device will navigate to the [DeviceDetailPage]. @@ -40,17 +43,15 @@ class _DevicesPageState extends State { ); } - Widget _buildSmallScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { + Widget _buildSmallScreenLayout( + BuildContext context, + WearablesProvider wearablesProvider, + ) { return PlatformScaffold( appBar: PlatformAppBar( title: PlatformText("Devices"), trailingActions: [ - PlatformIconButton( - icon: Icon(context.platformIcons.info), - onPressed: () { - context.push('/log-files'); - }, - ), + const AppBarRecordingIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { @@ -63,7 +64,10 @@ class _DevicesPageState extends State { ); } - Widget _buildSmallScreenContent(BuildContext context, WearablesProvider wearablesProvider) { + Widget _buildSmallScreenContent( + BuildContext context, + WearablesProvider wearablesProvider, + ) { if (wearablesProvider.wearables.isEmpty) { return RefreshIndicator( onRefresh: () async { @@ -71,13 +75,14 @@ class _DevicesPageState extends State { //TODO: implement refresh logic }, child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: SensorPageSpacing.pagePadding, children: [ SizedBox( - height: MediaQuery.of(context).size.height * 0.8, + height: MediaQuery.of(context).size.height * 0.62, child: Center( - child: PlatformText( - "No devices connected", - style: Theme.of(context).textTheme.titleLarge, + child: _NoDevicesPromptView( + onScanPressed: () => context.push('/connect-devices'), ), ), ), @@ -86,23 +91,52 @@ class _DevicesPageState extends State { ); } - return RefreshIndicator( - onRefresh: () { - return WearableManager().connectToSystemDevices().then((wearables) { - for (var wearable in wearables) { - wearablesProvider.addWearable(wearable); - } - }); - }, - child: Padding( - padding: EdgeInsets.all(10), - child: ListView.builder( - itemCount: wearablesProvider.wearables.length, - itemBuilder: (context, index) { - return DeviceRow(device: wearablesProvider.wearables[index]); - }, + return FutureBuilder>( + future: buildWearableDisplayGroups( + wearablesProvider.wearables, + shouldCombinePair: (left, right) => + wearablesProvider.isStereoPairCombined( + first: left, + second: right, ), ), + builder: (context, snapshot) { + final groups = _orderGroupsForOverview( + snapshot.data ?? + wearablesProvider.wearables + .map( + (wearable) => + WearableDisplayGroup.single(wearable: wearable), + ) + .toList(), + ); + + return RefreshIndicator( + onRefresh: () { + return WearableManager().connectToSystemDevices().then((wearables) { + for (var wearable in wearables) { + wearablesProvider.addWearable(wearable); + } + }); + }, + child: Padding( + padding: const EdgeInsets.all(10), + child: ListView.builder( + itemCount: groups.length, + itemBuilder: (context, index) { + return DeviceRow( + group: groups[index], + onPairCombineChanged: (pairKey, combined) => + wearablesProvider.setStereoPairKeyCombined( + pairKey: pairKey, + combined: combined, + ), + ); + }, + ), + ), + ); + }, ); } @@ -110,216 +144,869 @@ class _DevicesPageState extends State { BuildContext context, WearablesProvider wearablesProvider, ) { - return GridView.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 500, - childAspectRatio: 3, - crossAxisSpacing: 10, - mainAxisSpacing: 10, + return FutureBuilder>( + future: buildWearableDisplayGroups( + wearablesProvider.wearables, + shouldCombinePair: (left, right) => + wearablesProvider.isStereoPairCombined( + first: left, + second: right, + ), ), - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemCount: wearablesProvider.wearables.length + 1, - itemBuilder: (context, index) { - if (index == wearablesProvider.wearables.length) { - return GestureDetector( - onTap: () { - showPlatformModalSheet( - context: context, - builder: (context) => ConnectDevicesPage(), - ); - }, - child: Card( - color: Theme.of(context) - .colorScheme - .surfaceTint - .withValues(alpha: 0.2), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - PlatformIcons(context).add, - color: Theme.of(context).colorScheme.surfaceTint, - ), - PlatformText( - "Connect Device", - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.surfaceTint, - ), - ), - ], + builder: (context, snapshot) { + final groups = _orderGroupsForOverview( + snapshot.data ?? + wearablesProvider.wearables + .map( + (wearable) => + WearableDisplayGroup.single(wearable: wearable), + ) + .toList(), + ); + + if (groups.isEmpty) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Padding( + padding: const EdgeInsets.all(20), + child: _NoDevicesPromptView( + onScanPressed: () => context.push('/connect-devices'), ), ), ), ); } - return DeviceRow(device: wearablesProvider.wearables[index]); + + return GridView.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 500, + childAspectRatio: 3, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: groups.length + 1, + itemBuilder: (context, index) { + if (index == groups.length) { + return GestureDetector( + onTap: () { + showPlatformModalSheet( + context: context, + builder: (context) => const ConnectDevicesPage(), + ); + }, + child: Card( + color: Theme.of(context) + .colorScheme + .surfaceTint + .withValues(alpha: 0.2), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + PlatformIcons(context).add, + color: Theme.of(context).colorScheme.surfaceTint, + ), + PlatformText( + "Connect Device", + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + color: + Theme.of(context).colorScheme.surfaceTint, + ), + ), + ], + ), + ), + ), + ); + } + return DeviceRow( + group: groups[index], + onPairCombineChanged: (pairKey, combined) => + wearablesProvider.setStereoPairKeyCombined( + pairKey: pairKey, + combined: combined, + ), + ); + }, + ); }, ); } + + List _orderGroupsForOverview( + List groups, + ) { + final indexed = groups.asMap().entries.toList(); + + int rank(WearableDisplayGroup group) { + if (group.isCombined) { + return 0; + } + if (group.primaryPosition == DevicePosition.left) { + return 1; + } + if (group.primaryPosition == DevicePosition.right) { + return 2; + } + return 3; + } + + indexed.sort((a, b) { + final rankA = rank(a.value); + final rankB = rank(b.value); + if (rankA != rankB) { + return rankA.compareTo(rankB); + } + + if (rankA <= 2) { + final byName = a.value.displayName + .toLowerCase() + .compareTo(b.value.displayName.toLowerCase()); + if (byName != 0) { + return byName; + } + } + + return a.key.compareTo(b.key); + }); + + return indexed.map((entry) => entry.value).toList(); + } } +class _NoDevicesPromptView extends StatelessWidget { + final VoidCallback onScanPressed; + + const _NoDevicesPromptView({required this.onScanPressed}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.45), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + Icons.bluetooth_searching_rounded, + size: 28, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 14), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + 'No devices connected', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + 'Scan for devices to start streaming and recording data.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: onScanPressed, + icon: const Icon(Icons.search_rounded, size: 18), + label: const Text('Scan for devices'), + ), + ], + ), + ); + } +} // MARK: DeviceRow /// This widget represents a single device in the list/grid. /// Tapping on it will navigate to the [DeviceDetailPage]. class DeviceRow extends StatelessWidget { - final Wearable _device; + final WearableDisplayGroup group; + final void Function(String pairKey, bool combined)? onPairCombineChanged; + final void Function(Wearable device)? onSingleDeviceSelected; + final bool showWearableIcon; - const DeviceRow({super.key, required Wearable device}) : _device = device; + const DeviceRow({ + super.key, + required this.group, + this.onPairCombineChanged, + this.onSingleDeviceSelected, + this.showWearableIcon = true, + }); @override Widget build(BuildContext context) { - String? wearableIconPath = _device.getWearableIconPath(); + final primary = group.representative; + final secondary = group.secondary; + final pairKey = group.stereoPairKey; + final knownIconVariant = _resolveWearableIconVariant(); + final hasWearableIcon = showWearableIcon && + (primary.getWearableIconPath(variant: knownIconVariant)?.isNotEmpty ?? + false); + final topRightIdentifierLabel = _buildTopRightIdentifierLabel(); + final statusPills = _buildDeviceStatusPills( + primary, + includeSideLabel: false, + showStereoPosition: !group.isCombined, + ); return GestureDetector( - onTap: () { - bool isLargeScreen = MediaQuery.of(context).size.width > 600; - if (isLargeScreen) { - showGeneralDialog( - context: context, - pageBuilder: (context, animation1, animation2) { - return Center( - child: SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - height: MediaQuery.of(context).size.height * 0.5, - child: DeviceDetailPage(device: _device), - ), - ); - }, - ); - return; - } - context.push('/device-detail', extra: _device); - }, + onTap: () => _openDetails(context), child: Card( child: Padding( - padding: const EdgeInsets.all(10.0), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (wearableIconPath != null) - SvgPicture.asset( - wearableIconPath, - width: 50, - height: 50, - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PlatformText( - _device.name, - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(fontWeight: FontWeight.bold), + if (hasWearableIcon) ...[ + Padding( + padding: const EdgeInsets.only(top: 2), + child: SizedBox( + width: 56, + height: 56, + child: _WearableIconView( + device: primary, + initialVariant: knownIconVariant, + ), ), - Row(children: [ - BatteryStateView(device: _device), - if (_device.hasCapability()) + ), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + flex: group.isCombined ? 6 : 7, + child: Text( + group.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (topRightIdentifierLabel != null) ...[ + const SizedBox(width: 8), + Expanded( + flex: group.isCombined ? 5 : 4, + child: _buildIdentifierLabel( + context, + topRightIdentifierLabel, + ), + ), + ], + ], + ), + if (group.isCombined) ...[ + const SizedBox(height: 8), + ..._buildCombinedStatusLines(), + ] else if (statusPills.isNotEmpty) ...[ + const SizedBox(height: 8), + _buildStatusPillLine(statusPills), + ], + if (secondary != null) Padding( - padding: EdgeInsets.only(left: 8.0), - child: StereoPosLabel(device: _device.requireCapability()), + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Tap to choose left or right device controls', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), ), ], ), - ], - ), - Spacer(), - if (_device.hasCapability()) - FutureBuilder( - future: - _device.requireCapability().readDeviceIdentifier(), - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return PlatformCircularProgressIndicator(); - } - if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } - return PlatformText(snapshot.data.toString()); - }, - ) - else - PlatformText(_device.deviceId), + ), ], ), - if (_device.hasCapability()) - Row( - children: [ - PlatformText("Firmware Version: "), - FutureBuilder( - future: _device.requireCapability() - .readDeviceFirmwareVersion(), - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return PlatformCircularProgressIndicator(); - } - if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } - return PlatformText(snapshot.data.toString()); - }, - ), - FutureBuilder( - future: _device.requireCapability() - .checkFirmwareSupport(), - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return PlatformCircularProgressIndicator(); - } - if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } - switch (snapshot.data) { - case FirmwareSupportStatus.supported: - return SizedBox.shrink(); - case FirmwareSupportStatus.tooOld: - case FirmwareSupportStatus.tooNew: - return Icon( - Icons.warning, - color: Colors.orange, - size: 16, - ); - case FirmwareSupportStatus.unknown: - default: - return Icon( - Icons.help, - color: Colors.grey, - size: 16, - ); - } - }, - ), - ], + if (pairKey != null) ...[ + const SizedBox(height: 8), + Divider( + height: 1, + thickness: 0.6, + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + const SizedBox(height: 6), + _buildPairToggleButton( + context, + pairKey: pairKey, + combined: group.isCombined, + ), + ], + ], + ), + ), + ), + ); + } + + WearableIconVariant _resolveWearableIconVariant() { + if (group.isCombined) { + return WearableIconVariant.pair; + } + + switch (group.primaryPosition) { + case DevicePosition.left: + return WearableIconVariant.left; + case DevicePosition.right: + return WearableIconVariant.right; + case null: + return WearableIconVariant.single; + } + } + + String? _buildTopRightIdentifierLabel() { + if (!group.isCombined) { + final label = group.identifiersLabel.trim(); + return label.isEmpty ? null : label; + } + + final leftId = group.leftDevice?.deviceId; + final rightId = group.rightDevice?.deviceId; + if (leftId == null || + leftId.isEmpty || + rightId == null || + rightId.isEmpty) { + return null; + } + + return '${_compactIdentifier(leftId)} / ${_compactIdentifier(rightId)}'; + } + + Widget _buildIdentifierLabel(BuildContext context, String label) { + final style = Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ); + + if (!group.isCombined) { + return Align( + alignment: Alignment.centerRight, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: style, + ), + ); + } + + final parts = label.split(' / '); + if (parts.length != 2) { + return Align( + alignment: Alignment.centerRight, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: style, + ), + ); + } + + return Row( + children: [ + Expanded( + child: Text( + parts[0], + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + textAlign: TextAlign.right, + style: style, + ), + ), + Text(' / ', style: style), + Expanded( + child: Text( + parts[1], + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + textAlign: TextAlign.left, + style: style, + ), + ), + ], + ); + } + + String _compactIdentifier(String id) { + final normalized = id.trim(); + const maxChars = 14; + if (normalized.length <= maxChars) { + return normalized; + } + + const ellipsis = '...'; + final keep = maxChars - ellipsis.length; + final prefixLength = (keep / 2).ceil(); + final suffixLength = keep - prefixLength; + + return '${normalized.substring(0, prefixLength)}' + '$ellipsis' + '${normalized.substring(normalized.length - suffixLength)}'; + } + + Widget _buildPairToggleButton( + BuildContext context, { + required String pairKey, + required bool combined, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final enabled = onPairCombineChanged != null; + return SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Row( + children: [ + Icon( + combined ? Icons.merge_type : Icons.call_split, + size: 16, + color: enabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Combine stereo pair', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, ), - if (_device.hasCapability()) - Row( - children: [ - PlatformText("Hardware Version: "), - FutureBuilder( - future: _device.requireCapability() - .readDeviceHardwareVersion(), - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return PlatformCircularProgressIndicator(); - } - if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } - return PlatformText(snapshot.data.toString()); - }, + ), + ), + Switch.adaptive( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: combined, + onChanged: enabled + ? (value) => onPairCombineChanged!(pairKey, value) + : null, + ), + ], + ), + ), + ); + } + + List _buildCombinedStatusLines() { + final lines = []; + final left = group.leftDevice; + final right = group.rightDevice; + + if (left != null) { + lines.add( + _buildStatusPillLine( + _buildDeviceStatusPills( + left, + includeSideLabel: true, + sideLabel: 'L', + ), + ), + ); + } + + if (right != null) { + if (lines.isNotEmpty) { + lines.add(const SizedBox(height: 6)); + } + lines.add( + _buildStatusPillLine( + _buildDeviceStatusPills( + right, + includeSideLabel: true, + sideLabel: 'R', + ), + ), + ); + } + + if (lines.isNotEmpty) { + return lines; + } + + return [ + _buildStatusPillLine( + const [_MetadataBubble(label: 'L+R', highlighted: true)], + ), + ]; + } + + List _buildDeviceStatusPills( + Wearable device, { + required bool includeSideLabel, + String? sideLabel, + bool showStereoPosition = false, + }) { + final hasBatteryStatus = device.hasCapability() || + device.hasCapability(); + final hasFirmwareInfo = device.hasCapability(); + final hasHardwareInfo = device.hasCapability(); + final hasStereoPositionPill = + showStereoPosition && device.hasCapability(); + + return [ + if (includeSideLabel && sideLabel != null) + _MetadataBubble(label: sideLabel, highlighted: true), + if (hasStereoPositionPill) + StereoPositionBadge(device: device.requireCapability()), + if (hasBatteryStatus) BatteryStateView(device: device), + if (hasFirmwareInfo) + _FirmwareVersionBubble( + firmwareVersion: device.requireCapability(), + ), + if (hasHardwareInfo) + _HardwareVersionBubble( + hardwareVersion: device.requireCapability(), + ), + ]; + } + + Widget _buildStatusPillLine(List pills) { + if (pills.isEmpty) { + return const SizedBox.shrink(); + } + + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Row( + children: [ + for (var i = 0; i < pills.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + pills[i], + ], + ], + ), + ), + ), + ); + } + + Future _openDetails(BuildContext context) async { + final devices = group.members; + if (devices.length == 1) { + final device = devices.first; + if (onSingleDeviceSelected != null) { + onSingleDeviceSelected!(device); + } else { + _openDeviceDetail(context, device); + } + return; + } + + final leftDevice = group.leftDevice ?? devices.first; + final rightDevice = group.rightDevice ?? devices.last; + + await showPlatformModalSheet( + context: context, + builder: (sheetContext) => _PairedDeviceSheet( + title: group.displayName, + leftDevice: leftDevice, + rightDevice: rightDevice, + onOpenDeviceDetail: (device) { + Navigator.of(sheetContext).pop(); + if (onSingleDeviceSelected != null) { + onSingleDeviceSelected!(device); + } else { + _openDeviceDetail(context, device); + } + }, + ), + ); + } + + void _openDeviceDetail(BuildContext context, Wearable device) { + final isLargeScreen = MediaQuery.of(context).size.width > 600; + if (isLargeScreen) { + showGeneralDialog( + context: context, + pageBuilder: (context, animation1, animation2) { + return Center( + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.height * 0.5, + child: DeviceDetailPage(device: device), + ), + ); + }, + ); + return; + } + context.push('/device-detail', extra: device); + } +} + +class _WearableIconView extends StatefulWidget { + final Wearable device; + final WearableIconVariant initialVariant; + + const _WearableIconView({ + required this.device, + required this.initialVariant, + }); + + @override + State<_WearableIconView> createState() => _WearableIconViewState(); +} + +class _WearableIconViewState extends State<_WearableIconView> { + static final Expando> _positionFutureCache = + Expando>(); + + Future? _positionFuture; + + @override + void initState() { + super.initState(); + _configurePositionFuture(); + } + + @override + void didUpdateWidget(covariant _WearableIconView oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.device, widget.device) || + oldWidget.initialVariant != widget.initialVariant) { + _configurePositionFuture(); + } + } + + void _configurePositionFuture() { + if (widget.initialVariant != WearableIconVariant.single || + !widget.device.hasCapability()) { + _positionFuture = null; + return; + } + + final stereoDevice = widget.device.requireCapability(); + _positionFuture = + _positionFutureCache[stereoDevice] ??= stereoDevice.position; + } + + WearableIconVariant _variantForPosition(DevicePosition? position) { + return switch (position) { + DevicePosition.left => WearableIconVariant.left, + DevicePosition.right => WearableIconVariant.right, + _ => widget.initialVariant, + }; + } + + String? _resolveIconPath(WearableIconVariant variant) { + final variantPath = widget.device.getWearableIconPath(variant: variant); + if (variantPath != null && variantPath.isNotEmpty) { + return variantPath; + } + + if (variant != WearableIconVariant.single) { + final fallbackPath = widget.device.getWearableIconPath(); + if (fallbackPath != null && fallbackPath.isNotEmpty) { + return fallbackPath; + } + } + return null; + } + + Widget _buildIcon(WearableIconVariant variant) { + final path = _resolveIconPath(variant); + if (path == null) { + return const SizedBox.shrink(); + } + + if (path.toLowerCase().endsWith('.svg')) { + return SvgPicture.asset( + path, + fit: BoxFit.contain, + ); + } + + return Image.asset( + path, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Icon(Icons.watch_outlined), + ); + } + + @override + Widget build(BuildContext context) { + if (_positionFuture == null) { + return _buildIcon(widget.initialVariant); + } + + return FutureBuilder( + future: _positionFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + // Avoid flashing the generic icon before stereo side is known. + return const SizedBox.shrink(); + } + final variant = _variantForPosition(snapshot.data); + if (variant == WearableIconVariant.single) { + return const SizedBox.shrink(); + } + return _buildIcon(variant); + }, + ); + } +} + +class _PairedDeviceSheet extends StatelessWidget { + final String title; + final Wearable leftDevice; + final Wearable rightDevice; + final void Function(Wearable device) onOpenDeviceDetail; + + const _PairedDeviceSheet({ + required this.title, + required this.leftDevice, + required this.rightDevice, + required this.onOpenDeviceDetail, + }); + + bool _supportsStereoListeningMode(Wearable device) { + return device.hasCapability() && + device.hasCapability(); + } + + Wearable? _resolveListeningModeDevice() { + if (_supportsStereoListeningMode(leftDevice)) { + return leftDevice; + } + if (_supportsStereoListeningMode(rightDevice)) { + return rightDevice; + } + return null; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final listeningModeDevice = _resolveListeningModeDevice(); + + return SafeArea( + child: Material( + color: theme.colorScheme.surface, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Select a device to open details.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ), - ], + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded, size: 20), + ), + ], + ), + const SizedBox(height: 10), + DeviceRow( + group: WearableDisplayGroup.single( + wearable: leftDevice, + position: DevicePosition.left, ), + onSingleDeviceSelected: onOpenDeviceDetail, + ), + const SizedBox(height: 8), + DeviceRow( + group: WearableDisplayGroup.single( + wearable: rightDevice, + position: DevicePosition.right, + ), + onSingleDeviceSelected: onOpenDeviceDetail, + ), + if (listeningModeDevice != null) ...[ + const SizedBox(height: 12), + AudioModeWidget( + key: ValueKey( + 'pair_audio_${leftDevice.deviceId}_${rightDevice.deviceId}', + ), + device: listeningModeDevice, + applyScope: AudioModeApplyScope.pairOnly, + ), + ] else ...[ + const SizedBox(height: 10), + Text( + 'Listening mode is not available for this stereo pair.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ], ), ), @@ -327,3 +1014,216 @@ class DeviceRow extends StatelessWidget { ); } } + +class _FirmwareVersionBubble extends StatefulWidget { + final DeviceFirmwareVersion firmwareVersion; + + const _FirmwareVersionBubble({required this.firmwareVersion}); + + @override + State<_FirmwareVersionBubble> createState() => _FirmwareVersionBubbleState(); +} + +class _FirmwareVersionBubbleState extends State<_FirmwareVersionBubble> { + static final Expando> _versionFutureCache = + Expando>(); + static final Expando> _supportFutureCache = + Expando>(); + + late Future _versionFuture; + late Future _supportFuture; + + @override + void initState() { + super.initState(); + _versionFuture = _resolveVersionFuture(widget.firmwareVersion); + _supportFuture = _resolveSupportFuture(widget.firmwareVersion); + } + + @override + void didUpdateWidget(covariant _FirmwareVersionBubble oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.firmwareVersion, widget.firmwareVersion)) { + _versionFuture = _resolveVersionFuture(widget.firmwareVersion); + _supportFuture = _resolveSupportFuture(widget.firmwareVersion); + } + } + + Future _resolveVersionFuture(DeviceFirmwareVersion firmwareVersion) { + return _versionFutureCache[firmwareVersion] ??= + firmwareVersion.readDeviceFirmwareVersion(); + } + + Future _resolveSupportFuture( + DeviceFirmwareVersion firmwareVersion, + ) { + return _supportFutureCache[firmwareVersion] ??= + firmwareVersion.checkFirmwareSupport(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _versionFuture, + builder: (context, versionSnapshot) { + if (versionSnapshot.connectionState == ConnectionState.waiting) { + return const _MetadataBubble(label: "FW", isLoading: true); + } + + final versionText = versionSnapshot.hasError + ? "--" + : (versionSnapshot.data?.toString() ?? "--"); + + return FutureBuilder( + future: _supportFuture, + builder: (context, supportSnapshot) { + IconData? statusIcon; + Color? statusColor; + + switch (supportSnapshot.data) { + case FirmwareSupportStatus.tooOld: + case FirmwareSupportStatus.tooNew: + statusIcon = Icons.warning_rounded; + statusColor = Colors.orange; + break; + case FirmwareSupportStatus.unknown: + statusIcon = Icons.help_rounded; + statusColor = Theme.of(context).colorScheme.onSurfaceVariant; + break; + default: + break; + } + + return _MetadataBubble( + label: "FW", + value: versionText, + trailingIcon: statusIcon, + foregroundColor: statusColor, + ); + }, + ); + }, + ); + } +} + +class _HardwareVersionBubble extends StatefulWidget { + final DeviceHardwareVersion hardwareVersion; + + const _HardwareVersionBubble({required this.hardwareVersion}); + + @override + State<_HardwareVersionBubble> createState() => _HardwareVersionBubbleState(); +} + +class _HardwareVersionBubbleState extends State<_HardwareVersionBubble> { + static final Expando> _versionFutureCache = + Expando>(); + + late Future _versionFuture; + + @override + void initState() { + super.initState(); + _versionFuture = _resolveVersionFuture(widget.hardwareVersion); + } + + @override + void didUpdateWidget(covariant _HardwareVersionBubble oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.hardwareVersion, widget.hardwareVersion)) { + _versionFuture = _resolveVersionFuture(widget.hardwareVersion); + } + } + + Future _resolveVersionFuture(DeviceHardwareVersion hardwareVersion) { + return _versionFutureCache[hardwareVersion] ??= + hardwareVersion.readDeviceHardwareVersion(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _versionFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _MetadataBubble(label: "HW", isLoading: true); + } + + final versionText = + snapshot.hasError ? "--" : (snapshot.data?.toString() ?? "--"); + + return _MetadataBubble( + label: "HW", + value: versionText, + ); + }, + ); + } +} + +class _MetadataBubble extends StatelessWidget { + final String label; + final String? value; + final bool isLoading; + final bool highlighted; + final IconData? trailingIcon; + final Color? foregroundColor; + + const _MetadataBubble({ + required this.label, + this.value, + this.isLoading = false, + this.highlighted = false, + this.trailingIcon, + this.foregroundColor, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final defaultForeground = colorScheme.primary; + final resolvedForeground = foregroundColor ?? defaultForeground; + final effectiveForeground = + highlighted ? colorScheme.primary : resolvedForeground; + final backgroundColor = highlighted + ? effectiveForeground.withValues(alpha: 0.12) + : colorScheme.surface; + final borderColor = highlighted + ? effectiveForeground.withValues(alpha: 0.24) + : resolvedForeground.withValues(alpha: 0.42); + final displayText = + isLoading ? "$label ..." : (value == null ? label : "$label $value"); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isLoading && trailingIcon != null) + Icon( + trailingIcon, + size: 14, + color: effectiveForeground, + ), + if (!isLoading && trailingIcon != null) const SizedBox(width: 6), + Text( + displayText, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: effectiveForeground, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ], + ), + ); + } +} diff --git a/open_wearable/lib/widgets/devices/stereo_position_badge.dart b/open_wearable/lib/widgets/devices/stereo_position_badge.dart new file mode 100644 index 00000000..ae248092 --- /dev/null +++ b/open_wearable/lib/widgets/devices/stereo_position_badge.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +class StereoPositionBadge extends StatefulWidget { + final StereoDevice device; + + const StereoPositionBadge({super.key, required this.device}); + + @override + State createState() => _StereoPositionBadgeState(); +} + +class _StereoPositionBadgeState extends State { + late Future _positionFuture; + + @override + void initState() { + super.initState(); + _positionFuture = widget.device.position; + } + + @override + void didUpdateWidget(covariant StereoPositionBadge oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.device != widget.device) { + _positionFuture = widget.device.position; + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _positionFuture, + builder: (context, snapshot) { + final colorScheme = Theme.of(context).colorScheme; + final foregroundColor = colorScheme.primary; + final backgroundColor = foregroundColor.withValues(alpha: 0.12); + final borderColor = foregroundColor.withValues(alpha: 0.24); + + final isLoading = snapshot.connectionState == ConnectionState.waiting; + + final label = switch (snapshot.data) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + + if (!isLoading && label == null) { + return const SizedBox.shrink(); + } + + final displayLabel = isLoading ? '...' : (label ?? '--'); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayLabel, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index b526c687..87d563be 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -5,8 +5,9 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:provider/provider.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; +import 'package:provider/provider.dart'; class FirmwareList extends StatefulWidget { const FirmwareList({super.key}); @@ -20,7 +21,6 @@ class _FirmwareListState extends State { final _repository = UnifiedFirmwareRepository(); String? firmwareVersion; bool _expanded = false; - bool _showBeta = false; @override void initState() { @@ -30,48 +30,85 @@ class _FirmwareListState extends State { } void _loadFirmwares() { - _firmwareFuture = _repository.getAllFirmwares(includeBeta: _showBeta); + _firmwareFuture = _loadFirmwaresWithFallback(); + } + + Future> _loadFirmwaresWithFallback() async { + List stable = const []; + List beta = const []; + Object? stableError; + Object? betaError; + + try { + stable = await _repository.getStableFirmwares(); + } catch (error) { + stableError = error; + // Keep going to allow beta-only fallback when stable fetch fails. + } + + try { + beta = await _repository.getBetaFirmwares(); + } catch (error) { + betaError = error; + // Beta feed is optional. Ignore failures. + } + + final combined = [ + ...stable, + ...beta, + ]; + + // Require both requests to succeed. If either source request fails, + // surface the fetch error UI. + if (stableError != null || betaError != null) { + throw Exception('Could not fetch firmware list from the internet.'); + } + + return combined; + } + + Future _refreshFirmwares() async { + setState(_loadFirmwares); + try { + await _firmwareFuture; + } catch (_) { + // Error state is handled by FutureBuilder. + } } - void _loadFirmwareVersion() async { + Future _loadFirmwareVersion() async { final wearable = Provider.of(context, listen: false) .selectedWearable; - if (wearable != null && wearable.hasCapability()) { - final version = await wearable - .requireCapability() - .readDeviceFirmwareVersion(); - setState(() { - firmwareVersion = version; - }); + if (wearable == null || !wearable.hasCapability()) { + return; } - } - void _toggleBeta() { + final version = await wearable + .requireCapability() + .readDeviceFirmwareVersion(); + if (!mounted) { + return; + } setState(() { - _showBeta = !_showBeta; - _loadFirmwares(); + firmwareVersion = version; }); - print(_showBeta ? 'Beta firmware enabled' : 'Beta firmware disabled'); } @override Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: GestureDetector( - onLongPress: _toggleBeta, - child: PlatformText('Select Firmware'), - ), + title: const Text('Select Firmware'), trailingActions: [ - IconButton( + PlatformIconButton( onPressed: () => _onCustomFirmwareSelect(context), - icon: Icon(Icons.add), + icon: const Icon(Icons.upload_file_rounded), padding: EdgeInsets.zero, ), ], ), - body: _body(), // Remove Material wrapper + body: _body(), ); } @@ -79,23 +116,17 @@ class _FirmwareListState extends State { final confirmed = await showDialog( context: context, builder: (_) => PlatformAlertDialog( - title: PlatformText('Disclaimer'), - content: PlatformText( + title: const Text('Custom Firmware'), + content: const Text( 'By selecting a custom firmware file, you acknowledge that you are doing so at your own risk. The developers are not responsible for any damage caused.', ), actions: [ PlatformDialogAction( - child: PlatformText( - 'Cancel', - style: TextStyle(fontWeight: FontWeight.bold), - ), + child: const Text('Cancel'), onPressed: () => Navigator.of(context).pop(false), ), PlatformDialogAction( - child: PlatformText( - 'Continue', - style: TextStyle(color: Colors.red), - ), + child: const Text('Continue'), onPressed: () => Navigator.of(context).pop(true), ), ], @@ -125,7 +156,9 @@ class _FirmwareListState extends State { ); context.read().setFirmware(fw); - Navigator.pop(context); + if (mounted) { + Navigator.pop(context); + } } Widget _body() { @@ -135,31 +168,115 @@ class _FirmwareListState extends State { if (snapshot.hasData) { final entries = snapshot.data!; if (entries.isEmpty) { - return Center(child: PlatformText('No firmware available')); + return _emptyState(); } return _listBuilder(entries); } else if (snapshot.hasError) { return _errorWidget(); } - return const Center(child: CircularProgressIndicator()); + return _loadingState(); }, ); } + Widget _loadingState() { + final colorScheme = Theme.of(context).colorScheme; + + return ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Loading firmware versions...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _emptyState() { + final colorScheme = Theme.of(context).colorScheme; + return ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + child: Text( + 'No firmware is available right now.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], + ); + } + Widget _errorWidget() { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - PlatformText("Could not fetch firmware, please try again"), - const SizedBox(height: 16), - PlatformElevatedButton( - onPressed: _loadFirmwares, - child: PlatformText('Reload'), + final colorScheme = Theme.of(context).colorScheme; + + return ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Could not fetch firmware list from the internet.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Please check your internet connection and try again.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _refreshFirmwares, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Reload'), + ), + ), + ], + ), ), - ], - ), + ), + ], ); } @@ -167,23 +284,129 @@ class _FirmwareListState extends State { final stableEntries = entries.where((e) => e.isStable).toList(); final betaEntries = entries.where((e) => e.isBeta).toList(); final latestStable = stableEntries.isNotEmpty ? stableEntries.first : null; + final orderedEntries = [ + ...stableEntries, + ...betaEntries, + ]; + final latestEntry = latestStable ?? + (orderedEntries.isNotEmpty ? orderedEntries.first : entries.first); + final currentEntry = _findCurrentEntry(orderedEntries); + final collapsedEntries = [ + latestEntry, + if (currentEntry != null && currentEntry != latestEntry) currentEntry, + ]; + final visibleEntries = _expanded ? orderedEntries : collapsedEntries; + final canToggleExpanded = orderedEntries.length > collapsedEntries.length; + + return ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + _summaryCard( + totalCount: entries.length, + stableCount: stableEntries.length, + betaCount: betaEntries.length, + ), + if (betaEntries.isNotEmpty) ...[ + const SizedBox(height: SensorPageSpacing.sectionGap), + _betaWarningBanner(), + ], + const SizedBox(height: SensorPageSpacing.sectionGap), + ...visibleEntries.map( + (entry) => Padding( + padding: + const EdgeInsets.only(bottom: SensorPageSpacing.sectionGap), + child: _firmwareListItem( + entry, + latestStable, + ), + ), + ), + if (canToggleExpanded || _expanded) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + setState(() { + _expanded = !_expanded; + }); + }, + icon: Icon( + _expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 18, + ), + label: Text( + _expanded ? 'Hide Older Versions' : 'Show Older Versions', + ), + ), + ), + ], + ); + } - final visibleEntries = _expanded ? entries : [entries.first]; - - return SafeArea( - child: SingleChildScrollView( + Widget _summaryCard({ + required int totalCount, + required int stableCount, + required int betaCount, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final installed = _normalizedDeviceVersion; + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_showBeta && betaEntries.isNotEmpty) _betaWarningBanner(), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: visibleEntries.length, - itemBuilder: (context, index) => - _firmwareListItem(visibleEntries[index], latestStable, index), + Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + Icons.system_update_alt_rounded, + size: 15, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Available Firmware', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _MetaChip(label: '$totalCount total'), + _MetaChip(label: '$stableCount stable'), + if (betaCount > 0) _MetaChip(label: '$betaCount beta'), + ], + ), + const SizedBox(height: 8), + Text( + installed == null + ? 'Current firmware version could not be read from the device.' + : 'Current device firmware version: $installed', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - if (entries.length > 1) _expandButton(), - const SizedBox(height: 16), // Simple bottom padding ], ), ), @@ -191,22 +414,33 @@ class _FirmwareListState extends State { } Widget _betaWarningBanner() { + final colorScheme = Theme.of(context).colorScheme; return Container( width: double.infinity, - padding: const EdgeInsets.all(12), - color: Colors.orange.withValues(alpha: 0.2), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.52), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.tertiary.withValues(alpha: 0.45), + ), + ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.warning, color: Colors.orange, size: 20), + Icon( + Icons.warning_amber_rounded, + color: colorScheme.tertiary, + size: 18, + ), const SizedBox(width: 8), Expanded( - child: PlatformText( - 'Beta firmware is experimental and may be unstable. Use at your own risk.', - style: TextStyle( - color: Colors.orange.shade900, - fontSize: 12, - fontWeight: FontWeight.w500, - ), + child: Text( + 'Beta firmware is experimental and is not recommended to be used. Use at your own risk.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), ), ), ], @@ -217,175 +451,312 @@ class _FirmwareListState extends State { Widget _firmwareListItem( FirmwareEntry entry, FirmwareEntry? latestStable, - int index, ) { final firmware = entry.firmware; final isBeta = entry.isBeta; final isLatestStable = entry == latestStable; - final remarks = []; - bool isInstalled = false; - - if (firmwareVersion != null && - firmware.version.contains(firmwareVersion!.replaceAll('\x00', ''))) { - remarks.add('Current'); - isInstalled = true; - } - - if (isLatestStable) { - remarks.add('Latest'); - } - - if (isBeta) { - remarks.add('Beta'); - } - - return ListTile( - leading: isBeta ? Icon(Icons.bug_report, color: Colors.orange) : null, - title: PlatformText( - firmware.name, - style: TextStyle( - color: isLatestStable ? Colors.black : Colors.grey, + final isInstalled = _isInstalledFirmware(firmware); + + return Card( + margin: EdgeInsets.zero, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _onFirmwareTap( + firmware, + isInstalled: isInstalled, + isLatest: isLatestStable, + isBeta: isBeta, ), - ), - subtitle: PlatformText( - remarks.join(', '), - style: TextStyle( - color: isLatestStable ? Colors.black : Colors.grey, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 10, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFirmwareLeading( + isBeta: isBeta, + isInstalled: isInstalled, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + firmware.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Version ${firmware.version}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + if (isInstalled) + _MetaChip( + label: 'Current', + tone: _ChipTone.success, + ), + if (isLatestStable) + const _MetaChip( + label: 'Latest', + tone: _ChipTone.primary, + ), + if (isBeta) + const _MetaChip( + label: 'Beta', + tone: _ChipTone.warning, + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right_rounded, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), ), ), - onTap: () => _onFirmwareTap( - firmware, - index, - isInstalled, - isLatestStable, - isBeta, - ), ); } - Widget _expandButton() { - return PlatformTextButton( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - PlatformText( - _expanded ? 'Hide older versions' : 'Show older versions', - style: TextStyle(color: Colors.black), - ), - SizedBox(width: 8), - Icon( - _expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, - ), - ], + Widget _buildFirmwareLeading({ + required bool isBeta, + required bool isInstalled, + }) { + final colorScheme = Theme.of(context).colorScheme; + final iconColor = isBeta + ? colorScheme.tertiary + : isInstalled + ? colorScheme.primary + : colorScheme.onSurfaceVariant; + final icon = isBeta + ? Icons.science_outlined + : isInstalled + ? Icons.check_circle_rounded + : Icons.memory_rounded; + + return Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.14), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + icon, + size: 15, + color: iconColor, ), - onPressed: () { - setState(() { - _expanded = !_expanded; - }); - }, ); } void _onFirmwareTap( - RemoteFirmware firmware, - int index, - bool isInstalled, - bool isLatest, - bool isBeta, - ) { + RemoteFirmware firmware, { + required bool isInstalled, + required bool isLatest, + required bool isBeta, + }) { if (isInstalled) { - _showInstalledDialog(firmware, index); + _showInstalledDialog(firmware); } else if (isBeta) { - _showBetaWarningDialog(firmware, index); + _showBetaWarningDialog(firmware); } else if (!isLatest) { - _showOldVersionWarningDialog(firmware, index); + _showOldVersionWarningDialog(firmware); } else { - _installFirmware(firmware, index); + _installFirmware(firmware); } } - void _showInstalledDialog(RemoteFirmware firmware, int index) { - showDialog( + Future _showInstalledDialog(RemoteFirmware firmware) async { + final confirmed = await showPlatformDialog( context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Already Installed'), - content: PlatformText( - 'This firmware version is already installed on the device.', + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Firmware Already Installed'), + content: const Text( + 'This firmware version appears to already be installed. Do you want to install it again?', ), - actions: [ - PlatformTextButton( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('Cancel'), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), ), - PlatformTextButton( + PlatformDialogAction( + child: const Text('Install Anyway'), onPressed: () { - _installFirmware(firmware, index); - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(true); }, - child: PlatformText('Install Anyway'), ), ], ), ); + if (confirmed == true) { + _installFirmware(firmware); + } } - void _showBetaWarningDialog(RemoteFirmware firmware, int index) { - showDialog( + Future _showBetaWarningDialog(RemoteFirmware firmware) async { + final confirmed = await showPlatformDialog( context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Beta Firmware Warning'), - content: PlatformText( + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Install Beta Firmware?'), + content: const Text( 'You are about to install beta firmware from a pull request. ' 'This firmware may be unstable or incomplete. ' 'Proceed at your own risk.', ), - actions: [ - PlatformTextButton( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('Cancel'), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), ), - PlatformTextButton( + PlatformDialogAction( + cupertino: (_, __) => + CupertinoDialogActionData(isDestructiveAction: true), + child: const Text('Install Beta'), onPressed: () { - _installFirmware(firmware, index); - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(true); }, - child: PlatformText( - 'Install', - style: TextStyle(color: Colors.red), - ), ), ], ), ); + if (confirmed == true) { + _installFirmware(firmware); + } } - void _showOldVersionWarningDialog(RemoteFirmware firmware, int index) { - showDialog( + Future _showOldVersionWarningDialog(RemoteFirmware firmware) async { + final confirmed = await showPlatformDialog( context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Warning'), - content: PlatformText( + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Install Older Version?'), + content: const Text( 'You are selecting an old firmware version. We recommend installing the newest version.', ), - actions: [ - PlatformTextButton( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('Cancel'), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), ), - PlatformTextButton( + PlatformDialogAction( + child: const Text('Proceed'), onPressed: () { - _installFirmware(firmware, index); - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(true); }, - child: PlatformText('Proceed'), ), ], ), ); + if (confirmed == true) { + _installFirmware(firmware); + } + } + + bool _isInstalledFirmware(RemoteFirmware firmware) { + final version = _normalizedDeviceVersion; + if (version == null) { + return false; + } + return firmware.version == version || firmware.version.contains(version); + } + + FirmwareEntry? _findCurrentEntry(List orderedEntries) { + final version = _normalizedDeviceVersion; + if (version == null) { + return null; + } + + for (final entry in orderedEntries) { + final fwVersion = entry.firmware.version; + if (fwVersion == version || fwVersion.contains(version)) { + return entry; + } + } + return null; + } + + String? get _normalizedDeviceVersion { + final version = firmwareVersion?.replaceAll('\x00', '').trim(); + if (version == null || version.isEmpty) { + return null; + } + return version; } - void _installFirmware(RemoteFirmware firmware, int index) { + void _installFirmware(RemoteFirmware firmware) { context.read().setFirmware(firmware); - Navigator.pop(context, 'Firmware $index'); + Navigator.pop(context); + } +} + +enum _ChipTone { neutral, primary, success, warning } + +class _MetaChip extends StatelessWidget { + final String label; + final _ChipTone tone; + + const _MetaChip({ + required this.label, + this.tone = _ChipTone.neutral, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final (foreground, background, border) = switch (tone) { + _ChipTone.primary => ( + colorScheme.primary, + colorScheme.primaryContainer.withValues(alpha: 0.3), + colorScheme.primary.withValues(alpha: 0.35), + ), + _ChipTone.success => ( + colorScheme.primary, + colorScheme.primaryContainer.withValues(alpha: 0.3), + colorScheme.primary.withValues(alpha: 0.35), + ), + _ChipTone.warning => ( + colorScheme.tertiary, + colorScheme.tertiaryContainer.withValues(alpha: 0.5), + colorScheme.tertiary.withValues(alpha: 0.45), + ), + _ChipTone.neutral => ( + colorScheme.onSurfaceVariant, + colorScheme.surfaceContainerHighest.withValues(alpha: 0.38), + colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: border), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ); } } diff --git a/open_wearable/lib/widgets/fota/firmware_update.dart b/open_wearable/lib/widgets/fota/firmware_update.dart index 42d3e459..4fe13d29 100644 --- a/open_wearable/lib/widgets/fota/firmware_update.dart +++ b/open_wearable/lib/widgets/fota/firmware_update.dart @@ -1,105 +1,282 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; - -import '../fota/stepper_view/update_view.dart'; -import '../fota/stepper_view/firmware_select.dart'; +import 'package:go_router/go_router.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/fota/stepper_view/firmware_select.dart'; +import 'package:open_wearable/widgets/fota/stepper_view/update_view.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; class FirmwareUpdateWidget extends StatefulWidget { const FirmwareUpdateWidget({super.key}); @override - State createState() => _FirmwareUpdateWidget(); + State createState() => _FirmwareUpdateWidgetState(); } -class _FirmwareUpdateWidget extends State { +class _FirmwareUpdateWidgetState extends State { late FirmwareUpdateRequestProvider provider; + bool _isUpdateRunning = false; + bool _hasStartedUpdate = false; @override Widget build(BuildContext context) { provider = context.watch(); - return PlatformScaffold( - appBar: PlatformAppBar(title: PlatformText("Update Firmware")), - body: Material(type: MaterialType.transparency, child: _body(context)), - ); - } + final request = provider.updateParameters; + final shouldRouteBackToDevices = _hasStartedUpdate && !_isUpdateRunning; - Widget _body(BuildContext context) { - return Stepper( - connectorColor: WidgetStateProperty.resolveWith( - (states) { - if (states.contains(WidgetState.selected)) { - return Colors.green; - } - return Colors.grey; - }, - ), - currentStep: provider.currentStep, - onStepContinue: () { - setState(() { - provider.nextStep(); - }); - }, - onStepCancel: () { - setState(() { - provider.previousStep(); - }); + return PopScope( + canPop: !_isUpdateRunning && !shouldRouteBackToDevices, + onPopInvokedWithResult: (didPop, _) { + if (didPop) { + return; + } + + if (_isUpdateRunning) { + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.hideCurrentSnackBar(); + messenger?.showSnackBar( + const SnackBar( + content: Text( + 'Firmware update is running. Please stay on this page until it finishes.', + ), + ), + ); + return; + } + + if (shouldRouteBackToDevices) { + context.go('/?tab=devices'); + } }, - controlsBuilder: _controlBuilder, - steps: [ - Step( - state: - provider.currentStep > 0 ? StepState.complete : StepState.indexed, - title: PlatformText('Select Firmware'), - content: Center( - child: FirmwareSelect(), - ), - isActive: provider.currentStep >= 0, + child: PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Firmware Update'), ), - Step( - state: - provider.currentStep > 1 ? StepState.complete : StepState.indexed, - title: PlatformText('Update'), - content: PlatformText('Update'), - isActive: provider.currentStep >= 1, + body: ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + _UpdateStepHeader(currentStep: provider.currentStep), + const SizedBox(height: SensorPageSpacing.sectionGap), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: _buildStepContent(context, request), + ), + ), + if (provider.currentStep == 0 && request.firmware != null) ...[ + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + setState(() { + _isUpdateRunning = true; + _hasStartedUpdate = true; + }); + provider.nextStep(); + }, + icon: const Icon(Icons.system_update_alt_rounded, size: 18), + label: const Text('Start Update'), + ), + ), + ], + ], ), - ], + ), ); } - Widget _controlBuilder(BuildContext context, ControlsDetails details) { - final provider = context.watch(); - FirmwareUpdateRequest parameters = provider.updateParameters; + Widget _buildStepContent( + BuildContext context, + FirmwareUpdateRequest request, + ) { switch (provider.currentStep) { case 0: - if (parameters.firmware == null) { - return Container(); - } - return Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - PlatformElevatedButton( - onPressed: details.onStepContinue, - child: PlatformText('Next'), + Text( + 'Select Firmware', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Choose firmware to install on the selected wearable.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), + const SizedBox(height: 12), + const FirmwareSelect(), ], ); case 1: - return BlocProvider( - create: (context) => UpdateBloc(firmwareUpdateRequest: parameters), - child: UpdateStepView(), + if (request.firmware == null) { + return Text( + 'No firmware selected. Go back and select firmware first.', + style: Theme.of(context).textTheme.bodyMedium, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Install Firmware', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Firmware update is running. Do not close the app until it finishes.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + BlocProvider( + create: (context) => UpdateBloc(firmwareUpdateRequest: request), + child: UpdateStepView( + autoStart: true, + onUpdateRunningChanged: _handleUpdateRunningChanged, + ), + ), + ], ); default: - throw Exception('Unknown step'); + return const SizedBox.shrink(); } } @override void dispose() { - // Reset the state when this widget is disposed (e.g. popped) + // Reset state after the page has been removed. WidgetsBinding.instance.addPostFrameCallback((_) { provider.reset(); }); super.dispose(); } + + void _handleUpdateRunningChanged(bool running) { + if (!mounted) { + return; + } + + if (running && !_hasStartedUpdate) { + _hasStartedUpdate = true; + } + + if (_isUpdateRunning == running) { + return; + } + setState(() { + _isUpdateRunning = running; + }); + } +} + +class _UpdateStepHeader extends StatelessWidget { + final int currentStep; + + const _UpdateStepHeader({required this.currentStep}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _StepPill( + index: 1, + label: 'Select', + isActive: currentStep == 0, + isComplete: currentStep > 0, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _StepPill( + index: 2, + label: 'Update', + isActive: currentStep == 1, + isComplete: false, + ), + ), + ], + ); + } +} + +class _StepPill extends StatelessWidget { + final int index; + final String label; + final bool isActive; + final bool isComplete; + + const _StepPill({ + required this.index, + required this.label, + required this.isActive, + required this.isComplete, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = isActive || isComplete + ? colorScheme.primary + : colorScheme.onSurfaceVariant; + final background = isActive + ? colorScheme.primaryContainer.withValues(alpha: 0.8) + : colorScheme.surfaceContainerHighest.withValues(alpha: 0.45); + final border = isActive || isComplete + ? colorScheme.primary.withValues(alpha: 0.45) + : colorScheme.outlineVariant.withValues(alpha: 0.6); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: border), + ), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: foreground.withValues(alpha: 0.14), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: isComplete + ? Icon( + Icons.check_rounded, + size: 12, + color: foreground, + ) + : Text( + '$index', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } } diff --git a/open_wearable/lib/widgets/fota/fota_warning_page.dart b/open_wearable/lib/widgets/fota/fota_warning_page.dart index 6eb028d9..3f846caf 100644 --- a/open_wearable/lib/widgets/fota/fota_warning_page.dart +++ b/open_wearable/lib/widgets/fota/fota_warning_page.dart @@ -1,10 +1,10 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; class FotaWarningPage extends StatefulWidget { const FotaWarningPage({super.key}); @@ -15,10 +15,10 @@ class FotaWarningPage extends StatefulWidget { class _FotaWarningPageState extends State { static const int _minimumBatteryThreshold = 50; - + int? _currentBatteryLevel; bool _checkingBattery = true; - + @override void initState() { super.initState(); @@ -32,31 +32,29 @@ class _FotaWarningPageState extends State { listen: false, ); final device = updateProvider.selectedWearable; - + if (device != null && device.hasCapability()) { - // Get the current battery level from the stream - final batteryLevel = await device.requireCapability() + final batteryLevel = await device + .requireCapability() .batteryPercentageStream .first .timeout( const Duration(seconds: 5), onTimeout: () => 0, ); - + if (mounted) { setState(() { _currentBatteryLevel = batteryLevel; _checkingBattery = false; }); } - } else { - if (mounted) { - setState(() { - _checkingBattery = false; - }); - } + } else if (mounted) { + setState(() { + _checkingBattery = false; + }); } - } catch (e) { + } catch (_) { if (mounted) { setState(() { _checkingBattery = false; @@ -69,23 +67,28 @@ class _FotaWarningPageState extends State { final uri = Uri.parse( 'https://github.com/OpenEarable/open-earable-2?tab=readme-ov-file#setup', ); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.platformDefault); - } else { - throw 'Could not launch $uri'; + final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (opened || !mounted) { + return; } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not open GitHub instructions.'), + ), + ); } void _handleProceed() { if (_currentBatteryLevel == null) { - // Battery level could not be determined showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( - title: const Text('Battery Level Unknown'), + title: const Text('Battery level unknown'), content: Text( - 'Unable to determine the OpenEarable battery level. ' - 'For safety, please ensure your OpenEarable is charged to at least $_minimumBatteryThreshold% before proceeding with the firmware update.\n\n' + 'Unable to read the current battery level.\n\n' + 'Please make sure your OpenEarable is charged to at least ' + '$_minimumBatteryThreshold% before continuing.\n\n' 'Do you want to proceed anyway?', ), actions: [ @@ -95,9 +98,8 @@ class _FotaWarningPageState extends State { onPressed: () => Navigator.of(context).pop(), ), PlatformDialogAction( - cupertino: (_, __) => CupertinoDialogActionData( - isDestructiveAction: true, - ), + cupertino: (_, __) => + CupertinoDialogActionData(isDestructiveAction: true), child: const Text('Proceed Anyway'), onPressed: () { Navigator.of(context).pop(); @@ -108,7 +110,6 @@ class _FotaWarningPageState extends State { ), ); } else if (_currentBatteryLevel! < _minimumBatteryThreshold) { - // Show first warning dialog with option to force update _showLowBatteryWarning(); } else { context.push('/fota/update'); @@ -119,11 +120,12 @@ class _FotaWarningPageState extends State { showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( - title: const Text('Battery Level Too Low'), + title: const Text('Battery level too low'), content: Text( - 'Your OpenEarable battery level is $_currentBatteryLevel%, which is below the required $_minimumBatteryThreshold% minimum for firmware updates.\n\n' - 'Updating with low battery can cause the update to fail and may result in a bricked device.\n\n' - 'It is strongly recommended to charge your device before proceeding.', + 'Your OpenEarable battery level is $_currentBatteryLevel%, which is ' + 'below the recommended $_minimumBatteryThreshold% for firmware updates.\n\n' + 'Updating with low battery can fail and may leave the device unusable, requiring recovery with a J-Link debugger.\n\n' + 'Please charge your device before continuing.', ), actions: [ PlatformDialogAction( @@ -134,9 +136,8 @@ class _FotaWarningPageState extends State { onPressed: () => Navigator.of(context).pop(), ), PlatformDialogAction( - cupertino: (_, __) => CupertinoDialogActionData( - isDestructiveAction: true, - ), + cupertino: (_, __) => + CupertinoDialogActionData(isDestructiveAction: true), child: const Text('Force Update Anyway'), onPressed: () { Navigator.of(context).pop(); @@ -152,10 +153,10 @@ class _FotaWarningPageState extends State { showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( - title: const Text('Critical Warning'), + title: const Text('Critical warning'), content: Text( - 'FINAL WARNING: Proceeding with a firmware update at $_currentBatteryLevel% battery may permanently brick your OpenEarable device.\n\n' - 'You will not be able to recover the device without a J-Link debugger if the update fails due to low battery.\n\n' + 'FINAL WARNING: Proceeding with $_currentBatteryLevel% battery can ' + 'cause the update to fail and leave your OpenEarable unusable until it is recovered with a J-Link debugger.\n\n' 'Are you absolutely sure you want to continue?', ), actions: [ @@ -167,9 +168,8 @@ class _FotaWarningPageState extends State { onPressed: () => Navigator.of(context).pop(), ), PlatformDialogAction( - cupertino: (_, __) => CupertinoDialogActionData( - isDestructiveAction: true, - ), + cupertino: (_, __) => + CupertinoDialogActionData(isDestructiveAction: true), child: const Text('I Understand, Proceed'), onPressed: () { Navigator.of(context).pop(); @@ -184,280 +184,404 @@ class _FotaWarningPageState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final textTheme = theme.textTheme; - final baseTextStyle = textTheme.bodyLarge; // one place to define size + final colorScheme = theme.colorScheme; return PlatformScaffold( appBar: PlatformAppBar( - title: const Text('Firmware Update'), + title: const Text('Update Instructions'), ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: DefaultTextStyle.merge( // <<– base style for everything - style: baseTextStyle ?? const TextStyle(fontSize: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header with warning icon - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Icons.warning_amber_rounded, - color: theme.colorScheme.error, - size: 32, - ), - const SizedBox(width: 12), - Text( - 'Warning', - style: (baseTextStyle ?? const TextStyle()) - .copyWith( - fontWeight: FontWeight.bold, - fontSize: (baseTextStyle?.fontSize ?? 16) + 2, - ), - ), - ], - ), - const SizedBox(height: 16), - - // First paragraph with hyperlink - Text.rich( - TextSpan( - style: baseTextStyle, - children: [ - const TextSpan( - text: - 'Updating OpenEarable via Bluetooth is currently an experimental feature. ' - 'Hence, updating OpenEarable over Bluetooth might sometimes not complete successfully. ' - 'If that happens, you can easily perform a manual update with the help of a J-Link debugger (see ', - ), - TextSpan( - text: 'GitHub instructions', - style: const TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - fontWeight: FontWeight.w600, - ), - recognizer: TapGestureRecognizer() - ..onTap = _openGitHubLink, - ), - const TextSpan(text: ').'), - ], + body: ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + _SectionCard( + title: 'Before You Update', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _WarningPill( + label: 'Bluetooth firmware updates are experimental.', + ), + const SizedBox(height: 10), + Text( + 'Following the steps below ensures that updates will not fail. ' + 'In the unlikely event that an update fails, the device must be recovered with a J-Link debugger.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _openGitHubLink, + icon: const Icon(Icons.open_in_new_rounded, size: 18), + label: const Text('Open GitHub Recovery Instructions'), + ), + ), + ], + ), + ), + const SizedBox(height: SensorPageSpacing.sectionGap), + _SectionCard( + title: 'Checklist', + subtitle: 'Please confirm these points before continuing.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _ChecklistItem( + number: 1, + text: + 'Power cycle your OpenEarable once before starting the update.', + ), + const SizedBox(height: 8), + const _ChecklistItem( + number: 2, + text: + 'Keep the app open in the foreground and disable power-saving mode.', + ), + const SizedBox(height: 8), + _ChecklistItem( + number: 3, + text: + 'Ensure at least $_minimumBatteryThreshold% battery before updating. Full charge is recommended.', + ), + const SizedBox(height: 8), + const _ChecklistItem( + number: 4, + text: 'Keep OpenEarable disconnected from the charger.', + ), + const SizedBox(height: 8), + const _ChecklistItem( + number: 5, + text: + 'If you have two devices, power off the one that is not being updated.', + ), + const SizedBox(height: 8), + const _ChecklistItem( + number: 6, + text: + 'After upload, verification can take up to 3 minutes and is indicated by a blinking red LED. Do not reset during verification, or you may brick it.', + boldFragment: 'Do not reset during verification', + ), + ], + ), + ), + const SizedBox(height: SensorPageSpacing.sectionGap), + _buildBatteryCard(context), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _checkingBattery ? null : _handleProceed, + icon: _checkingBattery + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, ), - ), - const SizedBox(height: 16), + ) + : const Icon(Icons.arrow_forward_rounded, size: 18), + label: Text( + _checkingBattery + ? 'Checking Battery...' + : 'Acknowledge and Proceed', + ), + ), + ), + ], + ), + ); + } - Text( - 'To help ensure a smooth update, please:', - style: baseTextStyle?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - - // Steps in a Card - Card( - elevation: 2, - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _NumberedStep( - number: '1.', - text: TextSpan( - text: - 'Power cycle your OpenEarable once before you update.', - ), - ), - const SizedBox(height: 8), - const _NumberedStep( - number: '2.', - text: TextSpan( - text: - 'Keep the app open in the foreground and make sure your phone doesn’t enter power-saving mode.', - ), - ), - const SizedBox(height: 8), - _NumberedStep( - number: '3.', - text: TextSpan( - text: - 'Ensure your OpenEarable has at least $_minimumBatteryThreshold% battery charge before starting. Fully charging is recommended.', - ), - ), - const SizedBox(height: 8), - const _NumberedStep( - number: '4.', - text: TextSpan( - text: "Keep OpenEarable disconnected from charger during the update.", - ), - ), - const SizedBox(height: 8), - const _NumberedStep( - number: '5.', - text: TextSpan( - text: - 'If you have two devices, power off the one that’s not being updated.', - ), - ), - const SizedBox(height: 8), - const _NumberedStep( - number: '6.', - text: TextSpan( - children: [ - TextSpan( - text: - 'After the firmware is uploaded, OpenEarable will automatically verify it. ' - 'During this step, the device might seem unresponsive for up to 3 minutes. ' - 'Don’t worry, this is normal. It will start blinking again once the process is complete.\n', - ), - TextSpan( - text: - 'Don‘t reset the device via the button while the firmware is verified by OpenEarable.', - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ), - ), - - const SizedBox(height: 24), - - // Battery level warning if below 50% - if (_currentBatteryLevel != null && _currentBatteryLevel! < _minimumBatteryThreshold) - Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.error.withValues(alpha: 0.1), - border: Border.all( - color: theme.colorScheme.error, - width: 2, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - Icons.battery_alert, - color: theme.colorScheme.error, - size: 24, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Battery level is $_currentBatteryLevel%. Please charge to at least $_minimumBatteryThreshold% before updating.', - style: baseTextStyle?.copyWith( - color: theme.colorScheme.error, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - - // Battery level warning if unknown - if (!_checkingBattery && _currentBatteryLevel == null) - Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.1), - border: Border.all( - color: Colors.orange, - width: 2, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - Icons.battery_unknown, - color: Colors.orange, - size: 24, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Unable to determine battery level. Please ensure your device is charged to at least $_minimumBatteryThreshold%.', - style: baseTextStyle?.copyWith( - color: Colors.orange.shade900, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), + Widget _buildBatteryCard(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; - // Proceed button - SizedBox( - width: double.infinity, - child: _checkingBattery - ? const Center(child: CircularProgressIndicator()) - : PlatformElevatedButton( - onPressed: _handleProceed, - child: const Text('Acknowledge and Proceed'), - ), - ), - ], + if (_checkingBattery) { + return _SectionCard( + title: 'Battery Status', + subtitle: 'Checking current battery level...', + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Reading battery level from the device.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), ), - ), + ], + ), + ); + } + + if (_currentBatteryLevel == null) { + return _SectionCard( + title: 'Battery Status', + subtitle: 'Battery level could not be determined.', + child: _StatusNotice( + icon: Icons.battery_unknown_rounded, + text: + 'Please ensure your device is charged to at least $_minimumBatteryThreshold% before updating.', + foregroundColor: colorScheme.tertiary, + backgroundColor: colorScheme.tertiaryContainer.withValues(alpha: 0.5), + borderColor: colorScheme.tertiary.withValues(alpha: 0.45), + ), + ); + } + + final batteryLevel = _currentBatteryLevel!; + final low = batteryLevel < _minimumBatteryThreshold; + + return _SectionCard( + title: 'Battery Status', + subtitle: low + ? 'Battery is below the recommended update threshold.' + : 'Battery level is sufficient for update.', + child: _StatusNotice( + icon: low ? Icons.battery_alert_rounded : Icons.battery_charging_full, + text: low + ? 'Battery level is $batteryLevel%. Please charge to at least $_minimumBatteryThreshold% before updating.' + : 'Battery level is $batteryLevel%. You can proceed with the update.', + foregroundColor: low ? colorScheme.error : colorScheme.primary, + backgroundColor: low + ? colorScheme.errorContainer.withValues(alpha: 0.45) + : colorScheme.primaryContainer.withValues(alpha: 0.35), + borderColor: low + ? colorScheme.error.withValues(alpha: 0.5) + : colorScheme.primary.withValues(alpha: 0.35), + ), + ); + } +} + +class _SectionCard extends StatelessWidget { + final String title; + final String? subtitle; + final Widget child; + + const _SectionCard({ + required this.title, + this.subtitle, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 10), + child, + ], + ), + ), + ); + } +} + +class _WarningPill extends StatelessWidget { + final String label; + + const _WarningPill({required this.label}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = colorScheme.error; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: foreground.withValues(alpha: 0.45), ), ), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning_amber_rounded, + size: 15, + color: foreground, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + softWrap: true, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), ); } } -/// Helper widget for cleanly aligned numbered steps -class _NumberedStep extends StatelessWidget { - final String number; - final InlineSpan text; // now accepts TextSpan / InlineSpan +class _ChecklistItem extends StatelessWidget { + final int number; + final String text; + final String? boldFragment; - const _NumberedStep({ + const _ChecklistItem({ required this.number, required this.text, + this.boldFragment, }); @override Widget build(BuildContext context) { - final baseStyle = Theme.of(context).textTheme.bodyLarge ?? - const TextStyle(fontSize: 16); + final colorScheme = Theme.of(context).colorScheme; + final numberColor = colorScheme.primary; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - number, - style: baseStyle.copyWith(fontWeight: FontWeight.bold), + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: numberColor.withValues(alpha: 0.14), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + '$number', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: numberColor, + fontWeight: FontWeight.w800, + ), + ), ), - const SizedBox(width: 8), + const SizedBox(width: 10), Expanded( - child: RichText( - text: TextSpan( - style: baseStyle, - children: [text], - ), - ), + child: _buildText(context), ), ], ); } + + Widget _buildText(BuildContext context) { + final baseStyle = Theme.of(context).textTheme.bodyMedium; + if (boldFragment == null || boldFragment!.isEmpty) { + return Text( + text, + style: baseStyle, + ); + } + + final start = text.indexOf(boldFragment!); + if (start < 0) { + return Text( + text, + style: baseStyle, + ); + } + + final end = start + boldFragment!.length; + final before = text.substring(0, start); + final bold = text.substring(start, end); + final after = text.substring(end); + + return RichText( + text: TextSpan( + style: baseStyle, + children: [ + if (before.isNotEmpty) TextSpan(text: before), + TextSpan( + text: bold, + style: baseStyle?.copyWith(fontWeight: FontWeight.w800), + ), + if (after.isNotEmpty) TextSpan(text: after), + ], + ), + ); + } +} + +class _StatusNotice extends StatelessWidget { + final IconData icon; + final String text; + final Color foregroundColor; + final Color backgroundColor; + final Color borderColor; + + const _StatusNotice({ + required this.icon, + required this.text, + required this.foregroundColor, + required this.backgroundColor, + required this.borderColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: foregroundColor), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } } diff --git a/open_wearable/lib/widgets/fota/stepper_view/firmware_select.dart b/open_wearable/lib/widgets/fota/stepper_view/firmware_select.dart index 902120b9..3e69fa31 100644 --- a/open_wearable/lib/widgets/fota/stepper_view/firmware_select.dart +++ b/open_wearable/lib/widgets/fota/stepper_view/firmware_select.dart @@ -1,34 +1,159 @@ import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; - -import '../firmware_select/firmware_list.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import '../firmware_select/firmware_list.dart'; class FirmwareSelect extends StatelessWidget { const FirmwareSelect({super.key}); @override Widget build(BuildContext context) { - FirmwareUpdateRequest updateParameters = + final updateParameters = context.watch().updateParameters; + final selectedFirmware = updateParameters.firmware; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; - return Column( - children: [ - if (updateParameters.firmware != null) - PlatformText(updateParameters.firmware!.name), - PlatformElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => FirmwareList(), + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _openFirmwareSelection(context), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.38), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.45), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + Icons.memory_rounded, + size: 15, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: selectedFirmware == null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No firmware selected', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Tap to choose firmware.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + selectedFirmware.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + _subtitleForFirmware(selectedFirmware), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + _StatusChip( + label: selectedFirmware is RemoteFirmware + ? 'Remote' + : 'Local', + ), + ], + ), + ), + const SizedBox(width: 6), + Icon( + Icons.chevron_right_rounded, + size: 20, + color: colorScheme.onSurfaceVariant, ), - ); - }, - child: PlatformText('Select Firmware'), + ], + ), + ), + ), + ); + } + + void _openFirmwareSelection(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const FirmwareList(), + ), + ); + } + + String _subtitleForFirmware(SelectedFirmware firmware) { + if (firmware is RemoteFirmware) { + return 'Version ${firmware.version}'; + } + if (firmware is LocalFirmware) { + final typeLabel = firmware.type == FirmwareType.multiImage + ? 'Multi-image' + : 'Single-image'; + return 'Local file • $typeLabel'; + } + return 'Firmware'; + } +} + +class _StatusChip extends StatelessWidget { + final String label; + + const _StatusChip({required this.label}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.35), ), - ], + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), ); } } diff --git a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart index f65d68cd..7a856c0d 100644 --- a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart +++ b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart @@ -2,13 +2,72 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/widgets/fota/fota_verification_banner.dart'; + import '../logger_screen/logger_screen.dart'; -import 'package:open_earable_flutter/open_earable_flutter.dart'; -class UpdateStepView extends StatelessWidget { - const UpdateStepView({super.key}); +class UpdateStepView extends StatefulWidget { + final bool autoStart; + final ValueChanged? onUpdateRunningChanged; + + const UpdateStepView({ + super.key, + this.autoStart = true, + this.onUpdateRunningChanged, + }); + + @override + State createState() => _UpdateStepViewState(); +} + +class _UpdateStepViewState extends State { + static const Color _successGreen = Color(0xFF2E7D32); + + bool _lastReportedRunning = false; + bool _startRequested = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final bloc = context.read(); + final state = bloc.state; + if (widget.autoStart && state is UpdateInitial) { + setState(() { + _startRequested = true; + }); + _reportRunningState(true); + bloc.add(BeginUpdateProcess()); + return; + } + _reportRunningState(_isUpdateInProgress(state)); + }); + } + + @override + void dispose() { + if (_lastReportedRunning) { + widget.onUpdateRunningChanged?.call(false); + } + super.dispose(); + } + + void _reportRunningState(bool running) { + if (_lastReportedRunning == running) { + return; + } + _lastReportedRunning = running; + if (!running && _startRequested) { + setState(() { + _startRequested = false; + }); + } + widget.onUpdateRunningChanged?.call(running); + } @override Widget build(BuildContext context) { @@ -17,6 +76,7 @@ class UpdateStepView extends StatelessWidget { return BlocConsumer( listener: (context, state) { + _reportRunningState(_isUpdateInProgress(state)); if (state is UpdateFirmwareStateHistory && state.isComplete && state.history.isNotEmpty && @@ -25,148 +85,332 @@ class UpdateStepView extends StatelessWidget { } }, builder: (context, state) { - switch (state) { - case UpdateInitial(): - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _firmwareInfo(context, request.firmware!), - PlatformElevatedButton( - onPressed: () { - context.read().add(BeginUpdateProcess()); - }, - child: PlatformText('Update'), - ), - ], - ); + return switch (state) { + UpdateInitial() => _buildInitial(context, request), + UpdateFirmwareStateHistory() => _buildHistory(context, state), + UpdateFirmware() => _buildPendingState(context, state.stage), + }; + }, + ); + } - case UpdateFirmwareStateHistory(): - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var s in state.history) - Row( - children: [ - _stateIcon( - s, - Colors.green, - ), - const SizedBox(width: 8), - PlatformText(s.stage), - ], - ), - if (state.currentState != null) - Row( - children: [ - const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - padding: EdgeInsets.all(4), - ), - ), - _currentState(state), - ], - ), - const SizedBox(height: 12), - if (state.isComplete && state.updateManager?.logger != null) - ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LoggerScreen( - logger: state.updateManager!.logger, - ), - ), - ); - }, - child: PlatformText('Show Log'), - ), - if (state.isComplete) - ElevatedButton( - onPressed: () { - BlocProvider.of(context).add(ResetUpdate()); - provider.reset(); - }, - child: PlatformText('Update Again'), + bool _isUpdateInProgress(UpdateState state) { + if (state is UpdateInitial) { + return false; + } + if (state is UpdateFirmwareStateHistory) { + return !state.isComplete; + } + return true; + } + + Widget _buildInitial( + BuildContext context, + FirmwareUpdateRequest request, + ) { + final firmware = request.firmware; + if (firmware == null) { + return Text( + 'No firmware selected. Go back and choose firmware.', + style: Theme.of(context).textTheme.bodyMedium, + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _firmwareInfoCard(context, firmware), + const SizedBox(height: 12), + _buildPendingState(context, 'Starting update...'), + ], + ); + } + + Widget _buildPendingState(BuildContext context, String stage) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.26), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.35), + ), + ), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + stage, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, ), + ), + ), + ], + ), + ); + } - if (state.isComplete && - state.history.last is UpdateCompleteSuccess) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PlatformText( - 'Firmware upload complete.\n\n' - 'The image has been successfully uploaded and is now being verified by the device. ' - 'The device will automatically restart once verification is complete.\n\n' - 'This may take up to 3 minutes. Please keep the device powered on and nearby.', - textAlign: TextAlign.start, - ), - const SizedBox(height: 8), - const _VerificationCountdown(), // you can remove this once the global banner handles the timer - ], + Widget _buildHistory( + BuildContext context, + UpdateFirmwareStateHistory state, + ) { + final history = state.history; + final currentState = state.currentState; + final showSuccessMessage = state.isComplete && + history.isNotEmpty && + history.last is UpdateCompleteSuccess; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final entry in history) ...[ + _historyEntry(context, entry), + const SizedBox(height: 8), + ], + if (currentState != null) ...[ + _currentStatePanel(context, state), + const SizedBox(height: 10), + ], + if (showSuccessMessage) ...[ + _successPanel(context), + const SizedBox(height: 8), + const _VerificationCountdown(), + const SizedBox(height: 10), + ], + if (state.isComplete && state.updateManager?.logger != null) ...[ + OutlinedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoggerScreen( + logger: state.updateManager!.logger, ), - ], - ); + ), + ); + }, + icon: const Icon(Icons.description_outlined, size: 18), + label: const Text('Show Log'), + ), + const SizedBox(height: 8), + ], + ], + ); + } - default: - return PlatformText('Unknown state'); - } - }, + Widget _historyEntry(BuildContext context, UpdateFirmware state) { + final colorScheme = Theme.of(context).colorScheme; + final failed = state is UpdateCompleteFailure; + final foregroundColor = failed ? colorScheme.error : _successGreen; + final backgroundColor = failed + ? colorScheme.errorContainer.withValues(alpha: 0.35) + : _successGreen.withValues(alpha: 0.12); + final borderColor = failed + ? colorScheme.error.withValues(alpha: 0.45) + : _successGreen.withValues(alpha: 0.34); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + failed ? Icons.error_outline_rounded : Icons.check_circle_rounded, + size: 18, + color: foregroundColor, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + state.stage, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), ); } - Icon _stateIcon(UpdateFirmware state, Color successColor) { - if (state is UpdateCompleteFailure) { - return const Icon(size: 24, Icons.error_outline, color: Colors.red); - } else { - return Icon(size: 24, Icons.check_circle_outline, color: successColor); - } + Widget _currentStatePanel( + BuildContext context, + UpdateFirmwareStateHistory state, + ) { + final currentState = state.currentState; + final colorScheme = Theme.of(context).colorScheme; + final progress = currentState is UpdateProgressFirmware + ? (currentState.progress.clamp(0, 100) / 100.0) + : null; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.26), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.35), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _currentStateLabel(state), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (progress != null) ...[ + const SizedBox(height: 8), + LinearProgressIndicator( + value: progress, + minHeight: 4, + color: colorScheme.primary, + backgroundColor: colorScheme.primary.withValues(alpha: 0.16), + ), + ], + ], + ), + ); } - PlatformText _currentState(UpdateFirmwareStateHistory state) { + String _currentStateLabel(UpdateFirmwareStateHistory state) { final currentState = state.currentState; if (currentState == null) { - return PlatformText('Unknown state'); - } else if (currentState is UpdateProgressFirmware) { - var core = currentState.imageNumber == 0 ? "application" : "network"; - return PlatformText( - "Uploading $core core (image ${currentState.imageNumber}) ${currentState.progress}%", - ); - } else { - return PlatformText(currentState.stage); + return 'Preparing update...'; } - } - - Widget _firmwareInfo(BuildContext context, SelectedFirmware firmware) { - if (firmware is LocalFirmware) { - return _localFirmwareInfo(context, firmware); - } else if (firmware is RemoteFirmware) { - return _remoteFirmwareInfo(context, firmware); - } else { - return PlatformText('Unknown firmware type'); + if (currentState is UpdateProgressFirmware) { + final core = currentState.imageNumber == 0 ? 'application' : 'network'; + return 'Uploading $core core ${currentState.progress}%'; } + return currentState.stage; } - Widget _localFirmwareInfo(BuildContext context, LocalFirmware firmware) { - return PlatformText('Firmware: ${firmware.name}'); + Widget _successPanel(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: _successGreen.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _successGreen.withValues(alpha: 0.34), + ), + ), + child: Text( + 'Firmware upload complete. The device now verifies the image and ' + 'restarts automatically. This can take up to 3 minutes.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ); } - Widget _remoteFirmwareInfo(BuildContext context, RemoteFirmware firmware) { - return Column( - children: [ - PlatformText('Firmware: ${firmware.name}'), - PlatformText('Url: ${firmware.url}'), - ], + Widget _firmwareInfoCard(BuildContext context, SelectedFirmware firmware) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.38), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.memory_rounded, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + firmware.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + _firmwareSubtitle(firmware), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), ); } + + String _firmwareSubtitle(SelectedFirmware firmware) { + if (firmware is RemoteFirmware) { + return 'Remote firmware • version ${firmware.version}'; + } + if (firmware is LocalFirmware) { + final typeLabel = firmware.type == FirmwareType.multiImage + ? 'Multi-image' + : 'Single-image'; + return 'Local firmware • $typeLabel'; + } + return 'Firmware'; + } } -/// Small stateful widget that starts a 3-minute countdown when built. -/// You can delete this once the global banner shows the timer instead. class _VerificationCountdown extends StatefulWidget { const _VerificationCountdown(); @@ -183,7 +427,6 @@ class _VerificationCountdownState extends State<_VerificationCountdown> { void initState() { super.initState(); _remaining = _total; - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) { timer.cancel(); @@ -208,18 +451,22 @@ class _VerificationCountdownState extends State<_VerificationCountdown> { super.dispose(); } - String _format(Duration d) { - final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); - final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + String _format(Duration duration) { + final m = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$m:$s'; } @override Widget build(BuildContext context) { - return PlatformText( + final colorScheme = Theme.of(context).colorScheme; + + return Text( 'Estimated remaining: ${_format(_remaining)}', - textAlign: TextAlign.start, - style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 16), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), ); } } diff --git a/open_wearable/lib/widgets/global_app_banner_overlay.dart b/open_wearable/lib/widgets/global_app_banner_overlay.dart index 89363155..8282a1ea 100644 --- a/open_wearable/lib/widgets/global_app_banner_overlay.dart +++ b/open_wearable/lib/widgets/global_app_banner_overlay.dart @@ -30,28 +30,28 @@ class GlobalAppBannerOverlay extends StatelessWidget { child, if (hasBanners) Positioned( - top: MediaQuery.of(context).padding.top, + top: 0, left: 0, right: 0, child: SafeArea( bottom: false, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - for (final banner in banners) - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8), - key: banner.key, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 6), + ...banners.map( + (banner) => Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 8), + child: Dismissible( + key: banner.key ?? UniqueKey(), + direction: DismissDirection.up, + onDismissed: (_) => controller.hideBanner(banner), child: banner, ), - ], - ), + ), + ), + ], ), ), ), diff --git a/open_wearable/lib/widgets/home_page.dart b/open_wearable/lib/widgets/home_page.dart index 54bb8ccf..05b32d02 100644 --- a/open_wearable/lib/widgets/home_page.dart +++ b/open_wearable/lib/widgets/home_page.dart @@ -1,114 +1,1534 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/apps/widgets/apps_page.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/devices/devices_page.dart'; -import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_view.dart'; -import 'package:open_wearable/widgets/sensors/values/sensor_values_page.dart'; +import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart'; +import 'package:open_wearable/widgets/recording_activity_indicator.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'sensors/sensor_page.dart'; +import 'sensors/sensor_page_spacing.dart'; + +const int _overviewIndex = 0; +const int _devicesIndex = 1; +const int _sensorsIndex = 2; +const int _sectionCount = 5; + +const double _largeScreenBreakpoint = 960; -/// The home page of the app. -/// -/// The home page contains a tab bar and an AppBar. class HomePage extends StatefulWidget { - const HomePage({super.key}); + final int initialSectionIndex; + + const HomePage({super.key, this.initialSectionIndex = _overviewIndex}); @override State createState() => _HomePageState(); } class _HomePageState extends State { - static final titles = ["Devices", "Sensors", "Apps"]; + late final PlatformTabController _tabController; + late final SensorPageController _sensorPageController; + late final List<_HomeDestination> _destinations; + late final List _sections; + int _selectedIndex = _overviewIndex; + + @override + void initState() { + super.initState(); + + final requestedInitial = widget.initialSectionIndex; + final initialIndex = + (requestedInitial >= _overviewIndex && requestedInitial < _sectionCount) + ? requestedInitial + : _overviewIndex; + _selectedIndex = initialIndex; - List items(BuildContext context) { - return [ - BottomNavigationBarItem( - icon: Icon(Icons.devices), - label: titles[0], + _tabController = PlatformTabController(initialIndex: initialIndex); + _sensorPageController = SensorPageController(); + _tabController.addListener(_syncSelectedIndex); + + _destinations = const [ + _HomeDestination( + title: 'Overview', + icon: Icons.home_outlined, + selectedIcon: Icons.home, + ), + _HomeDestination( + title: 'Devices', + icon: Icons.dashboard_outlined, + selectedIcon: Icons.dashboard, ), - BottomNavigationBarItem( - icon: Icon(Icons.ssid_chart_rounded), - label: titles[1], + _HomeDestination( + title: 'Sensors', + icon: Icons.ssid_chart_outlined, + selectedIcon: Icons.ssid_chart, ), - BottomNavigationBarItem( - icon: Icon(Icons.apps_rounded), - label: titles[2], + _HomeDestination( + title: 'Apps', + icon: Icons.apps_outlined, + selectedIcon: Icons.apps, + ), + _HomeDestination( + title: 'Settings', + icon: Icons.settings_outlined, + selectedIcon: Icons.settings, ), ]; - } - late PlatformTabController _controller; - - late List _tabs; - - @override - void initState() { - super.initState(); - _controller = PlatformTabController(initialIndex: 0); - _tabs = [ - DevicesPage(), - SensorPage(), + _sections = [ + _OverviewPage( + onSectionRequested: _jumpToSection, + onConnectRequested: _openConnectDevices, + onSensorTabRequested: _openSensorsTab, + ), + const DevicesPage(), + SensorPage(controller: _sensorPageController), const AppsPage(), + _SettingsPage( + onLogsRequested: _openLogFiles, + onConnectRequested: _openConnectDevices, + ), ]; } + @override + void dispose() { + _tabController.removeListener(_syncSelectedIndex); + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - return _buildSmallScreenLayout(context); + if (constraints.maxWidth >= _largeScreenBreakpoint) { + return _buildLargeScreenLayout(context); + } + return _buildCompactLayout(context); }, ); } - // ignore: unused_element + Widget _buildCompactLayout(BuildContext context) { + return PlatformTabScaffold( + tabController: _tabController, + items: _destinations + .map( + (destination) => BottomNavigationBarItem( + icon: Icon(destination.icon), + activeIcon: Icon(destination.selectedIcon), + label: destination.title, + ), + ) + .toList(), + bodyBuilder: (context, index) => IndexedStack( + index: index, + children: _sections, + ), + ); + } + Widget _buildLargeScreenLayout(BuildContext context) { + final bool useExtendedRail = MediaQuery.of(context).size.width >= 1280; + + return PlatformScaffold( + body: SafeArea( + child: Row( + children: [ + NavigationRail( + selectedIndex: _selectedIndex, + onDestinationSelected: (index) => _selectSection(context, index), + labelType: NavigationRailLabelType.all, + extended: useExtendedRail, + leading: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: useExtendedRail + ? Text( + 'OpenWearable', + style: Theme.of(context).textTheme.titleMedium, + ) + : Icon( + Icons.watch, + color: Theme.of(context).colorScheme.primary, + ), + ), + destinations: _destinations + .map( + (destination) => NavigationRailDestination( + icon: Icon(destination.icon), + selectedIcon: Icon(destination.selectedIcon), + label: Text(destination.title), + ), + ) + .toList(), + ), + const VerticalDivider(width: 1), + Expanded( + child: IndexedStack( + index: _selectedIndex, + children: _sections, + ), + ), + ], + ), + ), + ); + } + + void _syncSelectedIndex() { + if (!mounted) return; + final int controllerIndex = _tabController.index(context); + if (_selectedIndex != controllerIndex) { + setState(() { + _selectedIndex = controllerIndex; + }); + } + } + + void _jumpToSection(int index) { + if (!mounted) return; + _selectSection(context, index); + } + + void _selectSection(BuildContext context, int index) { + if (index < 0 || index >= _sections.length) return; + + if (_selectedIndex != index) { + setState(() { + _selectedIndex = index; + }); + } + _tabController.setIndex(context, index); + } + + void _openConnectDevices() { + if (!mounted) return; + context.push('/connect-devices'); + } + + void _openSensorsTab(int tabIndex) { + if (!mounted) return; + _selectSection(context, _sensorsIndex); + _sensorPageController.openTab(tabIndex); + } + + void _openLogFiles() { + if (!mounted) return; + context.push('/log-files'); + } +} + +class _OverviewPage extends StatelessWidget { + final void Function(int index) onSectionRequested; + final VoidCallback onConnectRequested; + final void Function(int tabIndex) onSensorTabRequested; + + const _OverviewPage({ + required this.onSectionRequested, + required this.onConnectRequested, + required this.onSensorTabRequested, + }); + + @override + Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText("OpenWearable"), + title: const Text('Overview'), + trailingActions: [ + const AppBarRecordingIndicator(), + PlatformIconButton( + icon: Icon(context.platformIcons.bluetooth), + onPressed: onConnectRequested, + ), + ], + ), + body: Consumer2( + builder: (context, wearablesProvider, recorderProvider, _) { + final wearables = wearablesProvider.wearables; + final connectedCount = wearables.length; + final isRecording = recorderProvider.isRecording; + final hasSensorStreams = recorderProvider.hasSensorsConnected; + final recordingStart = recorderProvider.recordingStart; + + return ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + _OverviewHeroCard( + wearables: wearables, + connectedCount: connectedCount, + isRecording: isRecording, + hasSensorStreams: hasSensorStreams, + recordingStart: recordingStart, + onWearableTap: (wearable) => + _openDeviceFromOverview(context, wearable), + ), + const SizedBox(height: SensorPageSpacing.sectionGap), + _OverviewWorkflowIntroCard( + onConnectRequested: onConnectRequested, + onSensorTabRequested: onSensorTabRequested, + ), + ], + ); + }, + ), + ); + } + + static String formatRecordingTime(DateTime? time) { + if (time == null) return 'Recording active'; + final local = time.toLocal(); + String twoDigits(int n) => n.toString().padLeft(2, '0'); + return 'Recording since ${twoDigits(local.hour)}:${twoDigits(local.minute)}'; + } + + void _openDeviceFromOverview(BuildContext context, Wearable wearable) { + onSectionRequested(_devicesIndex); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) { + return; + } + + final isLargeScreen = MediaQuery.of(context).size.width > 600; + if (isLargeScreen) { + showGeneralDialog( + context: context, + pageBuilder: (dialogContext, animation1, animation2) { + return Center( + child: SizedBox( + width: MediaQuery.of(dialogContext).size.width * 0.5, + height: MediaQuery.of(dialogContext).size.height * 0.5, + child: DeviceDetailPage(device: wearable), + ), + ); + }, + ); + return; + } + context.push('/device-detail', extra: wearable); + }); + } +} + +class _OverviewWorkflowIntroCard extends StatelessWidget { + final VoidCallback onConnectRequested; + final void Function(int tabIndex) onSensorTabRequested; + + const _OverviewWorkflowIntroCard({ + required this.onConnectRequested, + required this.onSensorTabRequested, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'How the OpenWearables App Works', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Typical workflow: connect devices, configure sensors, validate signal quality, then record.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + _OverviewWorkflowStep( + icon: Icons.bluetooth_connected, + title: 'Connect devices', + detail: 'Pair wearables and confirm connection.', + sectionLabel: 'Devices › Connect', + isLast: false, + onTap: onConnectRequested, + ), + _OverviewWorkflowStep( + icon: Icons.tune_outlined, + title: 'Configure sensors', + detail: 'Set required channels and sampling.', + sectionLabel: 'Sensors › Configure', + isLast: false, + onTap: () => onSensorTabRequested(0), + ), + _OverviewWorkflowStep( + icon: Icons.ssid_chart_outlined, + title: 'View sensor data', + detail: 'Check live signal quality before capture.', + sectionLabel: 'Sensors › Live Data', + isLast: false, + onTap: () => onSensorTabRequested(1), + ), + _OverviewWorkflowStep( + icon: Icons.fiber_smart_record, + title: 'Record', + detail: 'Start and monitor recording.', + sectionLabel: 'Sensors › Recorder', + isLast: true, + onTap: () => onSensorTabRequested(2), + ), + ], + ), + ), + ); + } +} + +class _OverviewWorkflowStep extends StatelessWidget { + final IconData icon; + final String title; + final String detail; + final String sectionLabel; + final bool isLast; + final VoidCallback onTap; + + const _OverviewWorkflowStep({ + required this.icon, + required this.title, + required this.detail, + required this.sectionLabel, + required this.isLast, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final markerFill = colorScheme.surfaceContainerHighest; + final markerBorder = colorScheme.outlineVariant.withValues(alpha: 0.7); + final timelineColor = colorScheme.outlineVariant.withValues(alpha: 0.65); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 28, + child: Column( + children: [ + Container( + height: 24, + width: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: markerFill, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: markerBorder), + ), + child: Icon( + icon, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + ), + if (!isLast) + Expanded( + child: Container( + width: 2, + margin: const EdgeInsets.only(top: 6), + decoration: BoxDecoration( + color: timelineColor, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Material( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + detail, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + sectionLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + Container( + margin: const EdgeInsets.only(left: 8), + height: 30, + width: 30, + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Icon( + Icons.arrow_forward_rounded, + size: 18, + color: colorScheme.primary.withValues(alpha: 0.9), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), ), - body: Padding( - padding: const EdgeInsets.all(10), - child: ListView( + ); + } +} + +class _OverviewHeroCard extends StatelessWidget { + final List wearables; + final int connectedCount; + final bool isRecording; + final bool hasSensorStreams; + final DateTime? recordingStart; + final void Function(Wearable wearable) onWearableTap; + + const _OverviewHeroCard({ + required this.wearables, + required this.connectedCount, + required this.isRecording, + required this.hasSensorStreams, + required this.recordingStart, + required this.onWearableTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isReady = connectedCount > 0 && hasSensorStreams && !isRecording; + + final statusLabel = isRecording + ? 'RECORDING' + : isReady + ? 'READY' + : connectedCount == 0 + ? 'DISCONNECTED' + : 'SETUP'; + final statusColor = isRecording + ? colorScheme.error + : isReady + ? const Color(0xFF2E7D32) + : colorScheme.onSurfaceVariant.withValues(alpha: 0.95); + final statusBackground = statusColor.withValues(alpha: 0.15); + final statusBorder = statusColor.withValues(alpha: 0.35); + + final title = isRecording + ? 'Recording in progress' + : isReady + ? 'Ready for capture' + : connectedCount == 0 + ? 'No devices connected' + : 'Setup required'; + final subtitle = isRecording + ? _OverviewPage.formatRecordingTime(recordingStart) + : connectedCount > 0 + ? 'You can start streaming and recording data.' + : 'Pair at least one wearable to begin.'; + + final visibleWearables = wearables.take(5).toList(growable: false); + final hiddenWearablesCount = wearables.length - visibleWearables.length; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - PlatformText( - "Connected Devices", - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(color: Theme.of(context).colorScheme.surfaceTint), - ), - DevicesPage(), - PlatformText( - "Sensor Configuration", - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(color: Theme.of(context).colorScheme.surfaceTint), - ), - SensorConfigurationView(), - PlatformText( - "Sensor Values", - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(color: Theme.of(context).colorScheme.surfaceTint), - ), - SensorValuesPage(), + Row( + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: statusBackground, + border: Border.all(color: statusBorder), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + statusLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + if (isRecording) + const RecordingActivityIndicator( + size: 8, + showIdleOutline: false, + padding: EdgeInsets.zero, + ) + else + Container( + height: 8, + width: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: statusColor, + ), + ), + const SizedBox(width: 8), + Text( + 'Connected Devices ($connectedCount)', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 8), + if (connectedCount == 0) + Text( + 'No devices connected.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ) + else + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final wearable in visibleWearables) + _ConnectedWearablePill.device( + wearable: wearable, + onWearableTap: onWearableTap, + ), + if (hiddenWearablesCount > 0) + _ConnectedWearablePill.summary( + summaryLabel: '+$hiddenWearablesCount more', + ), + ], + ), ], ), ), ); } +} - Widget _buildSmallScreenLayout(BuildContext context) { - return PlatformTabScaffold( - tabController: _controller, - bodyBuilder: (context, index) => IndexedStack( - index: index, - children: _tabs, +class _ConnectedWearablePill extends StatefulWidget { + final Wearable? wearable; + final String? summaryLabel; + final void Function(Wearable wearable)? onWearableTap; + + const _ConnectedWearablePill.device({ + required this.wearable, + required this.onWearableTap, + }) : summaryLabel = null; + + const _ConnectedWearablePill.summary({ + required this.summaryLabel, + }) : wearable = null, + onWearableTap = null; + + String get label => wearable?.name ?? summaryLabel ?? ''; + + @override + State<_ConnectedWearablePill> createState() => _ConnectedWearablePillState(); +} + +class _ConnectedWearablePillState extends State<_ConnectedWearablePill> { + Future? _positionFuture; + + @override + void initState() { + super.initState(); + _positionFuture = _buildPositionFuture(widget.wearable); + } + + @override + void didUpdateWidget(covariant _ConnectedWearablePill oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.wearable != widget.wearable) { + _positionFuture = _buildPositionFuture(widget.wearable); + } + } + + Future? _buildPositionFuture(Wearable? wearable) { + if (wearable == null || !wearable.hasCapability()) { + return null; + } + return wearable.requireCapability().position; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + Widget buildPill(String? sideLabel) { + final pill = Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (sideLabel != null) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.24), + ), + ), + child: Text( + sideLabel, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ], + ), + ); + + final wearable = widget.wearable; + if (wearable == null || widget.onWearableTap == null) { + return pill; + } + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () => widget.onWearableTap!(wearable), + child: pill, + ), + ); + } + + if (_positionFuture == null) { + return buildPill(null); + } + + return FutureBuilder( + future: _positionFuture, + builder: (context, snapshot) { + final sideLabel = switch (snapshot.data) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + return buildPill(sideLabel); + }, + ); + } +} + +class _SettingsPage extends StatelessWidget { + final VoidCallback onLogsRequested; + final VoidCallback onConnectRequested; + + const _SettingsPage({ + required this.onLogsRequested, + required this.onConnectRequested, + }); + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Settings'), + trailingActions: [ + const AppBarRecordingIndicator(), + PlatformIconButton( + icon: Icon(context.platformIcons.bluetooth), + onPressed: onConnectRequested, + ), + ], + ), + body: ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + _QuickActionTile( + icon: Icons.hub, + title: 'Connectors', + subtitle: 'External connector integrations\n(coming soon)', + enabled: false, + ), + _QuickActionTile( + icon: Icons.receipt_long, + title: 'Log files', + subtitle: 'View, share, and remove diagnostic logs', + onTap: onLogsRequested, + ), + _QuickActionTile( + icon: Icons.info_outline_rounded, + title: 'About', + subtitle: 'App information, version, and licenses', + onTap: () => Navigator.push( + context, + platformPageRoute( + context: context, + builder: (_) => const _AboutPage(), + ), + ), + ), + ], + ), + ); + } +} + +class _AboutPage extends StatelessWidget { + const _AboutPage(); + + static final Uri _repoUri = Uri.parse('https://github.com/OpenEarable/app'); + static final Uri _tecoUri = Uri.parse('https://teco.edu'); + static final Uri _openWearablesUri = Uri.parse('https://openwearables.com'); + static const String _aboutAttribution = + 'The OpenWearables App is developed and maintained by the TECO research group at the Karlsruhe Institute of Technology and OpenWearables GmbH.'; + + Future _openExternalUrl( + BuildContext context, { + required Uri uri, + required String label, + }) async { + final opened = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + if (opened || !context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not open $label.'), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('About'), + ), + body: ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset( + 'android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png', + width: 44, + height: 44, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'OpenWearables App', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + _aboutAttribution, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + Text.rich( + TextSpan( + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + children: [ + const TextSpan(text: 'Made with'), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + ), + child: Icon( + Icons.favorite, + size: 15, + color: colorScheme.primary, + ), + ), + ), + const TextSpan(text: 'in Karlsruhe, Germany.'), + ], + ), + ), + const SizedBox(height: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AboutExternalLink( + icon: Icons.code_rounded, + title: 'Source Code', + urlText: 'github.com/OpenEarable/app', + onTap: () => _openExternalUrl( + context, + uri: _repoUri, + label: 'GitHub repository', + ), + ), + const SizedBox(height: 6), + _AboutExternalLink( + icon: Icons.school_outlined, + title: 'TECO Research Group', + urlText: 'teco.edu', + onTap: () => _openExternalUrl( + context, + uri: _tecoUri, + label: 'teco.edu', + ), + ), + const SizedBox(height: 6), + _AboutExternalLink( + icon: Icons.language_rounded, + title: 'OpenWearables GmbH', + urlText: 'openwearables.com', + trailing: const _OpenWearablesFloatingBadge(), + onTap: () => _openExternalUrl( + context, + uri: _openWearablesUri, + label: 'openwearables.com', + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(999), + ), + alignment: Alignment.center, + child: Icon( + Icons.verified_user_outlined, + size: 18, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Privacy & Data Protection', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Designed for transparency and control.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + const _PrivacyChecklistItem( + text: 'Only data required for app features is processed.', + ), + const SizedBox(height: 8), + const _PrivacyChecklistItem( + text: 'Recorded data stays on your device by default.', + ), + const SizedBox(height: 8), + const _PrivacyChecklistItem( + text: + 'Export and sharing happen only when you explicitly choose it.', + ), + const SizedBox(height: 8), + const _PrivacyChecklistItem( + text: + 'Diagnostic logs are shared only through manual user action.', + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Card( + child: ListTile( + leading: const Icon(Icons.description_outlined), + title: const Text('Open source licenses'), + subtitle: const Text('View third-party software licenses'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.push( + context, + platformPageRoute( + context: context, + builder: (_) => const _OpenSourceLicensesPage(), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _OpenSourceLicensesPage extends StatefulWidget { + const _OpenSourceLicensesPage(); + + @override + State<_OpenSourceLicensesPage> createState() => + _OpenSourceLicensesPageState(); +} + +class _OpenSourceLicensesPageState extends State<_OpenSourceLicensesPage> { + late final Future> _licensesFuture = + _loadLicenses(); + + Future> _loadLicenses() async { + final byPackage = >{}; + + await for (final entry in LicenseRegistry.licenses) { + final licenseText = entry.paragraphs.map((p) => p.text).join('\n').trim(); + if (licenseText.isEmpty) { + continue; + } + + for (final package in entry.packages) { + byPackage.putIfAbsent(package, () => {}).add(licenseText); + } + } + + final items = byPackage.entries + .map( + (entry) => _PackageLicenseEntry( + packageName: entry.key, + licenseTexts: entry.value.toList(growable: false), + ), + ) + .toList() + ..sort( + (a, b) => a.packageName.toLowerCase().compareTo( + b.packageName.toLowerCase(), + ), + ); + + return items; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Open source licenses'), + ), + body: FutureBuilder>( + future: _licensesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Text( + 'Unable to load licenses.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + final licenses = snapshot.data ?? const <_PackageLicenseEntry>[]; + + return ListView( + padding: SensorPageSpacing.pagePadding, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Why this list exists', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'The OpenWearables App uses third-party open source software. ' + 'This list provides the required license notices and ' + 'credits for those dependencies.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + if (licenses.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Text( + 'No licenses found.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ) + else + for (final item in licenses) ...[ + Card( + margin: const EdgeInsets.only(bottom: 8), + child: Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 2, + ), + childrenPadding: const EdgeInsets.fromLTRB( + 14, + 0, + 14, + 12, + ), + shape: const RoundedRectangleBorder( + side: BorderSide.none, + ), + collapsedShape: const RoundedRectangleBorder( + side: BorderSide.none, + ), + title: Text( + item.packageName, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text( + '${item.licenseTexts.length} license text${item.licenseTexts.length == 1 ? '' : 's'}', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + children: [ + for (var i = 0; + i < item.licenseTexts.length; + i++) ...[ + SelectableText( + item.licenseTexts[i], + style: theme.textTheme.bodySmall, + ), + if (i < item.licenseTexts.length - 1) ...[ + const SizedBox(height: 10), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues( + alpha: 0.55, + ), + ), + const SizedBox(height: 10), + ], + ], + ], + ), + ), + ), + ], + ], + ); + }, + ), + ); + } +} + +class _PackageLicenseEntry { + final String packageName; + final List licenseTexts; + + const _PackageLicenseEntry({ + required this.packageName, + required this.licenseTexts, + }); +} + +class _AboutExternalLink extends StatelessWidget { + final IconData icon; + final String title; + final String urlText; + final Widget? trailing; + final VoidCallback onTap; + + const _AboutExternalLink({ + required this.icon, + required this.title, + required this.urlText, + this.trailing, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + child: SizedBox( + width: double.infinity, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + ), + Text( + urlText, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ], + ], + ), + ), + ), + ); + } +} + +class _OpenWearablesFloatingBadge extends StatelessWidget { + const _OpenWearablesFloatingBadge(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final badgeBorderRadius = BorderRadius.circular(999); + return ClipRRect( + borderRadius: badgeBorderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: Container( + padding: const EdgeInsets.fromLTRB( + 5, + 5, + 9, + 5, + ), + decoration: BoxDecoration( + color: const Color.fromRGBO(69, 69, 69, 0.40), + borderRadius: badgeBorderRadius, + border: Border.all( + color: Colors.white.withValues(alpha: 0.22), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: const Color(0xFF2FB26F), + shape: BoxShape.circle, + border: Border.all( + color: const Color(0xFF5ED394), + ), + ), + alignment: Alignment.center, + child: const Icon( + Icons.check_rounded, + size: 10, + color: Colors.white, + ), + ), + const SizedBox(width: 7), + Text( + 'OpenWearables', + style: theme.textTheme.labelSmall?.copyWith( + fontSize: theme.textTheme.labelSmall?.fontSize ?? 11, + color: Colors.white, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ], + ), + ), ), - items: items(context), ); } } + +class _PrivacyChecklistItem extends StatelessWidget { + final String text; + + const _PrivacyChecklistItem({required this.text}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + const checkColor = Color(0xFF2E7D32); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 20, + height: 20, + alignment: Alignment.center, + child: const Icon( + Icons.check_circle_rounded, + size: 18, + color: checkColor, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.25, + ), + ), + ), + ], + ); + } +} + +class _QuickActionTile extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback? onTap; + final bool enabled; + + const _QuickActionTile({ + required this.icon, + required this.title, + required this.subtitle, + this.onTap, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + final iconColor = enabled ? null : Theme.of(context).disabledColor; + final textColor = enabled ? null : Theme.of(context).disabledColor; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + enabled: enabled, + leading: Icon(icon, color: iconColor), + title: Text( + title, + style: textColor == null ? null : TextStyle(color: textColor), + ), + subtitle: Text( + subtitle, + style: textColor == null ? null : TextStyle(color: textColor), + ), + trailing: enabled + ? const Icon(Icons.chevron_right) + : Icon(Icons.schedule, color: iconColor), + onTap: enabled ? onTap : null, + ), + ); + } +} + +class _HomeDestination { + final String title; + final IconData icon; + final IconData selectedIcon; + + const _HomeDestination({ + required this.title, + required this.icon, + required this.selectedIcon, + }); +} diff --git a/open_wearable/lib/widgets/recording_activity_indicator.dart b/open_wearable/lib/widgets/recording_activity_indicator.dart new file mode 100644 index 00000000..0f7f40e1 --- /dev/null +++ b/open_wearable/lib/widgets/recording_activity_indicator.dart @@ -0,0 +1,104 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../view_models/sensor_recorder_provider.dart'; + +/// Shared pulse ticker so every recording indicator stays in sync. +class _RecordingPulseTicker { + _RecordingPulseTicker._(); + + static const int _periodMs = 900; + static const Duration tick = Duration(milliseconds: 40); + static final Stream stream = + Stream.periodic(tick, (_) => DateTime.now()) + .asBroadcastStream(); + + static double opacityAt(DateTime now, DateTime origin) { + final elapsedMs = now.difference(origin).inMilliseconds; + final normalized = (elapsedMs % _periodMs) / _periodMs; + final wave = 0.5 - 0.5 * math.cos(2 * math.pi * normalized); + return 0.35 + (0.65 * wave); + } +} + +/// Animated status dot that pulses while sensor recording is active. +class RecordingActivityIndicator extends StatelessWidget { + const RecordingActivityIndicator({ + super.key, + this.size = 16, + this.showIdleOutline = true, + this.padding = const EdgeInsets.symmetric(horizontal: 2), + }); + + final double size; + final bool showIdleOutline; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final isRecording = context.select( + (provider) => provider.isRecording, + ); + final recordingStart = context.select( + (provider) => provider.recordingStart, + ); + + final colorScheme = Theme.of(context).colorScheme; + final color = isRecording + ? colorScheme.error + : colorScheme.onSurfaceVariant.withValues(alpha: 0.85); + final iconData = isRecording || !showIdleOutline + ? Icons.fiber_manual_record + : Icons.fiber_manual_record_outlined; + final icon = Icon( + iconData, + size: size, + color: color, + ); + + if (!isRecording) { + return Padding( + padding: padding, + child: icon, + ); + } + + final anchor = recordingStart ?? DateTime.now(); + return Padding( + padding: padding, + child: StreamBuilder( + stream: _RecordingPulseTicker.stream, + initialData: DateTime.now(), + builder: (context, snapshot) { + final now = snapshot.data ?? DateTime.now(); + final opacity = _RecordingPulseTicker.opacityAt(now, anchor); + return Opacity( + opacity: opacity, + child: icon, + ); + }, + ), + ); + } +} + +class AppBarRecordingIndicator extends StatelessWidget { + const AppBarRecordingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + final isRecording = context.select( + (provider) => provider.isRecording, + ); + if (!isRecording) { + return const SizedBox.shrink(); + } + return const RecordingActivityIndicator( + size: 16, + showIdleOutline: false, + padding: EdgeInsets.only(right: 4), + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart b/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart index 2b3476cc..14dbfe33 100644 --- a/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart @@ -33,12 +33,30 @@ class _RecorderPrefixRowState extends State { context: context, builder: (context) => PlatformAlertDialog( title: PlatformText('Set Recording Prefix'), - content: PlatformTextField( - controller: controller, - autofocus: true, - material: (_, __) => MaterialTextFieldData( - decoration: const InputDecoration(hintText: 'Prefix'), - ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'This prefix is placed before the current time on the device when recordings are created.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + Text( + 'Format: + ', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10), + PlatformTextField( + controller: controller, + autofocus: true, + material: (_, __) => MaterialTextFieldData( + decoration: const InputDecoration(hintText: 'Prefix'), + ), + ), + ], ), actions: [ PlatformDialogAction( @@ -52,9 +70,13 @@ class _RecorderPrefixRowState extends State { ], ), ); + controller.dispose(); if (result == true) { await widget.manager.setFilePrefix(controller.text.trim()); + if (!mounted) { + return; + } setState(_loadPrefix); } } @@ -65,15 +87,25 @@ class _RecorderPrefixRowState extends State { future: _prefixFuture, builder: (context, snapshot) { final isDone = snapshot.connectionState == ConnectionState.done; - final prefix = snapshot.data ?? ''; + final rawPrefix = snapshot.data ?? ''; + final prefix = rawPrefix.trim(); + final hasPrefix = prefix.isNotEmpty; return PlatformListTile( title: PlatformText('On-Device Filename Prefix'), + subtitle: Text( + hasPrefix + ? 'Used as: "$prefix" + ' + : 'Used as: + ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), trailing: isDone ? Row( mainAxisSize: MainAxisSize.min, children: [ - PlatformText(prefix), + PlatformText(hasPrefix ? prefix : '(empty)'), const SizedBox(width: 8), GestureDetector( onTap: () => _showEditDialog(prefix), diff --git a/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart b/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart index 6c8a091e..eb808e0a 100644 --- a/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart @@ -2,12 +2,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; +import '../../../models/logger.dart'; import '../../../view_models/sensor_configuration_provider.dart'; import '../../../view_models/sensor_configuration_storage.dart'; -import '../../../models/logger.dart'; class SaveConfigRow extends StatefulWidget { - const SaveConfigRow({super.key}); + final String storageScope; + final String? defaultName; + final VoidCallback? onSaved; + + const SaveConfigRow({ + super.key, + required this.storageScope, + this.defaultName, + this.onSaved, + }); @override State createState() => _SaveConfigRowState(); @@ -15,59 +24,168 @@ class SaveConfigRow extends StatefulWidget { class _SaveConfigRowState extends State { String _configName = ''; + bool _isSaving = false; + late final TextEditingController _nameController; + + @override + void initState() { + super.initState(); + _configName = widget.defaultName?.trim() ?? ''; + _nameController = TextEditingController(text: _configName); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return PlatformListTile( - title: PlatformTextField( - onChanged: (value) { - setState(() { - _configName = value; - }); - }, - onSubmitted: (value) async { - setState(() { - _configName = value.trim(); - }); - }, - onTapOutside: (event) => FocusScope.of(context).unfocus(), - hintText: "Save as...", - ), - trailing: PlatformElevatedButton( - onPressed: () async { - SensorConfigurationProvider provider = - Provider.of(context, listen: false); - Map config = provider.toJson(); - - logger.d("Saving configuration: $_configName with data: $config"); - - if (_configName.isNotEmpty) { - await SensorConfigurationStorage.saveConfiguration( - _configName.trim(), - config, - ); - } else { - showPlatformDialog( - context: context, - builder: (context) { - return PlatformAlertDialog( - title: PlatformText("Configuration Name Required"), - content: PlatformText( - "Please enter a name for the configuration.", - ), - actions: [ - PlatformDialogAction( - child: PlatformText("OK"), - onPressed: () => Navigator.of(context).pop(), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: PlatformTextField( + controller: _nameController, + onChanged: (value) { + setState(() { + _configName = value; + }); + }, + onTapOutside: (_) => FocusScope.of(context).unfocus(), + hintText: 'Profile name', + ), + ), + const SizedBox(width: 12), + _isSaving + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : PlatformElevatedButton( + onPressed: _saveConfiguration, + child: const Text('Save Profile'), ), - ], - ); - }, - ); - } - }, - child: PlatformText("Save"), + ], + ), + const SizedBox(height: 8), + Text( + 'Save current settings as a reusable profile for this device.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), ); } + + Future _saveConfiguration() async { + final String profileName = _configName.trim(); + if (profileName.isEmpty) { + await _showInfoDialog( + title: 'Profile name required', + message: 'Enter a profile name before saving.', + ); + return; + } + + setState(() { + _isSaving = true; + }); + + try { + final SensorConfigurationProvider provider = + Provider.of(context, listen: false); + final Map config = provider.toJson(); + final String storageKey = SensorConfigurationStorage.buildScopedKey( + scope: widget.storageScope, + name: profileName, + ); + + final existingKeys = + await SensorConfigurationStorage.listConfigurationKeys(); + if (existingKeys.contains(storageKey)) { + final shouldOverwrite = await _confirmOverwrite(profileName); + if (!shouldOverwrite) return; + } + + logger.d('Saving sensor profile "$profileName" to "$storageKey".'); + await SensorConfigurationStorage.saveConfiguration(storageKey, config); + + if (!mounted) return; + FocusScope.of(context).unfocus(); + _showToast('Saved profile "$profileName".'); + widget.onSaved?.call(); + } catch (e) { + logger.e('Failed to save sensor profile: $e'); + if (!mounted) return; + await _showInfoDialog( + title: 'Save failed', + message: 'Could not save this profile. Please try again.', + ); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Future _confirmOverwrite(String profileName) async { + final bool? confirmed = await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Overwrite profile?'), + content: Text( + 'A profile named "$profileName" already exists for this device.', + ), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), + ), + PlatformDialogAction( + child: const Text('Overwrite'), + onPressed: () => Navigator.of(dialogContext).pop(true), + ), + ], + ), + ); + + return confirmed ?? false; + } + + Future _showInfoDialog({ + required String title, + required String message, + }) async { + await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: Text(title), + content: Text(message), + actions: [ + PlatformDialogAction( + child: const Text('OK'), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + ], + ), + ); + } + + void _showToast(String message) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar(content: Text(message)), + ); + } } 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 608424ab..7e6260e0 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,91 +1,297 @@ import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; import 'package:provider/provider.dart'; import 'sensor_config_option_icon_factory.dart'; -class SensorConfigurationDetailView extends StatefulWidget { +class SensorConfigurationDetailView extends StatelessWidget { final SensorConfiguration sensorConfiguration; const SensorConfigurationDetailView({ super.key, required this.sensorConfiguration, }); - - @override - State createState() { - return _SensorConfigurationDetailViewState(); - } -} - -class _SensorConfigurationDetailViewState extends State { - SensorConfigurationValue? _selectedValue; @override Widget build(BuildContext context) { - SensorConfigurationProvider sensorConfigNotifier = Provider.of(context); - _selectedValue = sensorConfigNotifier.getSelectedConfigurationValue(widget.sensorConfiguration); + final sensorConfigNotifier = context.watch(); + final selectedValue = + sensorConfigNotifier.getSelectedConfigurationValue(sensorConfiguration); + final selectableValues = sensorConfigNotifier + .getSensorConfigurationValues(sensorConfiguration, distinct: true) + .where((value) => _isVisibleValue(value, selectedValue)) + .toList(growable: false); + final dropdownSelection = + _resolveSelection(selectableValues, selectedValue); + final colorScheme = Theme.of(context).colorScheme; + final targetOptions = sensorConfiguration is ConfigurableSensorConfiguration + ? (sensorConfiguration as ConfigurableSensorConfiguration) + .availableOptions + .toList(growable: false) + : const []; return ListView( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), children: [ - if (widget.sensorConfiguration is ConfigurableSensorConfiguration) - ...(widget.sensorConfiguration as ConfigurableSensorConfiguration).availableOptions.map((option) { - return PlatformListTile( - leading: Icon(getSensorConfigurationOptionIcon(option)), - title: PlatformText(option.name), - trailing: PlatformSwitch( - value: sensorConfigNotifier.getSelectedConfigurationOptions(widget.sensorConfiguration).contains(option), + if (targetOptions.isNotEmpty) ...[ + Text( + 'Data Targets', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + 'Select where this sensor output is sent.', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Column( + children: [ + for (var i = 0; i < targetOptions.length; i++) ...[ + _OptionToggleTile( + option: targetOptions[i], + selected: sensorConfigNotifier + .getSelectedConfigurationOptions( + sensorConfiguration, + ) + .contains(targetOptions[i]), + onChanged: (enabled) { + if (enabled) { + sensorConfigNotifier.addSensorConfigurationOption( + sensorConfiguration, + targetOptions[i], + ); + } else { + sensorConfigNotifier.removeSensorConfigurationOption( + sensorConfiguration, + targetOptions[i], + ); + } + }, + ), + if (i < targetOptions.length - 1) const SizedBox(height: 8), + ], + ], + ), + const SizedBox(height: 12), + ], + Text( + 'Sampling Rate', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + 'Set how often this sensor is sampled.', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + selectableValues.isEmpty + ? Text( + 'No sampling rates are available for this sensor.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ) + : DropdownButtonFormField( + initialValue: dropdownSelection, + isExpanded: true, + decoration: InputDecoration( + isDense: true, + filled: false, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.primary.withValues(alpha: 0.6), + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 7, + ), + ), + items: selectableValues + .map( + (value) => DropdownMenuItem( + value: value, + child: Text(_samplingRateLabel(value)), + ), + ) + .toList(growable: false), onChanged: (value) { - if (value) { - sensorConfigNotifier.addSensorConfigurationOption(widget.sensorConfiguration, option); - } else { - sensorConfigNotifier.removeSensorConfigurationOption(widget.sensorConfiguration, option); + if (value == null) { + return; } + sensorConfigNotifier.addSensorConfiguration( + sensorConfiguration, + value, + ); }, ), - ); - }), - PlatformListTile( - leading: Icon(Icons.speed_outlined), - title: PlatformText("Sampling Rate"), - trailing: Material( - child: DropdownButton( - value: sensorConfigNotifier.getSelectedConfigurationValue(widget.sensorConfiguration), - items: sensorConfigNotifier.getSensorConfigurationValues(widget.sensorConfiguration, distinct: true).where( - (value) { - if (value is SensorFrequencyConfigurationValue) { - return value.frequencyHz >= 0.1 - || value.frequencyHz == 0 - || sensorConfigNotifier.getSelectedConfigurationValue(widget.sensorConfiguration) == value; - } - return true; - }, - ).map((value) { - if (value is SensorFrequencyConfigurationValue) { - return DropdownMenuItem( - value: value, - child: PlatformText(value.frequencyHz.toStringAsFixed(2)), - ); - } - return DropdownMenuItem( - value: value, - child: PlatformText(value.key), - ); - }).toList(), - onChanged: (value) { - setState(() { - _selectedValue = value; - }); - if (_selectedValue != null) { - sensorConfigNotifier.addSensorConfiguration(widget.sensorConfiguration, _selectedValue!); - } - }, + ], + ); + } + + bool _isVisibleValue( + SensorConfigurationValue value, + SensorConfigurationValue? selectedValue, + ) { + if (value is! SensorFrequencyConfigurationValue) { + return true; + } + if (value.frequencyHz == 0 || value.frequencyHz >= 0.1) { + return true; + } + if (selectedValue is! SensorFrequencyConfigurationValue) { + return false; + } + return value.frequencyHz == selectedValue.frequencyHz; + } + + SensorConfigurationValue? _resolveSelection( + List values, + SensorConfigurationValue? selected, + ) { + if (selected == null) { + return null; + } + for (final value in values) { + if (_sameValue(value, selected)) { + return value; + } + } + return null; + } + + bool _sameValue(SensorConfigurationValue a, SensorConfigurationValue b) { + if (a.runtimeType != b.runtimeType) { + return false; + } + if (a is SensorFrequencyConfigurationValue && + b is SensorFrequencyConfigurationValue) { + return a.frequencyHz == b.frequencyHz; + } + return a.key == b.key; + } + + String _samplingRateLabel(SensorConfigurationValue value) { + if (value is SensorFrequencyConfigurationValue) { + return '${value.frequencyHz.toStringAsFixed(2)} Hz'; + } + return value.key; + } +} + +class _OptionToggleTile extends StatelessWidget { + final SensorConfigurationOption option; + final bool selected; + final ValueChanged onChanged; + + const _OptionToggleTile({ + required this.option, + required this.selected, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = selected ? colorScheme.primary : colorScheme.onSurface; + final (title, subtitle) = _copyForOption(option); + + return AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: selected + ? colorScheme.primary.withValues(alpha: 0.06) + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: (selected ? colorScheme.primary : colorScheme.outlineVariant) + .withValues(alpha: selected ? 0.35 : 0.25), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + getSensorConfigurationOptionIcon(option), + size: 14, + color: foreground, + ), + const SizedBox(width: 7), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 1), + Text( + subtitle, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.15, + ), + ), + ], + ], ), ), - ), - ], + const SizedBox(width: 8), + Switch.adaptive( + value: selected, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: onChanged, + ), + ], + ), ); } + + (String, String?) _copyForOption(SensorConfigurationOption option) { + if (option is StreamSensorConfigOption) { + return ( + 'Live stream to phone', + 'Send to app via Bluetooth.', + ); + } + if (option is RecordSensorConfigOption) { + return ( + 'Record to SD card', + 'Include this sensor in on-device recordings.', + ); + } + return (option.name, null); + } } diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 78224085..d4536d1a 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 @@ -1,20 +1,30 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_configuration_storage.dart'; +import 'package:open_wearable/widgets/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'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_value_row.dart'; import 'package:provider/provider.dart'; import '../../../view_models/sensor_configuration_provider.dart'; -import '../../devices/device_detail/stereo_pos_label.dart'; -/// A widget that displays a list of sensor configurations for a device. +/// A widget that displays and manages sensor configuration for a single device. class SensorConfigurationDeviceRow extends StatefulWidget { final Wearable device; + final Wearable? pairedDevice; + final String? displayName; + final String? storageScope; - const SensorConfigurationDeviceRow({super.key, required this.device}); + const SensorConfigurationDeviceRow({ + super.key, + required this.device, + this.pairedDevice, + this.displayName, + this.storageScope, + }); @override State createState() => @@ -24,24 +34,24 @@ class SensorConfigurationDeviceRow extends StatefulWidget { class _SensorConfigurationDeviceRowState extends State with SingleTickerProviderStateMixin { - late TabController _tabController; + late final TabController _tabController; List _content = []; + String get _deviceProfileScope => + widget.storageScope ?? 'device_${widget.device.deviceId}'; + @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); - _tabController.addListener(() { - if (!_tabController.indexIsChanging) { - _updateContent(); - } - }); - _content = [PlatformCircularProgressIndicator()]; + _tabController.addListener(_onTabChanged); + _content = const [Center(child: CircularProgressIndicator())]; _updateContent(); } @override void dispose() { + _tabController.removeListener(_onTabChanged); _tabController.dispose(); super.dispose(); } @@ -49,79 +59,122 @@ class _SensorConfigurationDeviceRowState @override Widget build(BuildContext context) { final device = widget.device; + final tabBar = _buildTabBar(context); + final isCombinedPair = widget.pairedDevice != null; + final title = widget.displayName ?? device.name; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - PlatformListTile( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( children: [ - PlatformText( - device.name, - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(fontWeight: FontWeight.bold), + Expanded( + child: Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: PlatformText( + title, + style: + Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isCombinedPair) + const Padding( + padding: EdgeInsets.only(left: 8), + child: _CombinedStereoBadge(), + ) + else if (device.hasCapability()) + Padding( + padding: const EdgeInsets.only(left: 8), + child: StereoPositionBadge( + device: device.requireCapability(), + ), + ), + ], + ), ), - if (device.hasCapability()) - StereoPosLabel(device: device.requireCapability()), + if (tabBar != null) ...[ + const SizedBox(width: 8), + Align( + alignment: Alignment.centerLeft, + widthFactor: 1, + child: tabBar, + ), + ], ], ), - trailing: _buildTabBar(context), ), + if (isCombinedPair) + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: Text( + 'Settings are applied to both paired devices.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), ..._content, ], ), ); } + void _onTabChanged() { + if (!_tabController.indexIsChanging) { + _updateContent(); + } + } + Future _updateContent() async { - final Wearable device = widget.device; + final device = widget.device; if (!device.hasCapability()) { if (!mounted) return; setState(() { _content = [ - Padding( - padding: const EdgeInsets.all(8.0), - child: PlatformText("This device does not support configuring sensors."), + const Padding( + padding: EdgeInsets.all(12), + child: Text('This device does not support sensor configuration.'), ), ]; }); return; } - final SensorConfigurationManager sensorManager = - device.requireCapability(); - if (_tabController.index == 0) { - _buildNewTabContent(device); + _buildSettingsTabContent(device); } else { - await _buildLoadTabContent(sensorManager); + await _buildProfilesTabContent(); } } - void _buildNewTabContent(Wearable device) { - SensorConfigurationManager sensorManager = + void _buildSettingsTabContent(Wearable device) { + final sensorManager = device.requireCapability(); - final List content = sensorManager.sensorConfigurations - .map( - (config) => SensorConfigurationValueRow(sensorConfiguration: config), - ) - .cast() - .toList(); - content.addAll([ - const Divider(), - const SaveConfigRow(), - ]); + final content = [ + ...sensorManager.sensorConfigurations.map( + (config) => SensorConfigurationValueRow( + sensorConfiguration: config, + ), + ), + ]; if (device.hasCapability()) { content.addAll([ - const Divider(), - EdgeRecorderPrefixRow(manager: device.requireCapability()), + const _InsetSectionDivider(), + EdgeRecorderPrefixRow( + manager: device.requireCapability(), + ), ]); } @@ -131,85 +184,528 @@ class _SensorConfigurationDeviceRowState }); } - Future _buildLoadTabContent(SensorConfigurationManager device) async { + Future _buildProfilesTabContent() async { if (!mounted) return; setState(() { - _content = [PlatformCircularProgressIndicator()]; + _content = const [Center(child: CircularProgressIndicator())]; }); - final configKeys = await SensorConfigurationStorage.listConfigurationKeys(); + final allConfigKeys = + await SensorConfigurationStorage.listConfigurationKeys(); + final scopedKeys = allConfigKeys + .where( + (key) => SensorConfigurationStorage.keyMatchesScope( + key, + _deviceProfileScope, + ), + ) + .toList() + ..sort(); + final legacyKeys = allConfigKeys + .where(SensorConfigurationStorage.isLegacyUnscopedKey) + .toList() + ..sort(); + final profileKeys = [...scopedKeys, ...legacyKeys]; if (!mounted) return; - if (configKeys.isEmpty) { - setState(() { - _content = [ - PlatformListTile(title: PlatformText("No configurations found")), - ]; - }); - return; - } + final content = [ + SaveConfigRow( + storageScope: _deviceProfileScope, + onSaved: _refreshProfiles, + ), + const Divider(), + ]; - final widgets = configKeys.map((key) { - return PlatformListTile( - title: PlatformText(key), - onTap: () async { - final config = - await SensorConfigurationStorage.loadConfiguration(key); - if (!mounted) return; - - final result = await Provider.of( - context, - listen: false, - ).restoreFromJson(config); - - if (!result && mounted) { - showPlatformDialog( - context: context, - builder: (_) => PlatformAlertDialog( - title: PlatformText("Error"), - content: PlatformText("Failed to load configuration: $key"), - actions: [ - PlatformDialogAction( - child: PlatformText("OK"), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - return; - } - - _tabController.index = 0; - _updateContent(); - }, - trailing: PlatformIconButton( - icon: Icon(context.platformIcons.delete), - onPressed: () async { - await SensorConfigurationStorage.deleteConfiguration(key); - if (mounted) _updateContent(); - }, + if (profileKeys.isEmpty) { + content.add( + const Padding( + padding: EdgeInsets.all(12), + child: Text( + 'No profiles saved yet. Save current settings above, then tap a profile to load as current.', + ), ), ); - }).toList(); + } else { + content.addAll(profileKeys.map(_buildProfileTile)); + } setState(() { - _content = widgets; + _content = content; }); } + Widget _buildProfileTile(String key) { + final isDeviceScoped = SensorConfigurationStorage.keyMatchesScope( + key, + _deviceProfileScope, + ); + + final title = isDeviceScoped + ? SensorConfigurationStorage.displayNameFromScopedKey( + key, + scope: _deviceProfileScope, + ) + : key; + + return PlatformListTile( + leading: Icon( + isDeviceScoped ? Icons.tune_outlined : Icons.tune, + ), + title: PlatformText(title), + subtitle: PlatformText( + isDeviceScoped ? 'Tap to load as current' : 'Legacy shared profile', + ), + onTap: () => _loadProfile(key: key, title: title), + trailing: PlatformIconButton( + icon: const Icon(Icons.more_horiz), + onPressed: () => _showProfileActions( + key: key, + title: title, + ), + ), + ); + } + Widget? _buildTabBar(BuildContext context) { if (!widget.device.hasCapability()) return null; - return SizedBox( - width: MediaQuery.of(context).size.width * 0.4, - child: TabBar.secondary( - controller: _tabController, - tabs: const [ - Tab(text: 'New'), - Tab(text: 'Load'), + return TabBar.secondary( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + padding: EdgeInsets.zero, + dividerHeight: 1, + labelPadding: const EdgeInsets.symmetric(horizontal: 8), + tabs: const [ + Tab(text: 'Current'), + Tab(text: 'Profiles'), + ], + ); + } + + Future _loadProfile({ + required String key, + required String title, + }) async { + final config = await SensorConfigurationStorage.loadConfiguration(key); + if (!mounted) return; + + final provider = context.read(); + final result = await provider.restoreFromJson(config); + if (!mounted) return; + + if (!result.hasRestoredValues) { + await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Profile error'), + content: Text( + 'No compatible values from "$title" could be restored for this device.', + ), + actions: [ + PlatformDialogAction( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + return; + } + + if (result.skippedCount > 0 || result.unknownConfigCount > 0) { + _showSnackBar( + 'Loaded "$title" (${result.restoredCount} restored, ${result.skippedCount + result.unknownConfigCount} skipped). Tap "Apply Profiles" to push.', + ); + } else { + _showSnackBar( + 'Loaded profile "$title". Tap "Apply Profiles" at the bottom to push to hardware.', + ); + } + + _tabController.index = 0; + _updateContent(); + } + + Future _overwriteProfile({ + required String key, + required String title, + }) async { + final confirmed = await _confirmOverwrite(title); + if (!confirmed) return; + if (!mounted) return; + + final provider = context.read(); + await SensorConfigurationStorage.saveConfiguration(key, provider.toJson()); + if (!mounted) return; + _showSnackBar('Updated profile "$title" with current settings.'); + _updateContent(); + } + + Future _deleteProfile({ + required String key, + required String title, + }) async { + final confirmed = await _confirmDelete(title); + if (!confirmed) return; + + await SensorConfigurationStorage.deleteConfiguration(key); + if (!mounted) return; + _showSnackBar('Deleted profile "$title".'); + _updateContent(); + } + + void _showProfileActions({ + required String key, + required String title, + }) { + showPlatformModalSheet( + context: context, + builder: (sheetContext) => PlatformWidget( + material: (_, __) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('View details'), + onTap: () async { + Navigator.of(sheetContext).pop(); + await _viewProfileDetails(key: key, title: title); + }, + ), + ListTile( + leading: const Icon(Icons.download), + title: const Text('Load'), + onTap: () async { + Navigator.of(sheetContext).pop(); + await _loadProfile(key: key, title: title); + }, + ), + ListTile( + leading: const Icon(Icons.save), + title: const Text('Overwrite with current settings'), + onTap: () async { + Navigator.of(sheetContext).pop(); + await _overwriteProfile(key: key, title: title); + }, + ), + ListTile( + leading: const Icon(Icons.delete), + title: const Text('Delete'), + onTap: () async { + Navigator.of(sheetContext).pop(); + await _deleteProfile(key: key, title: title); + }, + ), + ], + ), + ), + cupertino: (_, __) => CupertinoActionSheet( + title: Text(title), + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _viewProfileDetails(key: key, title: title); + }, + child: const Text('View details'), + ), + CupertinoActionSheetAction( + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _loadProfile(key: key, title: title); + }, + child: const Text('Load'), + ), + CupertinoActionSheetAction( + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _overwriteProfile(key: key, title: title); + }, + child: const Text('Overwrite with current settings'), + ), + CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _deleteProfile(key: key, title: title); + }, + child: const Text('Delete'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(sheetContext).pop(), + child: const Text('Cancel'), + ), + ), + ), + ); + } + + Future _viewProfileDetails({ + required String key, + required String title, + }) async { + final config = await SensorConfigurationStorage.loadConfiguration(key); + if (!mounted) return; + + if (!widget.device.hasCapability()) { + _showSnackBar('Profile details are unavailable for this device.'); + return; + } + + final sensorManager = + widget.device.requireCapability(); + final knownConfigs = { + for (final sensorConfig in sensorManager.sensorConfigurations) + sensorConfig.name: sensorConfig, + }; + + final details = config.entries.map((entry) { + final sensorConfig = knownConfigs[entry.key]; + if (sensorConfig == null) { + return _ProfileDetailEntry( + configName: entry.key, + resolvedValue: 'Configuration not available on this device.', + status: _ProfileDetailStatus.unknownConfiguration, + ); + } + + final matchedValue = sensorConfig.values + .where((value) => value.key == entry.value) + .firstOrNull; + if (matchedValue == null) { + return _ProfileDetailEntry( + configName: entry.key, + resolvedValue: 'Saved value is not available on this firmware.', + status: _ProfileDetailStatus.missingValue, + ); + } + + return _ProfileDetailEntry( + configName: entry.key, + resolvedValue: _describeSensorConfigurationValue(matchedValue), + status: _ProfileDetailStatus.compatible, + ); + }).toList() + ..sort((a, b) => a.configName.compareTo(b.configName)); + + final compatibleCount = details + .where((entry) => entry.status == _ProfileDetailStatus.compatible) + .length; + final mismatchCount = details.length - compatibleCount; + + await showPlatformModalSheet( + context: context, + builder: (sheetContext) => PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Profile details'), + trailingActions: [ + PlatformIconButton( + icon: const Icon(Icons.close_rounded), + padding: EdgeInsets.zero, + onPressed: () => Navigator.of(sheetContext).pop(), + ), + ], + ), + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + '${details.length} saved settings. ' + '$compatibleCount available, ' + '$mismatchCount unavailable on this device.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const Divider(height: 1), + if (details.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Text('This profile has no saved settings.'), + ) + else + ...details.map( + (entry) => PlatformListTile( + leading: Icon( + switch (entry.status) { + _ProfileDetailStatus.compatible => Icons.tune_outlined, + _ProfileDetailStatus.missingValue => + Icons.warning_amber_outlined, + _ProfileDetailStatus.unknownConfiguration => + Icons.help_outline, + }, + ), + title: PlatformText(entry.configName), + subtitle: PlatformText(entry.resolvedValue), + ), + ), + ], + ), + ), + ); + } + + String _describeSensorConfigurationValue(SensorConfigurationValue value) { + final baseValue = value is SensorFrequencyConfigurationValue + ? '${value.frequencyHz.toStringAsFixed(2)} Hz' + : value.key; + + if (value is! ConfigurableSensorConfigurationValue) { + return baseValue; + } + + final optionNames = value.options + .map(_describeSensorConfigurationOption) + .toSet() + .toList() + ..sort(); + + if (optionNames.isEmpty) { + return baseValue; + } + + return '$baseValue (${optionNames.join(', ')})'; + } + + String _describeSensorConfigurationOption(SensorConfigurationOption option) { + if (option is StreamSensorConfigOption) { + return 'Bluetooth'; + } + if (option is RecordSensorConfigOption) { + return 'SD card'; + } + return option.name; + } + + Future _confirmDelete(String title) async { + final bool? confirmed = await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Delete profile?'), + content: Text('Delete "$title" permanently?'), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), + ), + PlatformDialogAction( + child: const Text('Delete'), + onPressed: () => Navigator.of(dialogContext).pop(true), + ), + ], + ), + ); + return confirmed ?? false; + } + + Future _confirmOverwrite(String title) async { + final bool? confirmed = await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Overwrite profile?'), + content: Text( + 'Replace profile "$title" with current settings from this device?', + ), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), + ), + PlatformDialogAction( + child: const Text('Overwrite'), + onPressed: () => Navigator.of(dialogContext).pop(true), + ), ], ), ); + return confirmed ?? false; + } + + void _refreshProfiles() { + _updateContent(); + } + + void _showSnackBar(String message) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar(content: Text(message)), + ); + } +} + +enum _ProfileDetailStatus { + compatible, + missingValue, + unknownConfiguration, +} + +class _ProfileDetailEntry { + final String configName; + final String resolvedValue; + final _ProfileDetailStatus status; + + const _ProfileDetailEntry({ + required this.configName, + required this.resolvedValue, + required this.status, + }); +} + +class _CombinedStereoBadge extends StatelessWidget { + const _CombinedStereoBadge(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foregroundColor = colorScheme.primary; + final backgroundColor = foregroundColor.withValues(alpha: 0.12); + final borderColor = foregroundColor.withValues(alpha: 0.24); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Text( + 'L+R', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ); + } +} + +class _InsetSectionDivider extends StatelessWidget { + const _InsetSectionDivider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Divider( + height: 1, + thickness: 0.6, + color: Theme.of(context).colorScheme.outlineVariant.withValues( + alpha: 0.55, + ), + ), + ); } } 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 ae71111e..8b06631a 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 @@ -7,6 +7,8 @@ import 'package:provider/provider.dart'; import 'sensor_config_option_icon_factory.dart'; +const double _kSensorStatusPillHeight = 22; + /// A row that displays a sensor configuration and allows the user to select a value. /// /// The selected value is added to the [SensorConfigurationProvider]. @@ -20,88 +22,182 @@ class SensorConfigurationValueRow extends StatelessWidget { @override Widget build(BuildContext context) { - final sensorConfigNotifier = - Provider.of(context); - - return PlatformListTile( - onTap: () { - showPlatformModalSheet( - context: context, - builder: (modalContext) { - return ChangeNotifierProvider.value( - value: sensorConfigNotifier, - child: PlatformScaffold( - appBar: PlatformAppBar( - title: PlatformText(sensorConfiguration.name), - leading: IconButton( - icon: Icon(Icons.close), - onPressed: () => Navigator.of(modalContext).pop(), + final sensorConfigNotifier = context.watch(); + final colorScheme = Theme.of(context).colorScheme; + final isOn = _isOn(sensorConfigNotifier, sensorConfiguration); + final selectedValue = + sensorConfigNotifier.getSelectedConfigurationValue(sensorConfiguration); + final selectedOptions = + sensorConfiguration is ConfigurableSensorConfiguration + ? sensorConfigNotifier + .getSelectedConfigurationOptions( + sensorConfiguration, + ) + .toList(growable: false) + : const []; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 2, 12, 2), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => _openConfigurationSheet(context, sensorConfigNotifier), + child: Padding( + padding: const EdgeInsets.fromLTRB(2, 8, 2, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isOn ? 3 : 2, + height: 26, + decoration: BoxDecoration( + color: (isOn + ? colorScheme.primary + : colorScheme.outlineVariant) + .withValues(alpha: isOn ? 0.7 : 0.6), + borderRadius: BorderRadius.circular(999), ), ), - body: SensorConfigurationDetailView( - sensorConfiguration: sensorConfiguration, + const SizedBox(width: 6), + Icon( + isOn ? Icons.sensors_rounded : Icons.sensors_off_rounded, + size: 14, + color: isOn ? colorScheme.primary : colorScheme.outline, ), - ), - ); - }, - ); - }, - title: PlatformText(sensorConfiguration.name), - trailing: _isOn(sensorConfigNotifier, sensorConfiguration) - ? () { - if (sensorConfigNotifier - .getSelectedConfigurationValue(sensorConfiguration) == - null) { - return PlatformText( - "Internal Error", - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, + const SizedBox(width: 7), + Expanded( + child: Text( + sensorConfiguration.name, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (selectedOptions.isNotEmpty) ...[ + const SizedBox(width: 6), + _OptionsCompactBadge( + options: selectedOptions, ), - ); - } - SensorConfigurationValue value = sensorConfigNotifier - .getSelectedConfigurationValue(sensorConfiguration)!; - if (value is SensorFrequencyConfigurationValue) { - SensorFrequencyConfigurationValue freqValue = value; - - return Row( - mainAxisSize: MainAxisSize.min, + ], + const SizedBox(width: 6), + _SamplingRatePill( + label: _statusPillLabel(selectedValue, isOn: isOn), + enabled: isOn, + ), + const SizedBox(width: 2), + Icon( + Icons.chevron_right_rounded, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } + + void _openConfigurationSheet( + BuildContext context, + SensorConfigurationProvider sensorConfigNotifier, + ) { + showPlatformModalSheet( + context: context, + builder: (modalContext) { + return ChangeNotifierProvider.value( + value: sensorConfigNotifier, + child: SafeArea( + child: SizedBox( + height: MediaQuery.of(modalContext).size.height * 0.82, + child: Material( + color: Theme.of(modalContext).colorScheme.surface, + child: Column( children: [ - if (sensorConfiguration is ConfigurableSensorConfiguration) - ...(sensorConfigNotifier.getSelectedConfigurationOptions( - sensorConfiguration, - )).map( - (option) { - return Icon( - getSensorConfigurationOptionIcon(option), - color: Theme.of(context).colorScheme.secondary, - ); - }, + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sensorConfiguration.name, + style: Theme.of(modalContext) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Adjust data targets and sampling rate.', + style: Theme.of(modalContext) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(modalContext) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(modalContext).pop(), + icon: const Icon(Icons.close_rounded, size: 20), + ), + ], ), - PlatformText( - "${freqValue.frequencyHz} Hz", - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, + ), + Expanded( + child: SensorConfigurationDetailView( + sensorConfiguration: sensorConfiguration, ), ), ], - ); - } - - return PlatformText( - value.toString(), - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, ), - ); - }() - : PlatformText( - "Off", - style: TextStyle(color: Theme.of(context).colorScheme.secondary), + ), ), + ), + ); + }, ); } + String _statusPillLabel( + SensorConfigurationValue? value, { + required bool isOn, + }) { + if (!isOn) { + return 'Off'; + } + if (value is SensorFrequencyConfigurationValue) { + return _formatFrequency(value.frequencyHz); + } + return 'On'; + } + + String _formatFrequency(double hz) { + if ((hz - hz.roundToDouble()).abs() < 0.01) { + return '${hz.round()} Hz'; + } + if (hz >= 10) { + return '${hz.toStringAsFixed(1)} Hz'; + } + return '${hz.toStringAsFixed(2)} Hz'; + } + bool _isOn(SensorConfigurationProvider notifier, SensorConfiguration config) { bool isOn = false; if (config is ConfigurableSensorConfiguration) { @@ -118,3 +214,99 @@ class SensorConfigurationValueRow extends StatelessWidget { return isOn; } } + +class _OptionsCompactBadge extends StatelessWidget { + final List options; + + const _OptionsCompactBadge({ + required this.options, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final visibleCount = options.length > 2 ? 2 : options.length; + final remainingCount = options.length - visibleCount; + + return SizedBox( + height: _kSensorStatusPillHeight, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < visibleCount; i++) ...[ + Icon( + getSensorConfigurationOptionIcon(options[i]) ?? + Icons.tune_rounded, + size: 10, + color: colorScheme.onSurfaceVariant, + ), + if (i < visibleCount - 1) const SizedBox(width: 3), + ], + if (remainingCount > 0) ...[ + const SizedBox(width: 4), + Text( + '+$remainingCount', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _SamplingRatePill extends StatelessWidget { + final String label; + final bool enabled; + + const _SamplingRatePill({ + required this.label, + required this.enabled, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = + enabled ? colorScheme.primary : colorScheme.onSurfaceVariant; + + return SizedBox( + height: _kSensorStatusPillHeight, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: foreground.withValues(alpha: 0.42), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 38), + child: Text( + label, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index 2dedc189..e8d10ba2 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -2,15 +2,17 @@ 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/wearables_provider.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_device_row.dart'; import 'package:provider/provider.dart'; import '../../../models/logger.dart'; /// A view that displays the sensor configurations of all connected wearables. -/// +/// /// The specific sensor configurations should be made available via the [SensorConfigurationProvider]. class SensorConfigurationView extends StatelessWidget { final VoidCallback? onSetConfigPressed; @@ -26,140 +28,479 @@ class SensorConfigurationView extends StatelessWidget { ); } - Widget _buildSmallScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { + Widget _buildSmallScreenLayout( + BuildContext context, + WearablesProvider wearablesProvider, + ) { if (wearablesProvider.wearables.isEmpty) { return Center( - child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), + child: PlatformText( + "No devices connected", + style: Theme.of(context).textTheme.titleLarge, + ), ); } - return Padding( - padding: EdgeInsets.all(10), - child: wearablesProvider.wearables.isEmpty - ? Center( - child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), - ) - : ListView( - children: [ - ...wearablesProvider.wearables.map((wearable) { - if (wearable.hasCapability()) { - return ChangeNotifierProvider.value( - value: wearablesProvider.getSensorConfigurationProvider(wearable), - child: SensorConfigurationDeviceRow(device: wearable), - ); - } else { - return SensorConfigurationDeviceRow(device: wearable); - } - }), - _buildThroughputWarningBanner(context), - _buildSetConfigButton( - configProviders: wearablesProvider.wearables - // ignore: prefer_iterable_wheretype - .where((wearable) => wearable.hasCapability()) - .map( - (wearable) => wearablesProvider.getSensorConfigurationProvider(wearable), - ).toList(), - ), - ], + return FutureBuilder>( + future: buildWearableDisplayGroups( + wearablesProvider.wearables, + shouldCombinePair: (left, right) => + wearablesProvider.isStereoPairCombined( + first: left, + second: right, ), + ), + builder: (context, snapshot) { + final groups = _orderGroupsForConfigure( + snapshot.data ?? + wearablesProvider.wearables + .map( + (wearable) => + WearableDisplayGroup.single(wearable: wearable), + ) + .toList(), + ); + final applyTargets = _buildApplyTargets( + groups: groups, + wearablesProvider: wearablesProvider, + ); + final sections = [ + ...groups.map( + (group) => _buildGroupConfigurationRow( + group: group, + wearablesProvider: wearablesProvider, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: _buildApplyConfigButton( + context, + targets: applyTargets, + ), + ), + _buildThroughputWarningBanner(context), + ]; + + return ListView( + padding: SensorPageSpacing.pagePadding, + children: sections, + ); + }, ); } - Widget _buildSetConfigButton({required List configProviders}) { + List _orderGroupsForConfigure( + List groups, + ) { + final indexed = groups.asMap().entries.toList(); + + indexed.sort((a, b) { + final groupA = a.value; + final groupB = b.value; + final sameName = + groupA.displayName.toLowerCase() == groupB.displayName.toLowerCase(); + final bothSingle = !groupA.isCombined && !groupB.isCombined; + if (sameName && bothSingle) { + final sideOrderA = _configureSideOrder(groupA.primaryPosition); + final sideOrderB = _configureSideOrder(groupB.primaryPosition); + final knownSides = sideOrderA <= 1 && sideOrderB <= 1; + if (knownSides && sideOrderA != sideOrderB) { + return sideOrderA.compareTo(sideOrderB); + } + } + + // Preserve existing order for all other rows. + return a.key.compareTo(b.key); + }); + + return indexed.map((entry) => entry.value).toList(); + } + + int _configureSideOrder(DevicePosition? position) { + if (position == DevicePosition.left) { + return 0; + } + if (position == DevicePosition.right) { + return 1; + } + return 2; + } + + Widget _buildGroupConfigurationRow({ + required WearableDisplayGroup group, + required WearablesProvider wearablesProvider, + }) { + final primary = _resolvePrimaryForConfiguration(group); + final secondary = _resolveMirroredDevice(group, primary); + final storageScope = _storageScopeForGroup(group); + final supportsConfig = primary.hasCapability(); + + if (!supportsConfig) { + return SensorConfigurationDeviceRow( + device: primary, + pairedDevice: secondary, + displayName: group.displayName, + storageScope: storageScope, + ); + } + + return ChangeNotifierProvider.value( + value: wearablesProvider.getSensorConfigurationProvider(primary), + child: SensorConfigurationDeviceRow( + device: primary, + pairedDevice: secondary, + displayName: group.displayName, + storageScope: storageScope, + ), + ); + } + + String _storageScopeForGroup(WearableDisplayGroup group) { + if (!group.isCombined) { + return 'device_${group.representative.deviceId}'; + } + + final ids = group.members.map((device) => device.deviceId).toList()..sort(); + return 'stereo_${ids.join('_')}'; + } + + List<_ConfigApplyTarget> _buildApplyTargets({ + required List groups, + required WearablesProvider wearablesProvider, + }) { + final targets = <_ConfigApplyTarget>[]; + for (final group in groups) { + final primary = _resolvePrimaryForConfiguration(group); + if (!primary.hasCapability()) { + continue; + } + + final partner = _resolveMirroredDevice(group, primary); + final mirrorTarget = + partner != null && partner.hasCapability() + ? partner + : null; + + targets.add( + _ConfigApplyTarget( + primaryDevice: primary, + mirroredDevice: mirrorTarget, + provider: wearablesProvider.getSensorConfigurationProvider(primary), + ), + ); + } + return targets; + } + + Wearable _resolvePrimaryForConfiguration(WearableDisplayGroup group) { + if (group.isCombined) { + for (final member in group.members) { + if (member.hasCapability()) { + return member; + } + } + } + return group.representative; + } + + Wearable? _resolveMirroredDevice( + WearableDisplayGroup group, + Wearable primary, + ) { + if (!group.isCombined) { + return null; + } + for (final member in group.members) { + if (member.deviceId != primary.deviceId) { + return member; + } + } + return null; + } + + Widget _buildApplyConfigButton( + BuildContext context, { + required List<_ConfigApplyTarget> targets, + }) { return PlatformElevatedButton( - onPressed: () { - for (SensorConfigurationProvider notifier in configProviders) { - logger.d("Setting sensor configurations for notifier: $notifier"); - notifier.getSelectedConfigurations().forEach((entry) { - SensorConfiguration config = entry.$1; - SensorConfigurationValue value = entry.$2; + onPressed: () async { + if (targets.isEmpty) { + await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: PlatformText('No configurable devices'), + content: PlatformText( + 'Connect a wearable with configurable sensors to apply settings.', + ), + actions: [ + PlatformDialogAction( + child: PlatformText('OK'), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + ], + ), + ); + return; + } + + int appliedCount = 0; + int mirroredCount = 0; + int mirrorSkippedCount = 0; + + for (final target in targets) { + logger.d( + "Setting sensor configurations for ${target.primaryDevice.name}", + ); + + for (final entry in target.provider.getSelectedConfigurations()) { + final SensorConfiguration config = entry.$1; + final SensorConfigurationValue value = entry.$2; config.setConfiguration(value); - }); + appliedCount += 1; + + final mirroredDevice = target.mirroredDevice; + if (mirroredDevice != null) { + final mirrored = _applyConfigurationToDevice( + mirroredDevice: mirroredDevice, + configName: config.name, + valueKey: value.key, + ); + if (mirrored) { + mirroredCount += 1; + } else { + mirrorSkippedCount += 1; + } + } + } + } + + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.hideCurrentSnackBar(); + + final message = StringBuffer( + 'Applied $appliedCount sensor settings to ${targets.length} row(s).', + ); + if (mirroredCount > 0) { + message.write(' Mirrored $mirroredCount settings to paired devices.'); + } + if (mirrorSkippedCount > 0) { + message.write( + ' $mirrorSkippedCount mirrored settings were unavailable on partner firmware.', + ); } + + messenger?.showSnackBar( + SnackBar( + content: Text(message.toString()), + ), + ); + (onSetConfigPressed ?? () {})(); }, - child: PlatformText('Set Sensor Configurations'), + child: PlatformText('Apply Profiles'), ); } Widget _buildThroughputWarningBanner(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Card( - color: Theme.of(context).colorScheme.surfaceContainer, + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), child: Padding( - padding: const EdgeInsets.all(16.0), - child: RichText( - text: TextSpan( - style: Theme.of(context).textTheme.bodyLarge - ?? TextStyle(color: Colors.black, fontSize: 16), - children: [ - const TextSpan( - text: "Info: ", - style: TextStyle(fontWeight: FontWeight.bold), - ), - const TextSpan( - text: "Using too many sensors or setting high sampling rates can exceed the system’s " - "available bandwidth, causing data drops. Limit the number of active sensors and their " - "sampling rates, and record high-rate data directly to the SD card.", - ), - ], - ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.insights_outlined, + size: 20, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Sampling & bandwidth guidance', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'High sensor counts and aggressive sampling rates can exceed bandwidth and cause dropped samples.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 10), + _buildGuidanceItem( + context, + 'Enable only the sensors needed for this session.', + ), + _buildGuidanceItem( + context, + 'Lower sampling rates for non-critical signals.', + ), + _buildGuidanceItem( + context, + 'For high-rate recordings, recording to the on-board memory of the device is preferred (if available).', + ), + ], ), ), ); } + Widget _buildGuidanceItem(BuildContext context, String text) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.check_circle_outline, + size: 16, + color: colorScheme.primary.withValues(alpha: 0.9), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ); + } + + bool _applyConfigurationToDevice({ + required Wearable mirroredDevice, + required String configName, + required String valueKey, + }) { + if (!mirroredDevice.hasCapability()) { + return false; + } + + final manager = + mirroredDevice.requireCapability(); + SensorConfiguration? mirroredConfig; + for (final config in manager.sensorConfigurations) { + if (config.name == configName) { + mirroredConfig = config; + break; + } + } + if (mirroredConfig == null) { + return false; + } + + SensorConfigurationValue? mirroredValue; + for (final value in mirroredConfig.values) { + if (value.key == valueKey) { + mirroredValue = value; + break; + } + } + if (mirroredValue == null) { + return false; + } + + mirroredConfig.setConfiguration(mirroredValue); + return true; + } + // ignore: unused_element - Widget _buildLargeScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { + Widget _buildLargeScreenLayout( + BuildContext context, + WearablesProvider wearablesProvider, + ) { final List devices = wearablesProvider.wearables; - List tiles = _generateTiles(devices, wearablesProvider.sensorConfigurationProviders); + List tiles = + _generateTiles(devices, wearablesProvider.sensorConfigurationProviders); if (tiles.isNotEmpty) { - tiles.addAll([ - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 230.0, - child: _buildThroughputWarningBanner(context), - ), - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 100.0, - child: _buildSetConfigButton( - configProviders: devices.map((device) => wearablesProvider.getSensorConfigurationProvider(device)).toList(), + tiles.addAll( + [ + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 100.0, + child: _buildApplyConfigButton( + context, + targets: devices + .where( + (device) => + device.hasCapability(), + ) + .map( + (device) => _ConfigApplyTarget( + primaryDevice: device, + mirroredDevice: null, + provider: wearablesProvider + .getSensorConfigurationProvider(device), + ), + ) + .toList(), + ), + ), + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 230.0, + child: _buildThroughputWarningBanner(context), ), - ),], + ], ); } return StaggeredGrid.count( - crossAxisCount: (MediaQuery.of(context).size.width / 250).floor().clamp(1, 4), // Adaptive grid - mainAxisSpacing: 10, - crossAxisSpacing: 10, - children: tiles.isNotEmpty ? tiles : [ - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 100.0, - child: Card( - shape: RoundedRectangleBorder( - side: BorderSide( - color: Colors.grey, - width: 1, - style: BorderStyle.solid, - strokeAlign: -1, + crossAxisCount: (MediaQuery.of(context).size.width / 250) + .floor() + .clamp(1, 4), // Adaptive grid + mainAxisSpacing: SensorPageSpacing.gridGap, + crossAxisSpacing: SensorPageSpacing.gridGap, + children: tiles.isNotEmpty + ? tiles + : [ + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 100.0, + child: Card( + shape: RoundedRectangleBorder( + side: BorderSide( + color: Colors.grey, + width: 1, + style: BorderStyle.solid, + strokeAlign: -1, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: PlatformText( + "No devices connected", + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), ), - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), - ), - ), - ), - ], + ], ); } /// Generates a dynamic quilted grid layout based on the device properties - List _generateTiles(List devices, Map notifiers) { + List _generateTiles( + List devices, + Map notifiers, + ) { // Sort devices by size dynamically for a balanced layout - devices.sort((a, b) => _getGridSpanForDevice(b) - _getGridSpanForDevice(a)); + devices.sort( + (a, b) => _getGridSpanForDevice(b) - _getGridSpanForDevice(a), + ); return devices.map((device) { int span = _getGridSpanForDevice(device); @@ -181,8 +522,23 @@ class SensorConfigurationView extends StatelessWidget { return 1; // Default size } - int sensorConfigCount = device.requireCapability().sensorConfigurations.length; + int sensorConfigCount = device + .requireCapability() + .sensorConfigurations + .length; return sensorConfigCount.clamp(1, 4); } } + +class _ConfigApplyTarget { + final Wearable primaryDevice; + final Wearable? mirroredDevice; + final SensorConfigurationProvider provider; + + const _ConfigApplyTarget({ + required this.primaryDevice, + required this.mirroredDevice, + required this.provider, + }); +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index 8af6147c..c95c230c 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 @@ -13,9 +13,16 @@ import 'package:share_plus/share_plus.dart'; import 'package:flutter_archive/flutter_archive.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/recording_activity_indicator.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; Logger _logger = Logger(); +enum _StopRecordingMode { + stopOnly, + stopAndTurnOffSensors, +} + class LocalRecorderView extends StatefulWidget { const LocalRecorderView({super.key}); @@ -25,6 +32,7 @@ class LocalRecorderView extends StatefulWidget { class _LocalRecorderViewState extends State { static const MethodChannel platform = MethodChannel('edu.teco.open_folder'); + final ScrollController _recordingsScrollController = ScrollController(); List _recordings = []; final Set _expandedFolders = {}; // Track which folders are expanded Timer? _recordingTimer; @@ -34,6 +42,20 @@ class _LocalRecorderViewState extends State { DateTime? _activeRecordingStart; SensorRecorderProvider? _recorder; + String _basename(String path) => path.split(Platform.pathSeparator).last; + + void _scrollRecordingsFromHeaderDrag(DragUpdateDetails details) { + if (!_recordingsScrollController.hasClients) return; + final position = _recordingsScrollController.position; + final nextOffset = (position.pixels - details.delta.dy).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if (nextOffset != position.pixels) { + _recordingsScrollController.jumpTo(nextOffset); + } + } + @override void initState() { super.initState(); @@ -114,9 +136,7 @@ class _LocalRecorderViewState extends State { List _getFilesInFolder(Directory folder) { try { return folder.listSync(recursive: false).whereType().toList() - ..sort( - (a, b) => a.path.split('/').last.compareTo(b.path.split('/').last), - ); + ..sort((a, b) => _basename(a.path).compareTo(_basename(b.path))); } catch (e) { _logger.e('Error listing files in folder: $e'); return []; @@ -125,7 +145,7 @@ class _LocalRecorderViewState extends State { Future _confirmAndDeleteRecording(FileSystemEntity entity) async { if (!mounted) return; - final name = entity.path.split('/').last; + final name = _basename(entity.path); final shouldDelete = await showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( @@ -169,7 +189,7 @@ class _LocalRecorderViewState extends State { Future _handleStopRecording( SensorRecorderProvider recorder, { - required bool turnOffSensors, + required _StopRecordingMode mode, }) async { if (_isHandlingStopAction) return; setState(() { @@ -178,7 +198,7 @@ class _LocalRecorderViewState extends State { try { recorder.stopRecording(); - if (turnOffSensors) { + if (mode == _StopRecordingMode.stopAndTurnOffSensors) { final wearablesProvider = context.read(); final futures = wearablesProvider.sensorConfigurationProviders.values .map((provider) => provider.turnOffAllSensors()); @@ -235,6 +255,7 @@ class _LocalRecorderViewState extends State { void dispose() { _recordingTimer?.cancel(); _recorder?.removeListener(_handleRecorderUpdate); + _recordingsScrollController.dispose(); super.dispose(); } @@ -300,7 +321,7 @@ class _LocalRecorderViewState extends State { _logger.i('Creating zip file for ${folder.path}...'); final tempDir = await getTemporaryDirectory(); - final zipPath = '${tempDir.path}/${folder.path.split("/").last}.zip'; + final zipPath = '${tempDir.path}/${_basename(folder.path)}.zip'; final zipFile = File(zipPath); await ZipFile.createFromDirectory( @@ -329,6 +350,424 @@ class _LocalRecorderViewState extends State { } } + String _formatDateTime(DateTime value) { + final local = value.toLocal(); + String twoDigits(int n) => n.toString().padLeft(2, '0'); + return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} ' + '${twoDigits(local.hour)}:${twoDigits(local.minute)}'; + } + + Future _startRecording(SensorRecorderProvider recorder) async { + final dir = await _pickDirectory(); + if (dir == null) { + await _showErrorDialog('Could not create a recording directory.'); + return; + } + + if (!await _isDirectoryEmpty(dir)) { + if (!mounted) return; + final proceed = await _askOverwriteConfirmation(context, dir); + if (!proceed) return; + } + + recorder.startRecording(dir); + await _listRecordings(); + } + + Future _openRecordingFile(File file) async { + final result = await OpenFile.open( + file.path, + type: 'text/comma-separated-values', + ); + if (result.type != ResultType.done) { + await _showErrorDialog('Could not open file: ${result.message}'); + } + } + + Widget _buildRecorderCard( + BuildContext context, { + required SensorRecorderProvider recorder, + required bool isRecording, + required bool canStartRecording, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final hasSensorsConnected = recorder.hasSensorsConnected; + final statusIcon = isRecording + ? Icons.fiber_manual_record + : hasSensorsConnected + ? Icons.sensors + : Icons.sensors_off; + final statusColor = isRecording + ? colorScheme.error + : hasSensorsConnected + ? colorScheme.primary + : colorScheme.onSurfaceVariant; + final statusTitle = isRecording + ? 'Recording in progress' + : hasSensorsConnected + ? 'Ready to record' + : 'No active sensors'; + final statusSubtitle = isRecording + ? 'Capturing live Bluetooth sensor data.' + : hasSensorsConnected + ? 'Start a session to capture live Bluetooth sensor data.' + : 'Connect a wearable and enable sensors to start recording.'; + + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: isRecording + ? const Center( + child: RecordingActivityIndicator( + size: 20, + showIdleOutline: false, + padding: EdgeInsets.zero, + ), + ) + : Icon(statusIcon, color: statusColor, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Local Recorder', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + statusTitle, + style: theme.textTheme.titleSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + statusSubtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (isRecording) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + _formatDuration(_elapsedRecording), + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + if (!isRecording) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: canStartRecording + ? () => _startRecording(recorder) + : null, + icon: const Icon(Icons.play_arrow), + label: const Text('Start Recording'), + ), + ), + if (!isRecording && !recorder.hasSensorsConnected) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'No connected sensors detected yet.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + if (isRecording) ...[ + Row( + children: [ + Expanded( + child: FilledButton.tonalIcon( + style: FilledButton.styleFrom( + foregroundColor: colorScheme.error, + backgroundColor: + colorScheme.errorContainer.withValues(alpha: 0.45), + ), + onPressed: _isHandlingStopAction + ? null + : () => _handleStopRecording( + recorder, + mode: _StopRecordingMode.stopAndTurnOffSensors, + ), + icon: const Icon(Icons.power_settings_new), + label: const Text('Stop + Off'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ), + onPressed: _isHandlingStopAction + ? null + : () => _handleStopRecording( + recorder, + mode: _StopRecordingMode.stopOnly, + ), + icon: const Icon(Icons.stop), + label: const Text('Stop Recording'), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } + + Widget _buildRecordingsHeaderCard(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final recordingCount = _recordings.length; + final subtitle = recordingCount == 1 + ? '1 recording folder' + : '$recordingCount recording folders'; + + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + child: Row( + children: [ + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + minLeadingWidth: 26, + leading: const Icon(Icons.folder_copy_outlined), + title: const Text('Recordings'), + subtitle: Text(subtitle), + ), + ), + IconButton( + tooltip: 'Refresh recordings', + onPressed: _listRecordings, + icon: const Icon(Icons.refresh), + ), + if (Platform.isIOS) + IconButton( + tooltip: 'Open recording folder', + onPressed: () async { + final recordDir = await getIOSDirectory(); + _openFolder(recordDir.path); + }, + icon: Icon( + Icons.folder_open, + color: colorScheme.primary, + ), + ), + ], + ), + ), + ); + } + + Widget _buildEmptyRecordingsState(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + children: [ + Icon( + Icons.folder_open_outlined, + size: 36, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 10), + Text( + 'No recordings yet', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Start a recording session to create your first export.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + Widget _buildRecordingFileTile(BuildContext context, File file) { + final fileName = _basename(file.path); + final fileSize = _formatFileSize(file); + final isCsv = fileName.toLowerCase().endsWith('.csv'); + + return ListTile( + contentPadding: const EdgeInsets.fromLTRB(58, 2, 10, 2), + dense: true, + leading: Icon( + isCsv ? Icons.table_chart_outlined : Icons.insert_drive_file_outlined, + size: 20, + ), + title: Text( + fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + subtitle: Text(fileSize), + trailing: IconButton( + tooltip: 'Share file', + icon: const Icon(Icons.ios_share, size: 20), + onPressed: () => _shareFile(file), + ), + onTap: () => _openRecordingFile(file), + ); + } + + Widget _buildRecordingCard( + BuildContext context, + Directory folder, { + required bool isRecording, + required int index, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final folderName = _basename(folder.path); + final isCurrentRecording = isRecording && index == 0; + final isExpanded = _expandedFolders.contains(folder.path); + final files = isExpanded ? _getFilesInFolder(folder) : []; + final modified = folder.statSync().changed; + + return Card( + margin: const EdgeInsets.only(bottom: SensorPageSpacing.sectionGap), + child: Column( + children: [ + ListTile( + leading: Icon( + isExpanded ? Icons.folder_open : Icons.folder_outlined, + color: isCurrentRecording + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + title: Text( + folderName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + isCurrentRecording + ? 'Active recording' + : 'Updated ${_formatDateTime(modified)}', + ), + trailing: isCurrentRecording + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.error, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Share folder', + onPressed: () => _shareFolder(folder), + icon: Icon(Icons.ios_share, color: colorScheme.primary), + ), + IconButton( + tooltip: 'Delete folder', + onPressed: () => _confirmAndDeleteRecording(folder), + icon: Icon( + Icons.delete_outline, + color: colorScheme.error, + ), + ), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + onTap: () { + if (isCurrentRecording) return; + setState(() { + if (isExpanded) { + _expandedFolders.remove(folder.path); + } else { + _expandedFolders.add(folder.path); + } + }); + }, + ), + if (isExpanded) const Divider(height: 1), + if (isExpanded && files.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(58, 10, 10, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'No files in this folder', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + if (isExpanded) + ...files.map( + (file) => _buildRecordingFileTile( + context, + file, + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return Consumer( @@ -341,327 +780,47 @@ class _LocalRecorderViewState extends State { child: Column( children: [ Padding( - padding: const EdgeInsets.all(10), - child: Padding( - padding: const EdgeInsets.all(16), + padding: SensorPageSpacing.pageHeaderPadding, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: _scrollRecordingsFromHeaderDrag, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - PlatformText( - 'Local Recorder', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - PlatformText( - "Only records sensor data streamed over Bluetooth.", - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: !isRecording - ? ElevatedButton.icon( - icon: const Icon(Icons.play_arrow), - style: ElevatedButton.styleFrom( - backgroundColor: canStartRecording - ? Colors.green.shade600 - : Colors.grey.shade400, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(48), - ), - label: const Text( - 'Start Recording', - style: TextStyle(fontSize: 18), - ), - onPressed: !canStartRecording - ? null - : () async { - final dir = await _pickDirectory(); - if (dir == null) return; - - // Check if directory is empty - if (!await _isDirectoryEmpty(dir)) { - if (!context.mounted) return; - final proceed = - await _askOverwriteConfirmation( - context, - dir, - ); - if (!proceed) return; - } - - recorder.startRecording(dir); - await _listRecordings(); // Refresh list - }, - ) - : Column( - children: [ - Row( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Expanded( - child: ElevatedButton.icon( - icon: const Icon(Icons.stop), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - minimumSize: - const Size.fromHeight(48), - ), - label: const Text( - 'Stop Recording', - style: TextStyle(fontSize: 18), - ), - onPressed: _isHandlingStopAction - ? null - : () => _handleStopRecording( - recorder, - turnOffSensors: false, - ), - ), - ), - const SizedBox(width: 8), - ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 90, - ), - child: Text( - _formatDuration(_elapsedRecording), - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - ElevatedButton.icon( - icon: const Icon(Icons.power_settings_new), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red[800], - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(48), - ), - label: const Text( - 'Stop & Turn Off Sensors', - style: TextStyle(fontSize: 18), - ), - onPressed: _isHandlingStopAction - ? null - : () => _handleStopRecording( - recorder, - turnOffSensors: true, - ), - ), - ], - ), + _buildRecorderCard( + context, + recorder: recorder, + isRecording: isRecording, + canStartRecording: canStartRecording, ), + const SizedBox(height: SensorPageSpacing.sectionGap), + _buildRecordingsHeaderCard(context), ], ), ), ), Expanded( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Recordings", - style: TextStyle( - fontSize: 20.0, - fontWeight: FontWeight.bold, - ), - ), - if (Platform.isIOS) - IconButton( - icon: Icon(Icons.folder_open), - onPressed: () async { - Directory recordDir = await getIOSDirectory(); - _openFolder(recordDir.path); - }, - ), - ], - ), - ), - Divider(thickness: 2), - Expanded( - child: _recordings.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.warning, - size: 48, - color: Colors.grey, - ), - SizedBox(height: 16), - Text( - "No recordings found", - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ) - : ListView.builder( - padding: EdgeInsets.zero, - itemCount: _recordings.length, - itemBuilder: (context, index) { - Directory folder = - _recordings[index] as Directory; - String folderName = folder.path.split("/").last; - bool isCurrentRecording = - isRecording && index == 0; - bool isExpanded = - _expandedFolders.contains(folder.path); - List files = - isExpanded ? _getFilesInFolder(folder) : []; - - return Column( - children: [ - ListTile( - leading: Icon( - isExpanded - ? Icons.folder_open - : Icons.folder, - color: Colors.grey, - ), - title: Text( - folderName, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 14), - ), - trailing: isCurrentRecording - ? Padding( - padding: EdgeInsets.all( - 16.0, - ), - child: SizedBox( - width: 16, - height: 16, - child: - CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon( - Icons.share, - color: isCurrentRecording - ? Colors.grey - .withValues( - alpha: 30, - ) - : Colors.blue, - ), - onPressed: isCurrentRecording - ? null - : () => _shareFolder( - folder, - ), - ), - IconButton( - icon: Icon( - Icons.delete, - color: isCurrentRecording - ? Colors.grey - .withValues( - alpha: 30, - ) - : Colors.red, - ), - onPressed: isCurrentRecording - ? null - : () => - _confirmAndDeleteRecording( - folder, - ), - ), - ], - ), - onTap: () { - setState(() { - if (isExpanded) { - _expandedFolders - .remove(folder.path); - } else if (!isCurrentRecording) { - _expandedFolders.add(folder.path); - } - }); - }, - ), - // Show files when expanded - if (isExpanded) - ...files.map((file) { - String fileName = - file.path.split("/").last; - String fileSize = _formatFileSize(file); - - return ListTile( - contentPadding: EdgeInsets.only( - left: 72, - right: 16, - ), - leading: Icon( - fileName.endsWith('.csv') - ? Icons.table_chart - : Icons.insert_drive_file, - size: 20, - ), - title: Text( - fileName, - style: TextStyle(fontSize: 14), - ), - subtitle: Text( - fileSize, - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - trailing: IconButton( - icon: Icon( - Icons.share, - color: Colors.blue, - size: 20, - ), - onPressed: () => _shareFile(file), - ), - onTap: () async { - final result = await OpenFile.open( - file.path, - type: - 'text/comma-separated-values', - ); - if (result.type != - ResultType.done) { - await _showErrorDialog( - 'Could not open file: ${result.message}', - ); - } - }, - ); - }), - ], - ); - }, - ), - ), - ], + child: RefreshIndicator( + onRefresh: _listRecordings, + child: ListView( + controller: _recordingsScrollController, + primary: false, + physics: const AlwaysScrollableScrollPhysics(), + padding: SensorPageSpacing.pageListPadding, + children: [ + if (_recordings.isEmpty) + _buildEmptyRecordingsState(context), + if (_recordings.isNotEmpty) + ..._recordings.asMap().entries.map((entry) { + final folder = entry.value as Directory; + return _buildRecordingCard( + context, + folder, + isRecording: isRecording, + index: entry.key, + ); + }), + ], + ), ), ), ], diff --git a/open_wearable/lib/widgets/sensors/sensor_page.dart b/open_wearable/lib/widgets/sensors/sensor_page.dart index 76eb1d07..98a9d277 100644 --- a/open_wearable/lib/widgets/sensors/sensor_page.dart +++ b/open_wearable/lib/widgets/sensors/sensor_page.dart @@ -1,89 +1,217 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_view.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_view.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_values_page.dart'; import 'package:provider/provider.dart'; -import '../../view_models/sensor_recorder_provider.dart'; +class SensorPageController { + _SensorPageState? _state; + int? _pendingTabIndex; + void _attach(_SensorPageState state) { + _state = state; + if (_pendingTabIndex != null) { + state.openTab(_pendingTabIndex!); + _pendingTabIndex = null; + } + } + + void _detach(_SensorPageState state) { + if (_state == state) { + _state = null; + } + } + + void openTab(int tabIndex) { + final attachedState = _state; + if (attachedState == null) { + _pendingTabIndex = tabIndex; + return; + } + attachedState.openTab(tabIndex); + } +} + +class SensorPage extends StatefulWidget { + final SensorPageController? controller; -class SensorPage extends StatelessWidget { - const SensorPage({super.key}); + const SensorPage({ + super.key, + this.controller, + }); + + @override + State createState() => _SensorPageState(); +} + +class _SensorPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + widget.controller?._attach(this); + } + + @override + void didUpdateWidget(covariant SensorPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller?._detach(this); + widget.controller?._attach(this); + } + } + + @override + void dispose() { + widget.controller?._detach(this); + _tabController.dispose(); + super.dispose(); + } + + void openTab(int tabIndex) { + final safeIndex = tabIndex.clamp(0, _tabController.length - 1).toInt(); + if (_tabController.index == safeIndex) return; + _tabController.animateTo(safeIndex); + } @override Widget build(BuildContext context) { - return DefaultTabController( - length: 3, - child: PlatformScaffold( - body: NestedScrollView( - headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { - return [ - SliverAppBar( - title: PlatformText("Sensors"), - actions: [ - PlatformIconButton( - icon: Icon(context.platformIcons.bluetooth), - onPressed: () { - context.push('/connect-devices'); - }, - ), - ], - pinned: true, - floating: true, - snap: true, - forceElevated: innerBoxIsScrolled, - bottom: TabBar( - tabs: [ - const Tab(text: 'Configuration'), - const Tab(text: 'Charts'), - Tab( - child: Row( - children: [ - const _RecordingIndicator(), - PlatformText('Recorder'), - ], - ), + return Consumer( + builder: (context, wearablesProvider, child) { + final hasConnectedDevices = wearablesProvider.wearables.isNotEmpty; + final noDevicesPrompt = _NoSensorDevicesPromptView( + onScanPressed: () => context.push('/connect-devices'), + ); + + return PlatformScaffold( + body: NestedScrollView( + headerSliverBuilder: + (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverAppBar( + title: PlatformText("Sensors"), + actions: [ + const AppBarRecordingIndicator(), + PlatformIconButton( + icon: Icon(context.platformIcons.bluetooth), + onPressed: () { + context.push('/connect-devices'); + }, ), ], + pinned: true, + floating: true, + snap: true, + forceElevated: innerBoxIsScrolled, + bottom: TabBar( + controller: _tabController, + tabs: [ + const Tab(text: 'Configure'), + const Tab(text: 'Live Data'), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const RecordingActivityIndicator(size: 14), + const SizedBox(width: 4), + PlatformText('Recorder'), + ], + ), + ), + ], + ), ), - ), - ]; - }, - body: TabBarView( - children: [ - Builder( - builder: (tabCtx) => SensorConfigurationView( - onSetConfigPressed: () { - DefaultTabController.of(tabCtx).animateTo(1); - }, - ), - ), - - SensorValuesPage(), - - LocalRecorderView(), - ], + ]; + }, + body: TabBarView( + controller: _tabController, + children: [ + hasConnectedDevices + ? SensorConfigurationView( + onSetConfigPressed: () { + _tabController.animateTo(1); + }, + ) + : noDevicesPrompt, + hasConnectedDevices ? SensorValuesPage() : noDevicesPrompt, + hasConnectedDevices ? LocalRecorderView() : noDevicesPrompt, + ], + ), ), - ), - ), + ); + }, ); } } -class _RecordingIndicator extends StatelessWidget { - const _RecordingIndicator(); +class _NoSensorDevicesPromptView extends StatelessWidget { + final VoidCallback onScanPressed; + + const _NoSensorDevicesPromptView({required this.onScanPressed}); @override Widget build(BuildContext context) { - return Consumer( - builder: (context, recorderProvider, child) { - return Icon( - recorderProvider.isRecording ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined, - color: recorderProvider.isRecording ? Colors.red : Colors.grey, - ); - }, + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.45), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + Icons.bluetooth_searching_rounded, + size: 28, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 14), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + 'No devices connected', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + 'Scan for devices to start streaming and recording data.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: onScanPressed, + icon: const Icon(Icons.search_rounded, size: 18), + label: const Text('Scan for devices'), + ), + ], + ), + ), ); } } diff --git a/open_wearable/lib/widgets/sensors/sensor_page_spacing.dart b/open_wearable/lib/widgets/sensors/sensor_page_spacing.dart new file mode 100644 index 00000000..70868041 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/sensor_page_spacing.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +/// Shared spacing scale for the Sensors page tabs. +class SensorPageSpacing { + static const double sectionGap = 8; + static const double gridGap = 10; + + static const EdgeInsets pagePadding = EdgeInsets.all(10); + + static const EdgeInsets pageHeaderPadding = + EdgeInsets.fromLTRB(10, 10, 10, 0); + + static const EdgeInsets pageListPadding = + EdgeInsets.fromLTRB(10, sectionGap, 10, 10); +} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_chart.dart b/open_wearable/lib/widgets/sensors/values/sensor_chart.dart index 6beda2b1..4d317add 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_chart.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_chart.dart @@ -7,8 +7,6 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:provider/provider.dart'; -import '../../../models/logger.dart'; - class SensorChart extends StatefulWidget { final bool allowToggleAxes; @@ -22,17 +20,28 @@ class SensorChart extends StatefulWidget { } class _SensorChartState extends State { + static const List _fallbackColors = [ + Color(0xFF4A90E2), + Color(0xFFE76F51), + Color(0xFF2A9D8F), + Color(0xFFB565D9), + Color(0xFFF4A261), + Color(0xFF3D5A80), + Color(0xFFD62828), + ]; + late Map _axisEnabled; + int? _xOriginTimestamp; + int? _originSensorHash; @override void initState() { super.initState(); final sensor = context.read().sensor; - _axisEnabled = { for (var axis in sensor.axisNames) axis: true }; + _axisEnabled = {for (var axis in sensor.axisNames) axis: true}; } void _toggleAxis(String axisName, bool value) { - logger.d('Toggling axis $axisName to $value'); setState(() { _axisEnabled[axisName] = value; }); @@ -40,122 +49,397 @@ class _SensorChartState extends State { @override Widget build(BuildContext context) { - Sensor sensor = context.watch().sensor; - final enabledAxes = sensor.axisNames - .where((axis) => _axisEnabled[axis] ?? false) - .toList(); - final axisData = _buildAxisData( - sensor, - context.watch().sensorValues, - ); - - return Column( - children: [ - if (widget.allowToggleAxes) - Wrap( - spacing: 8, - children: sensor.axisNames.map((axisName) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: _axisEnabled[axisName], - checkColor: Colors.white, - activeColor: _axisColor(axisName), - onChanged: (value) => - _toggleAxis(axisName, value ?? false), - ), - PlatformText(axisName), - ], - ); - }).toList(), + final dataProvider = context.watch(); + final sensor = dataProvider.sensor; + final sensorValues = dataProvider.sensorValues; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final compactMode = !widget.allowToggleAxes; + + final currentSensorHash = identityHashCode(sensor); + if (_originSensorHash != currentSensorHash) { + _originSensorHash = currentSensorHash; + _xOriginTimestamp = null; + } + + final axisData = _buildAxisData(sensor, sensorValues); + final enabledSeries = <_AxisSeries>[ + for (int i = 0; i < sensor.axisNames.length; i++) + if (_axisEnabled[sensor.axisNames[i]] ?? false) + _AxisSeries( + spots: axisData[sensor.axisNames[i]] ?? const [], + color: _axisColor( + axisIndex: i, + axisName: sensor.axisNames[i], + colorScheme: colorScheme, + ), ), - Expanded( - child: LineChart( - LineChartData( - lineTouchData: LineTouchData(enabled: true), - gridData: FlGridData(show: true), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - axisNameWidget: PlatformText(sensor.axisUnits.first), - sideTitles: SideTitles( - showTitles: true, - reservedSize: 45, + ]; + + final windowSeconds = dataProvider.timeWindow.toDouble(); + final maxX = _calculateMaxX(sensor, sensorValues, fallback: windowSeconds); + final minX = max(0.0, maxX - windowSeconds); + + final axisChipTextStyle = theme.textTheme.labelMedium; + const disabledChipLabelColor = Color(0xFF8A8A8A); + const disabledChipBackgroundColor = Color(0xFFECECEC); + const disabledChipBorderColor = Color(0xFFD7D7D7); + const disabledChipDotColor = Color(0xFFB3B3B3); + + final leftUnit = sensor.axisUnits.isNotEmpty ? sensor.axisUnits.first : ''; + + final chartData = LineChartData( + minX: minX, + maxX: maxX, + lineTouchData: LineTouchData( + enabled: !compactMode, + handleBuiltInTouches: !compactMode, + ), + gridData: FlGridData( + show: true, + drawVerticalLine: true, + getDrawingHorizontalLine: (_) => FlLine( + color: colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 1, + ), + getDrawingVerticalLine: (_) => FlLine( + color: colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + axisNameWidget: leftUnit.isEmpty + ? null + : PlatformText( + leftUnit, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), - rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: false, + axisNameSize: leftUnit.isEmpty ? 0 : (compactMode ? 16 : 22), + sideTitles: SideTitles( + showTitles: true, + reservedSize: compactMode ? 34 : 46, + minIncluded: false, + maxIncluded: false, + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + space: 6, + child: SizedBox( + width: compactMode ? 30 : 40, + child: Text( + _formatYAxisTick(value), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + textAlign: TextAlign.right, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), - topTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: false, + ), + ), + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + axisNameSize: 0, + sideTitles: SideTitles( + showTitles: true, + reservedSize: compactMode ? 20 : 24, + interval: compactMode ? 2 : 1, + minIncluded: false, + maxIncluded: false, + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + _formatXAxisTick(value), + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + borderData: FlBorderData( + show: true, + border: Border( + left: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.28), + ), + bottom: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.28), + ), + ), + ), + lineBarsData: enabledSeries + .map( + (series) => LineChartBarData( + spots: series.spots, + isCurved: false, + barWidth: 2.2, + color: series.color, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ) + .toList(growable: false), + ); + + final enabledAxes = + sensor.axisNames.where((axis) => _axisEnabled[axis] ?? false).toList(); + + return Column( + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB( + compactMode ? 2 : 6, + compactMode ? 2 : 4, + 2, + 0, + ), + child: LineChart( + chartData, + duration: const Duration(milliseconds: 0), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: max(0.0, constraints.maxWidth - 70), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: + sensor.axisNames.asMap().entries.map((entry) { + final axisIndex = entry.key; + final axisName = entry.value; + final axisColor = _axisColor( + axisIndex: axisIndex, + axisName: axisName, + colorScheme: colorScheme, + ); + final selected = _axisEnabled[axisName] ?? false; + final chipLabelColor = selected + ? axisColor.withValues(alpha: 0.95) + : disabledChipLabelColor; + final chipBackgroundColor = selected + ? axisColor.withValues(alpha: 0.18) + : disabledChipBackgroundColor; + final chipBorderColor = selected + ? axisColor.withValues(alpha: 0.28) + : disabledChipBorderColor; + final chipDotColor = axisColor; + final disabledDotColor = disabledChipDotColor; + + return Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + label: Text( + axisName, + style: axisChipTextStyle?.copyWith( + color: chipLabelColor, + fontWeight: FontWeight.w700, + fontSize: compactMode ? 10.5 : 11.5, + ), + ), + avatar: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: selected + ? chipDotColor + : disabledDotColor, + shape: BoxShape.circle, + ), + ), + selected: selected, + onSelected: (value) => + _toggleAxis(axisName, value), + showCheckmark: false, + visualDensity: compactMode + ? const VisualDensity( + horizontal: -3, + vertical: -3, + ) + : const VisualDensity( + horizontal: -2, + vertical: -2, + ), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + labelPadding: + const EdgeInsets.symmetric(horizontal: 4), + padding: + const EdgeInsets.symmetric(horizontal: 4), + selectedColor: chipBackgroundColor, + backgroundColor: chipBackgroundColor, + side: BorderSide( + color: chipBorderColor, + ), + ), + ); + }).toList(growable: false), + ), + ), + ), ), ), - bottomTitles: AxisTitles( - axisNameWidget: PlatformText('Time (s)'), - axisNameSize: 30, - sideTitles: SideTitles( - showTitles: true, - reservedSize: 30, + const SizedBox(width: 8), + Text( + 'Time (s)', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, ), ), - ), - borderData: FlBorderData(show: false), - lineBarsData: enabledAxes.map((axisName) { - return LineChartBarData( - spots: axisData[axisName] ?? [], - isCurved: false, - barWidth: 2, - color: _axisColor(axisName), - isStrokeCapRound: true, - dotData: FlDotData(show: false), - ); - }).toList(), + ], ), - duration: const Duration(milliseconds: 0), ), ), + if (enabledAxes.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Enable at least one axis to display data.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), ], ); } - Map> _buildAxisData(Sensor sensor, Queue buffer) { - if (buffer.isEmpty) return { for (var axis in sensor.axisNames) axis: [] }; + double _calculateMaxX( + Sensor sensor, + Queue buffer, { + required double fallback, + }) { + if (buffer.isEmpty) return fallback; + return _toElapsedSeconds(sensor, buffer.last.timestamp); + } + double _toElapsedSeconds(Sensor sensor, int timestamp) { final scale = pow(10, -sensor.timestampExponent).toDouble(); + _xOriginTimestamp ??= timestamp; - return { - for (int i = 0; i < sensor.axisCount; i++) - sensor.axisNames[i]: buffer.map((v) { - final x = v.timestamp.toDouble() / scale; - final y = v is SensorDoubleValue - ? v.values[i] - : (v as SensorIntValue).values[i].toDouble(); - return FlSpot(x, y); - }).toList(), + if (timestamp < _xOriginTimestamp!) { + _xOriginTimestamp = timestamp; + } + + return (timestamp - _xOriginTimestamp!).toDouble() / scale; + } + + Map> _buildAxisData( + Sensor sensor, + Queue buffer, + ) { + final data = >{ + for (var axis in sensor.axisNames) axis: [], }; + if (buffer.isEmpty) return data; + + for (final sensorValue in buffer) { + final x = _toElapsedSeconds(sensor, sensorValue.timestamp); + if (sensorValue is SensorDoubleValue) { + for (int i = 0; i < sensor.axisCount; i++) { + data[sensor.axisNames[i]]!.add(FlSpot(x, sensorValue.values[i])); + } + } else { + final values = (sensorValue as SensorIntValue).values; + for (int i = 0; i < sensor.axisCount; i++) { + data[sensor.axisNames[i]]!.add(FlSpot(x, values[i].toDouble())); + } + } + } + + return data; + } + + String _formatXAxisTick(double value) { + final rounded = value.roundToDouble(); + if ((value - rounded).abs() < 0.05) { + return rounded.toInt().toString(); + } + return value.toStringAsFixed(1); + } + + String _formatYAxisTick(double value) { + final abs = value.abs(); + String output; + + if (abs >= 100000 || (abs > 0 && abs < 0.001)) { + output = value.toStringAsExponential(1); + } else if (abs >= 1000) { + output = value.toStringAsFixed(0); + } else if (abs >= 100) { + output = value.toStringAsFixed(1); + } else if (abs >= 1) { + output = value.toStringAsFixed(2); + } else { + output = value.toStringAsFixed(3); + } + + return _trimTrailingZeros(output); + } + + String _trimTrailingZeros(String value) { + if (value.contains('e') || value.contains('E')) return value; + var result = value; + if (result.contains('.')) { + result = result.replaceFirst(RegExp(r'0+$'), ''); + result = result.replaceFirst(RegExp(r'\.$'), ''); + } + return result; } - Color _axisColor(String axisName) { + Color _axisColor({ + required int axisIndex, + required String axisName, + required ColorScheme colorScheme, + }) { final name = axisName.toLowerCase(); + if (name == 'x') return const Color(0xFF4A90E2); + if (name == 'y') return const Color(0xFFE76F51); + if (name == 'z') return const Color(0xFF2A9D8F); if (name == 'r' || name == 'red') return Colors.red; if (name == 'g' || name == 'green') return Colors.green; if (name == 'b' || name == 'blue') return Colors.blue; + if (name.contains('temp')) return const Color(0xFFFB8500); + if (name.contains('pressure')) return const Color(0xFF6C63FF); - // Fallback for unrecognized names (e.g., axis4, temp, etc.) - final fallbackColors = [ - Colors.teal, - Colors.amber, - Colors.indigo, - Colors.lime, - Colors.brown, - Colors.deepOrange, - Colors.pink, - ]; - final index = context.read().sensor.axisNames.indexOf(axisName); - return fallbackColors[index % fallbackColors.length]; + if (axisIndex == 0) return colorScheme.primary; + return _fallbackColors[axisIndex % _fallbackColors.length]; } } + +class _AxisSeries { + final List spots; + final Color color; + + const _AxisSeries({ + required this.spots, + required this.color, + }); +} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart b/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart index 85f8e111..1c779695 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; +import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_chart.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_detail.dart'; import 'package:provider/provider.dart'; @@ -10,21 +11,25 @@ class SensorValueCard extends StatelessWidget { final Sensor sensor; final Wearable wearable; - const SensorValueCard({super.key, required this.sensor, required this.wearable}); + const SensorValueCard({ + super.key, + required this.sensor, + required this.wearable, + }); @override Widget build(BuildContext context) { return GestureDetector( onTap: () { - final provider = context.read(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ChangeNotifierProvider.value( - value: provider, - child: SensorValueDetail(sensor: sensor, wearable: wearable), - ), + final provider = context.read(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChangeNotifierProvider.value( + value: provider, + child: SensorValueDetail(sensor: sensor, wearable: wearable), ), - ); + ), + ); }, child: Card( child: Padding( @@ -33,16 +38,42 @@ class SensorValueCard extends StatelessWidget { children: [ Row( children: [ - PlatformText(sensor.sensorName, style: Theme.of(context).textTheme.bodyLarge), - Spacer(), - PlatformText(wearable.name, style: Theme.of(context).textTheme.bodyMedium), + Expanded( + child: PlatformText( + sensor.sensorName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + PlatformText( + wearable.name, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (wearable.hasCapability()) + Padding( + padding: const EdgeInsets.only(left: 8), + child: StereoPositionBadge( + device: wearable.requireCapability(), + ), + ), + ], + ), ], ), Padding( padding: const EdgeInsets.only(top: 10.0), - child: SizedBox( + child: SizedBox( height: 200, - child: SensorChart(allowToggleAxes: false,), + child: SensorChart( + allowToggleAxes: false, + ), ), ), ], diff --git a/open_wearable/lib/widgets/sensors/values/sensor_value_detail.dart b/open_wearable/lib/widgets/sensors/values/sensor_value_detail.dart index 1b8a0429..a87853a5 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_value_detail.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_value_detail.dart @@ -7,21 +7,35 @@ class SensorValueDetail extends StatelessWidget { final Sensor sensor; final Wearable wearable; - const SensorValueDetail({super.key, required this.sensor, required this.wearable}); + const SensorValueDetail({ + super.key, + required this.sensor, + required this.wearable, + }); @override Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText(sensor.sensorName, style: Theme.of(context).textTheme.titleMedium), + title: PlatformText( + sensor.sensorName, + style: Theme.of(context).textTheme.titleMedium, + ), ), - body: Padding( - padding: EdgeInsets.all(10), + body: SafeArea( + top: false, + bottom: true, + minimum: const EdgeInsets.all(10), child: Column( children: [ - PlatformText(wearable.name, style: Theme.of(context).textTheme.bodyMedium), + PlatformText( + wearable.name, + style: Theme.of(context).textTheme.bodyMedium, + ), Expanded( - child: SensorChart(allowToggleAxes: true), + child: SensorChart( + allowToggleAxes: 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 97b6101f..eb4f104c 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:open_wearable/view_models/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'; @@ -18,24 +19,35 @@ class SensorValuesPage extends StatelessWidget { List charts = []; for (var wearable in wearablesProvider.wearables) { if (wearable.hasCapability()) { - for (Sensor sensor in wearable.requireCapability().sensors) { + for (Sensor sensor + in wearable.requireCapability().sensors) { if (!_sensorDataProvider.containsKey((wearable, sensor))) { - _sensorDataProvider[(wearable, sensor)] = SensorDataProvider(sensor: sensor); + _sensorDataProvider[(wearable, sensor)] = + SensorDataProvider(sensor: sensor); } charts.add( ChangeNotifierProvider.value( value: _sensorDataProvider[(wearable, sensor)], - child: SensorValueCard(sensor: sensor, wearable: wearable,), + child: SensorValueCard( + sensor: sensor, + wearable: wearable, + ), ), ); } } } - _sensorDataProvider.removeWhere((key, _) => - !wearablesProvider.wearables.any((device) => device.hasCapability() - && device == key.$1 - && device.requireCapability().sensors.contains(key.$2),), + _sensorDataProvider.removeWhere( + (key, _) => !wearablesProvider.wearables.any( + (device) => + device.hasCapability() && + device == key.$1 && + device + .requireCapability() + .sensors + .contains(key.$2), + ), ); return LayoutBuilder( @@ -52,25 +64,29 @@ class SensorValuesPage extends StatelessWidget { } Widget _buildSmallScreenLayout(BuildContext context, List charts) { - return Padding( - padding: EdgeInsets.all(10), - child: charts.isEmpty - ? Center( - child: PlatformText("No sensors connected", style: Theme.of(context).textTheme.titleLarge), - ) - : ListView( - children: charts, + if (charts.isEmpty) { + return Center( + child: PlatformText( + "No sensors connected", + style: Theme.of(context).textTheme.titleLarge, ), + ); + } + + return ListView( + padding: SensorPageSpacing.pagePadding, + children: charts, ); } Widget _buildLargeScreenLayout(BuildContext context, List charts) { return GridView.builder( + padding: SensorPageSpacing.pagePadding, gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 500, childAspectRatio: 1.5, - crossAxisSpacing: 10, - mainAxisSpacing: 10, + crossAxisSpacing: SensorPageSpacing.gridGap, + mainAxisSpacing: SensorPageSpacing.gridGap, ), shrinkWrap: true, physics: NeverScrollableScrollPhysics(), @@ -88,7 +104,10 @@ class SensorValuesPage extends StatelessWidget { borderRadius: BorderRadius.circular(10), ), child: Center( - child: PlatformText("No sensors available", style: Theme.of(context).textTheme.titleLarge), + child: PlatformText( + "No sensors available", + style: Theme.of(context).textTheme.titleLarge, + ), ), ); } diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 034ce0e3..1010832c 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -444,18 +444,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" mcumgr_flutter: dependency: "direct main" description: @@ -507,11 +507,10 @@ packages: open_earable_flutter: dependency: "direct main" description: - name: open_earable_flutter - sha256: "23b784abdb9aa2a67afd6bcf22778cc9e3d124eba5a4d02f49443581fa3f8958" - url: "https://pub.dev" - source: hosted - version: "2.3.1" + path: "../../open_earable_flutter" + relative: true + source: path + version: "2.3.2" open_file: dependency: "direct main" description: @@ -865,10 +864,10 @@ packages: dependency: transitive description: name: test_api - sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.7" tuple: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 6b9c9fa2..345cb73f 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -35,7 +35,8 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 open_file: ^3.3.2 - open_earable_flutter: ^2.3.1 + open_earable_flutter: + path: ../../open_earable_flutter flutter_platform_widgets: ^9.0.0 provider: ^6.1.2 logger: ^2.5.0 @@ -82,6 +83,8 @@ flutter: assets: - lib/apps/posture_tracker/assets/ - lib/apps/heart_tracker/assets/ + - lib/apps/self_test/assets/ + - android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png # An image asset can refer to one or more resolution-specific "variants", seeq # https://flutter.dev/to/resolution-aware-images diff --git a/open_wearable/test/widget_test.dart b/open_wearable/test/widget_test.dart index f012e6aa..9ba375dc 100644 --- a/open_wearable/test/widget_test.dart +++ b/open_wearable/test/widget_test.dart @@ -1,30 +1,34 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read PlatformText, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:open_wearable/main.dart'; +import 'package:open_earable_flutter/open_earable_flutter.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/home_page.dart'; +import 'package:provider/provider.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + testWidgets('Home shell shows top-level navigation', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => WearablesProvider()), + ChangeNotifierProvider(create: (_) => SensorRecorderProvider()), + ChangeNotifierProvider( + create: (_) => FirmwareUpdateRequestProvider(), + ), + ], + child: const MaterialApp( + home: HomePage(), + ), + ), + ); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('Overview'), findsWidgets); + expect(find.text('Devices'), findsWidgets); + expect(find.text('Sensors'), findsWidgets); + expect(find.text('Apps'), findsWidgets); + expect(find.text('Utilities'), findsWidgets); }); }