Skip to content

Commit 8add661

Browse files
committed
feat: Add DeviceCard component and integrate into DevicesScreen; enhance summary content handling
1 parent 2447729 commit 8add661

6 files changed

Lines changed: 547 additions & 82 deletions

File tree

client/lib/devices/borneo/lyfi/manifest.dart

Lines changed: 203 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import 'package:borneo_app/core/services/local_service.dart';
2+
import 'package:borneo_app/core/utils/hex_color.dart';
3+
import 'package:borneo_app/devices/borneo/lyfi/view_models/constants.dart';
24
import 'package:borneo_app/devices/borneo/lyfi/view_models/lyfi_view_model.dart';
35
import 'package:borneo_app/devices/borneo/lyfi/view_models/summary_device_view_model.dart';
46
import 'package:borneo_app/devices/borneo/lyfi/views/lyfi_view.dart';
@@ -10,6 +12,7 @@ import 'package:borneo_app/core/services/app_notification_service.dart';
1012
import 'package:borneo_kernel/drivers/borneo/lyfi/models.dart';
1113
import 'package:borneo_wot/borneo/lyfi/wot_thing.dart';
1214
import 'package:event_bus/event_bus.dart';
15+
import 'package:fl_chart/fl_chart.dart';
1316
import 'package:flutter/material.dart';
1417
import 'package:flutter_gettext/flutter_gettext.dart';
1518
import 'package:logger/logger.dart';
@@ -38,6 +41,7 @@ class LyfiDeviceModuleMetadata extends DeviceModuleMetadata {
3841
deviceIconBuilder: _buildDeviceIcon,
3942
primaryStateIconBuilder: _buildPrimaryStateIcon,
4043
secondaryStatesBuilder: _secondaryStatesBuilder,
44+
summaryContentBuilder: _buildCardCenter,
4145
createSummaryVM: (dev, dm, bus, gt) => LyfiSummaryDeviceViewModel(dev, dm, bus, gt: gt),
4246
createWotThing: _createWotThing,
4347
);
@@ -47,8 +51,8 @@ class LyfiDeviceModuleMetadata extends DeviceModuleMetadata {
4751
Icons.light_outlined,
4852
size: iconSize,
4953
color: isOnline
50-
? Theme.of(context).colorScheme.onPrimaryContainer
51-
: Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.38),
54+
? Theme.of(context).colorScheme.primary
55+
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.38),
5256
);
5357
}
5458

@@ -70,6 +74,40 @@ class LyfiDeviceModuleMetadata extends DeviceModuleMetadata {
7074
return [modeWidget, stateWidget];
7175
}
7276

77+
/// Custom card center: bar chart of per-channel brightness.
78+
/// Falls back to large device icon when offline, powered off, or data unavailable.
79+
static Widget _buildCardCenter(BuildContext context, AbstractDeviceSummaryViewModel vm) {
80+
final lvm = vm as LyfiSummaryDeviceViewModel;
81+
return ValueListenableBuilder<LyfiDeviceInfo?>(
82+
valueListenable: lvm.lyfiDeviceInfo,
83+
builder: (context, deviceInfo, _) {
84+
return ValueListenableBuilder<List<int>?>(
85+
valueListenable: lvm.channelBrightness,
86+
builder: (context, brightness, _) {
87+
// Show large icon when offline, powered off, or data not yet available
88+
final showIcon =
89+
!lvm.isOnline ||
90+
!lvm.isPowerOn ||
91+
deviceInfo == null ||
92+
brightness == null ||
93+
deviceInfo.channels.isEmpty;
94+
if (showIcon) {
95+
return Center(
96+
child: LayoutBuilder(
97+
builder: (context, constraints) {
98+
final iconSize = constraints.maxHeight * 0.72;
99+
return _buildDeviceIcon(context, iconSize, lvm.isOnline);
100+
},
101+
),
102+
);
103+
}
104+
return _LyfiBrightnessChart(deviceInfo: deviceInfo, brightness: brightness);
105+
},
106+
);
107+
},
108+
);
109+
}
110+
73111
static String _modeText(BuildContext context, LyfiMode? mode) {
74112
switch (mode) {
75113
case LyfiMode.manual:
@@ -104,3 +142,166 @@ class LyfiDeviceModuleMetadata extends DeviceModuleMetadata {
104142
return lyfiThing;
105143
}
106144
}
145+
146+
/// A compact bar chart that displays Lyfi per-channel brightness.
147+
/// For a single channel, renders a circular progress indicator instead.
148+
class _LyfiBrightnessChart extends StatelessWidget {
149+
final LyfiDeviceInfo deviceInfo;
150+
final List<int> brightness;
151+
152+
const _LyfiBrightnessChart({required this.deviceInfo, required this.brightness});
153+
154+
@override
155+
Widget build(BuildContext context) {
156+
final channelCount = deviceInfo.channels.length.clamp(0, brightness.length);
157+
if (channelCount == 1) {
158+
return _buildSingleChannelGauge(context, channelCount);
159+
}
160+
return _buildBarChart(context, channelCount);
161+
}
162+
163+
Widget _buildSingleChannelGauge(BuildContext context, int channelCount) {
164+
final ch = deviceInfo.channels[0];
165+
final value = brightness[0];
166+
final fraction = (value / lyfiBrightnessMax).clamp(0.0, 1.0).toDouble();
167+
final pct = (fraction * 100).round();
168+
final primaryColor = HexColor.fromHex(ch.color);
169+
final trackColor = Theme.of(context).colorScheme.surfaceContainerLow;
170+
return Center(
171+
child: LayoutBuilder(
172+
builder: (context, constraints) {
173+
final size = constraints.biggest.shortestSide;
174+
return SizedBox(
175+
width: size,
176+
height: size,
177+
child: Stack(
178+
alignment: Alignment.center,
179+
children: [
180+
CircularProgressIndicator(
181+
value: fraction,
182+
strokeWidth: size * 0.09,
183+
backgroundColor: trackColor,
184+
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
185+
strokeCap: StrokeCap.round,
186+
),
187+
Column(
188+
mainAxisSize: MainAxisSize.min,
189+
children: [
190+
Text(
191+
'$pct%',
192+
style: Theme.of(context).textTheme.labelLarge?.copyWith(
193+
fontSize: (size * 0.22).clamp(12.0, 22.0),
194+
fontWeight: FontWeight.bold,
195+
color: primaryColor,
196+
),
197+
),
198+
Text(
199+
ch.name,
200+
style: Theme.of(context).textTheme.labelSmall?.copyWith(
201+
fontSize: (size * 0.13).clamp(8.0, 13.0),
202+
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
203+
),
204+
),
205+
],
206+
),
207+
],
208+
),
209+
);
210+
},
211+
),
212+
);
213+
}
214+
215+
Widget _buildBarChart(BuildContext context, int channelCount) {
216+
final colorScheme = Theme.of(context).colorScheme;
217+
final barBackColor = colorScheme.surfaceContainerLow;
218+
219+
// Adaptive bar width: shrink as channel count grows
220+
final barWidth =
221+
(channelCount <= 4
222+
? 18.0
223+
: channelCount <= 6
224+
? 13.0
225+
: channelCount <= 8
226+
? 10.0
227+
: 7.0)
228+
.toDouble();
229+
230+
// Label: abbreviate to fit — fewer chars for many channels
231+
final maxLabelLen = channelCount <= 4
232+
? 4
233+
: channelCount <= 6
234+
? 3
235+
: 2;
236+
237+
final groups = <BarChartGroupData>[];
238+
for (int i = 0; i < channelCount; i++) {
239+
final ch = deviceInfo.channels[i];
240+
final value = brightness[i].toDouble();
241+
final primaryColor = HexColor.fromHex(ch.color);
242+
final lightStart = Color.lerp(primaryColor, Colors.white, 0.7)!;
243+
final fraction = (value / lyfiBrightnessMax).clamp(0.0, 1.0);
244+
final currentEndColor = Color.lerp(lightStart, primaryColor, fraction)!;
245+
246+
groups.add(
247+
BarChartGroupData(
248+
x: i,
249+
barRods: [
250+
BarChartRodData(
251+
toY: value,
252+
borderRadius: BorderRadius.circular(4),
253+
gradient: LinearGradient(
254+
begin: Alignment.bottomCenter,
255+
end: Alignment.topCenter,
256+
colors: [lightStart, currentEndColor],
257+
),
258+
width: barWidth,
259+
backDrawRodData: BackgroundBarChartRodData(
260+
show: true,
261+
fromY: 0,
262+
toY: lyfiBrightnessMax.toDouble(),
263+
color: barBackColor,
264+
),
265+
),
266+
],
267+
),
268+
);
269+
}
270+
271+
return BarChart(
272+
BarChartData(
273+
barGroups: groups,
274+
maxY: lyfiBrightnessMax.toDouble(),
275+
groupsSpace: channelCount > 6 ? 4 : 8,
276+
titlesData: FlTitlesData(
277+
show: true,
278+
bottomTitles: AxisTitles(
279+
sideTitles: SideTitles(
280+
showTitles: true,
281+
reservedSize: 14,
282+
getTitlesWidget: (value, _) {
283+
final idx = value.toInt();
284+
if (idx < 0 || idx >= deviceInfo.channels.length) return const SizedBox.shrink();
285+
final ch = deviceInfo.channels[idx];
286+
final label = ch.name.length > maxLabelLen ? ch.name.substring(0, maxLabelLen) : ch.name;
287+
return Text(
288+
label,
289+
style: Theme.of(context).textTheme.labelSmall?.copyWith(
290+
fontSize: channelCount > 6 ? 8.0 : 9.0,
291+
color: colorScheme.onSurface.withValues(alpha: 0.7),
292+
),
293+
);
294+
},
295+
),
296+
),
297+
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
298+
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
299+
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
300+
),
301+
borderData: FlBorderData(show: false),
302+
gridData: const FlGridData(show: false),
303+
barTouchData: BarTouchData(enabled: false),
304+
),
305+
);
306+
}
307+
}

client/lib/devices/borneo/lyfi/view_models/summary_device_view_model.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ class LyfiSummaryDeviceViewModel extends BaseBorneoSummaryDeviceViewModel {
88
bool _disposed = false;
99
final ValueNotifier<LyfiState?> ledState = ValueNotifier(null);
1010
final ValueNotifier<LyfiMode?> ledMode = ValueNotifier(null);
11+
final ValueNotifier<List<int>?> channelBrightness = ValueNotifier(null);
12+
final ValueNotifier<LyfiDeviceInfo?> lyfiDeviceInfo = ValueNotifier(null);
1113

1214
LyfiSummaryDeviceViewModel(
1315
super.deviceEntity,
@@ -19,15 +21,21 @@ class LyfiSummaryDeviceViewModel extends BaseBorneoSummaryDeviceViewModel {
1921
_syncFromThing();
2022
wotThing?.addSubscriber(_onStateChanged);
2123
wotThing?.addSubscriber(_onModeChanged);
24+
wotThing?.addSubscriber(_onColorChanged);
25+
wotThing?.addSubscriber(_onDeviceInfoChanged);
2226
}
2327

2428
@override
2529
void dispose() {
2630
if (!_disposed) {
2731
wotThing?.removeSubscriber(_onStateChanged);
2832
wotThing?.removeSubscriber(_onModeChanged);
33+
wotThing?.removeSubscriber(_onColorChanged);
34+
wotThing?.removeSubscriber(_onDeviceInfoChanged);
2935
ledState.dispose();
3036
ledMode.dispose();
37+
channelBrightness.dispose();
38+
lyfiDeviceInfo.dispose();
3139
super.dispose();
3240
_disposed = true;
3341
}
@@ -49,13 +57,31 @@ class LyfiSummaryDeviceViewModel extends BaseBorneoSummaryDeviceViewModel {
4957
}
5058
}
5159

60+
void _onColorChanged(WotMessage msg) {
61+
final color = wotThing?.getProperty<List<int>>('color');
62+
if (color != null) {
63+
channelBrightness.value = List<int>.from(color);
64+
}
65+
}
66+
67+
void _onDeviceInfoChanged(WotMessage msg) {
68+
final info = wotThing?.getProperty<LyfiDeviceInfo>('lyfiDeviceInfo');
69+
if (info != null) {
70+
lyfiDeviceInfo.value = info;
71+
}
72+
}
73+
5274
@override
5375
void onWotThingChanged(WotThing? oldThing, WotThing? newThing) {
5476
super.onWotThingChanged(oldThing, newThing);
5577
oldThing?.removeSubscriber(_onStateChanged);
5678
oldThing?.removeSubscriber(_onModeChanged);
79+
oldThing?.removeSubscriber(_onColorChanged);
80+
oldThing?.removeSubscriber(_onDeviceInfoChanged);
5781
newThing?.addSubscriber(_onStateChanged);
5882
newThing?.addSubscriber(_onModeChanged);
83+
newThing?.addSubscriber(_onColorChanged);
84+
newThing?.addSubscriber(_onDeviceInfoChanged);
5985
_syncFromThing();
6086
}
6187

@@ -71,5 +97,15 @@ class LyfiSummaryDeviceViewModel extends BaseBorneoSummaryDeviceViewModel {
7197
final mode = LyfiMode.fromString(modeValue as String);
7298
ledMode.value = mode;
7399
}
100+
101+
final color = wotThing?.getProperty<List<int>>('color');
102+
if (color != null) {
103+
channelBrightness.value = List<int>.from(color);
104+
}
105+
106+
final info = wotThing?.getProperty<LyfiDeviceInfo>('lyfiDeviceInfo');
107+
if (info != null) {
108+
lyfiDeviceInfo.value = info;
109+
}
74110
}
75111
}

client/lib/features/devices/models/device_module_metadata.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ abstract class DeviceModuleMetadata {
2424
createSummaryVM;
2525
final Future<WotThing> Function(DeviceEntity, IDeviceManager, {Logger? logger}) createWotThing;
2626

27+
/// Optional: supply a custom center widget for the device card.
28+
/// When null the card falls back to [deviceIconBuilder].
29+
final Widget Function(BuildContext context, AbstractDeviceSummaryViewModel vm)? summaryContentBuilder;
30+
2731
const DeviceModuleMetadata({
2832
required this.id,
2933
required this.name,
@@ -35,5 +39,6 @@ abstract class DeviceModuleMetadata {
3539
required this.secondaryStatesBuilder,
3640
required this.createSummaryVM,
3741
required this.createWotThing,
42+
this.summaryContentBuilder,
3843
});
3944
}

0 commit comments

Comments
 (0)