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 399936ff7..70c93c08b 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 @@ -281,8 +281,12 @@ class _DeviceDetailPageState extends State { title: 'Status LED', subtitle: 'Customize the status indicator behavior.', child: StatusLEDControlWidget( + key: ObjectKey(widget.device), statusLED: widget.device.requireCapability(), rgbLed: widget.device.requireCapability(), + stateReader: widget.device.hasCapability() + ? widget.device.requireCapability() + : null, ), ) else if (widget.device.hasCapability()) 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 29f194bd3..d9068e6b7 100644 --- a/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart +++ b/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart @@ -5,15 +5,28 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; class RgbControlView extends StatefulWidget { final RgbLed rgbLed; + final Color initialColor; - const RgbControlView({super.key, required this.rgbLed}); + const RgbControlView({ + super.key, + required this.rgbLed, + this.initialColor = Colors.black, + }); @override State createState() => _RgbControlViewState(); } class _RgbControlViewState extends State { - Color _currentColor = Colors.black; + late Color _currentColor = widget.initialColor; + + @override + void didUpdateWidget(covariant RgbControlView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialColor != widget.initialColor) { + _currentColor = widget.initialColor; + } + } void _showColorPickerDialog() { showDialog( 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 2092a6de2..17f69f8a2 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 @@ -6,10 +6,12 @@ import 'rgb_control.dart'; class StatusLEDControlWidget extends StatefulWidget { final StatusLed statusLED; final RgbLed rgbLed; + final LedStateReader? stateReader; const StatusLEDControlWidget({ super.key, required this.statusLED, required this.rgbLed, + this.stateReader, }); @override @@ -19,59 +21,71 @@ class StatusLEDControlWidget extends StatefulWidget { class _StatusLEDControlWidgetState extends State { bool _overrideColor = false; bool _disableLed = false; + bool _busy = false; + Color _ledColor = Colors.black; - 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. + @override + void initState() { + super.initState(); + if (widget.stateReader != null) { + _busy = true; + _loadInitialState(); } } - Future _resetLedOverride() async { + Future _loadInitialState() async { try { - await widget.statusLED.showStatus(true); + await _readState(); } catch (_) { - // LED control is best-effort and should not interrupt UI interactions. + // Keep the existing controls available if readback fails. + } finally { + if (mounted) setState(() => _busy = false); } } - Future _onDisableLedChanged(bool value) async { + Future _readState({bool keepColorEditor = false}) async { + final reader = widget.stateReader; + if (reader == null) return; + final state = await reader.readLedState(); + if (!mounted) return; setState(() { - _disableLed = value; - if (value) { - _overrideColor = false; - } + _disableLed = !state.showStatus && state.isBlack && !keepColorEditor; + _overrideColor = !state.showStatus && !_disableLed; + _ledColor = Color.fromARGB(255, state.red, state.green, state.blue); }); - - if (value) { - await _setLedBlack(); - return; - } - await _resetLedOverride(); } - Future _onOverrideChanged(bool value) async { - setState(() { - _overrideColor = value; - if (value) { - _disableLed = false; - } - }); - - if (value) { + Future _applyState( + {required bool disable, required bool override,}) async { + if (_busy) return; + setState(() => _busy = true); + try { + await widget.statusLED.showStatus(!disable && !override); + if (disable) await widget.rgbLed.writeLedColor(r: 0, g: 0, b: 0); + if (!mounted) return; + setState(() { + _disableLed = disable; + _overrideColor = override; + }); + // A black override also means "disabled" on the device. Keep the color + // picker available while the user is choosing an override color. + await _readState(keepColorEditor: override); + } catch (_) { + // A partial write may have changed the mode but not the color. try { - await widget.statusLED.showStatus(false); - } catch (_) { - // LED control is best-effort and should not interrupt UI interactions. - } - return; + await _readState(keepColorEditor: _overrideColor); + } catch (_) {} + } finally { + if (mounted) setState(() => _busy = false); } - - await _resetLedOverride(); } + Future _onDisableLedChanged(bool value) => + _applyState(disable: value, override: false); + + Future _onOverrideChanged(bool value) => + _applyState(disable: false, override: value); + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -106,7 +120,7 @@ class _StatusLEDControlWidgetState extends State { const SizedBox(width: 10), Switch.adaptive( value: _disableLed, - onChanged: _onDisableLedChanged, + onChanged: _busy ? null : _onDisableLedChanged, ), ], ), @@ -137,7 +151,7 @@ class _StatusLEDControlWidgetState extends State { const SizedBox(width: 10), Switch.adaptive( value: _overrideColor, - onChanged: _onOverrideChanged, + onChanged: _busy ? null : _onOverrideChanged, ), ], ), @@ -175,7 +189,10 @@ class _StatusLEDControlWidgetState extends State { ), ), ), - RgbControlView(rgbLed: widget.rgbLed), + RgbControlView( + rgbLed: widget.rgbLed, + initialColor: _ledColor, + ), ], ), ), diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 85f4bd07e..75891dac3 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -580,8 +580,8 @@ packages: dependency: "direct main" description: path: "." - ref: "07456abb5ebf5e54255d84342cd3f4c643875dd7" - resolved-ref: "07456abb5ebf5e54255d84342cd3f4c643875dd7" + ref: "6b9f4d441d0873333e82bdd9c92a2f89292cf9fd" + resolved-ref: "6b9f4d441d0873333e82bdd9c92a2f89292cf9fd" url: "https://github.com/OpenEarable/open_earable_flutter.git" source: git version: "2.5.0" diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 67c1f648c..c37c34517 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -38,7 +38,7 @@ dependencies: open_earable_flutter: git: url: https://github.com/OpenEarable/open_earable_flutter.git - ref: 07456abb5ebf5e54255d84342cd3f4c643875dd7 + ref: 6b9f4d441d0873333e82bdd9c92a2f89292cf9fd universal_ble: ^2.0.4 permission_handler: ^12.0.1 flutter_platform_widgets: ^10.0.1 diff --git a/open_wearable/test/widgets/status_led_widget_test.dart b/open_wearable/test/widgets/status_led_widget_test.dart new file mode 100644 index 000000000..387defa05 --- /dev/null +++ b/open_wearable/test/widgets/status_led_widget_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/devices/device_detail/rgb_control.dart'; +import 'package:open_wearable/widgets/devices/device_detail/status_led_widget.dart'; + +class TestLed implements StatusLed, RgbLed, LedStateReader { + bool status = false; + List color = [0, 0, 0]; + bool failWrites = false; + int reads = 0; + + @override + Future readLedState() async { + reads++; + return LedState( + showStatus: status, + red: color[0], + green: color[1], + blue: color[2], + ); + } + + @override + Future showStatus(bool value) async { + if (failWrites) throw StateError('Disconnected'); + status = value; + } + + @override + Future writeLedColor( + {required int r, required int g, required int b,}) async { + if (failWrites) throw StateError('Disconnected'); + color = [r, g, b]; + } +} + +void main() { + Future open(WidgetTester tester, TestLed led, + {bool readback = true,}) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: StatusLEDControlWidget( + key: UniqueKey(), + statusLED: led, + rgbLed: led, + stateReader: readback ? led : null, + ),),),); + await tester.pumpAndSettle(); + } + + List switches(WidgetTester tester) => tester + .widgetList( + find.byType(Switch), + ) + .map((s) => s.value) + .toList(); + + testWidgets('reopening restores disabled output and manual RGB', + (tester) async { + final led = TestLed(); + await open(tester, led); + expect(switches(tester), [true, false]); + led.color = [12, 34, 56]; + await open(tester, led); + expect(switches(tester), [false, true]); + expect( + tester.widget(find.byType(RgbControlView)).initialColor, + const Color.fromARGB(255, 12, 34, 56),); + led.status = true; + await open(tester, led); + expect(switches(tester), [false, false]); + }); + + testWidgets('black override keeps the color picker usable until reopening', + (tester) async { + final led = TestLed()..status = true; + await open(tester, led); + await tester.tap(find.byType(Switch).last); + await tester.pumpAndSettle(); + expect(switches(tester), [false, true]); + expect(find.byType(RgbControlView), findsOneWidget); + await open(tester, led); + expect(switches(tester), [true, false]); + }); + + testWidgets('failed write retains confirmed state', (tester) async { + final led = TestLed()..status = true; + await open(tester, led); + led.failWrites = true; + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + expect(switches(tester), [false, false]); + }); + + testWidgets('older devices keep write controls without attempting readback', + (tester) async { + final led = TestLed()..status = true; + await open(tester, led, readback: false); + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + expect(switches(tester), [true, false]); + expect(led.reads, 0); + expect(led.status, isFalse); + expect(led.color, [0, 0, 0]); + }); +}