Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,12 @@ class _DeviceDetailPageState extends State<DeviceDetailPage> {
title: 'Status LED',
subtitle: 'Customize the status indicator behavior.',
child: StatusLEDControlWidget(
key: ObjectKey(widget.device),
statusLED: widget.device.requireCapability<StatusLed>(),
rgbLed: widget.device.requireCapability<RgbLed>(),
stateReader: widget.device.hasCapability<LedStateReader>()
? widget.device.requireCapability<LedStateReader>()
: null,
),
)
else if (widget.device.hasCapability<RgbLed>())
Expand Down
17 changes: 15 additions & 2 deletions open_wearable/lib/widgets/devices/device_detail/rgb_control.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<RgbControlView> createState() => _RgbControlViewState();
}

class _RgbControlViewState extends State<RgbControlView> {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,59 +21,71 @@ class StatusLEDControlWidget extends StatefulWidget {
class _StatusLEDControlWidgetState extends State<StatusLEDControlWidget> {
bool _overrideColor = false;
bool _disableLed = false;
bool _busy = false;
Color _ledColor = Colors.black;

Future<void> _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<void> _resetLedOverride() async {
Future<void> _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<void> _onDisableLedChanged(bool value) async {
Future<void> _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<void> _onOverrideChanged(bool value) async {
setState(() {
_overrideColor = value;
if (value) {
_disableLed = false;
}
});

if (value) {
Future<void> _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<void> _onDisableLedChanged(bool value) =>
_applyState(disable: value, override: false);

Future<void> _onOverrideChanged(bool value) =>
_applyState(disable: false, override: value);

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Expand Down Expand Up @@ -106,7 +120,7 @@ class _StatusLEDControlWidgetState extends State<StatusLEDControlWidget> {
const SizedBox(width: 10),
Switch.adaptive(
value: _disableLed,
onChanged: _onDisableLedChanged,
onChanged: _busy ? null : _onDisableLedChanged,
),
],
),
Expand Down Expand Up @@ -137,7 +151,7 @@ class _StatusLEDControlWidgetState extends State<StatusLEDControlWidget> {
const SizedBox(width: 10),
Switch.adaptive(
value: _overrideColor,
onChanged: _onOverrideChanged,
onChanged: _busy ? null : _onOverrideChanged,
),
],
),
Expand Down Expand Up @@ -175,7 +189,10 @@ class _StatusLEDControlWidgetState extends State<StatusLEDControlWidget> {
),
),
),
RgbControlView(rgbLed: widget.rgbLed),
RgbControlView(
rgbLed: widget.rgbLed,
initialColor: _ledColor,
),
],
),
),
Expand Down
4 changes: 2 additions & 2 deletions open_wearable/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion open_wearable/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions open_wearable/test/widgets/status_led_widget_test.dart
Original file line number Diff line number Diff line change
@@ -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<int> color = [0, 0, 0];
bool failWrites = false;
int reads = 0;

@override
Future<LedState> readLedState() async {
reads++;
return LedState(
showStatus: status,
red: color[0],
green: color[1],
blue: color[2],
);
}

@override
Future<void> showStatus(bool value) async {
if (failWrites) throw StateError('Disconnected');
status = value;
}

@override
Future<void> writeLedColor(
{required int r, required int g, required int b,}) async {
if (failWrites) throw StateError('Disconnected');
color = [r, g, b];
}
}

void main() {
Future<void> 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<bool> switches(WidgetTester tester) => tester
.widgetList<Switch>(
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<RgbControlView>(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]);
});
}
Loading