755 lines
26 KiB
Dart
755 lines
26 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../models/scooter_state.dart';
|
|
import '../settings.dart';
|
|
import '../theme.dart';
|
|
|
|
/// Values shared by every cluster layout, already converted for display.
|
|
class ClusterData {
|
|
ClusterData({required this.state, required this.imperial});
|
|
|
|
final ScooterState state;
|
|
final bool imperial;
|
|
|
|
static const _kmToMi = 0.621371;
|
|
|
|
double? _dist(double? km) => km == null ? null : (imperial ? km * _kmToMi : km);
|
|
|
|
double? get speed => _dist(state.speed);
|
|
double? get trip => _dist(state.tripDistance);
|
|
double? get odometer => _dist(state.odometer);
|
|
String get speedUnit => imperial ? 'mph' : 'km/h';
|
|
String get distUnit => imperial ? 'mi' : 'km';
|
|
|
|
/// Gauge full scale: the highest configured mode limit, or 30 as a fallback.
|
|
double get gaugeMax {
|
|
final limit = (state.maxSpeedLimit ?? 30).toDouble();
|
|
final v = imperial ? limit * _kmToMi : limit;
|
|
return v <= 0 ? 1 : v;
|
|
}
|
|
|
|
double get speedFraction => ((speed ?? 0) / gaugeMax).clamp(0.0, 1.0);
|
|
|
|
double get power => state.power ?? 0;
|
|
double get drivePower => power > 0 ? power : 0;
|
|
double get regenPower => power < 0 ? -power : 0;
|
|
|
|
String get speedText => speed == null ? '--' : speed!.round().toString();
|
|
static String fmt(num? v, [int decimals = 1]) => v == null ? '--' : v.toStringAsFixed(decimals);
|
|
}
|
|
|
|
/// Callbacks the layouts use for the control toggles.
|
|
class ClusterActions {
|
|
const ClusterActions({
|
|
required this.toggleHeadlight,
|
|
required this.toggleLock,
|
|
required this.readOnlyTap,
|
|
required this.busy,
|
|
});
|
|
|
|
final VoidCallback toggleHeadlight;
|
|
final VoidCallback toggleLock;
|
|
final void Function(String name) readOnlyTap;
|
|
final bool busy;
|
|
}
|
|
|
|
Widget buildCluster(ClusterLayout layout, ClusterData d, ClusterActions a) => switch (layout) {
|
|
ClusterLayout.arc => ArcCluster(data: d, actions: a),
|
|
ClusterLayout.digital => DigitalCluster(data: d, actions: a),
|
|
ClusterLayout.tiles => TilesCluster(data: d, actions: a),
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Arc layout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class ArcCluster extends StatelessWidget {
|
|
const ArcCluster({super.key, required this.data, required this.actions});
|
|
final ClusterData data;
|
|
final ClusterActions actions;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = data.state;
|
|
return LayoutBuilder(
|
|
builder: (context, box) {
|
|
final gaugeSize = math.min(box.maxWidth - 32, box.maxHeight * 0.5).clamp(220.0, 360.0);
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
|
child: Column(
|
|
children: [
|
|
Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: gaugeSize,
|
|
height: gaugeSize,
|
|
child: CustomPaint(
|
|
painter: _GaugePainter(
|
|
fraction: data.speedFraction,
|
|
battery: s.batteryLevel,
|
|
accent: Theme.of(context).colorScheme.primary,
|
|
),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ModeBadge(gear: s.gear),
|
|
const SizedBox(height: 4),
|
|
BigNumber(text: data.speedText, size: 108),
|
|
Text(data.speedUnit, style: const TextStyle(color: OsColors.textDim, fontSize: 18)),
|
|
const SizedBox(height: 14),
|
|
ValueWithUnit(value: ClusterData.fmt(data.odometer), unit: data.distUnit, size: 26),
|
|
const CapsLabel('ODOMETER'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(top: 8, left: 0, child: Stat(value: ClusterData.fmt(data.regenPower, 0), unit: 'W', label: 'REGEN')),
|
|
Positioned(top: 8, right: 0, child: Stat(value: ClusterData.fmt(data.drivePower, 0), unit: 'W', label: 'POWER', align: CrossAxisAlignment.end)),
|
|
Positioned(bottom: 0, left: 0, child: Stat(value: '${s.controllerTemperature ?? '--'}', unit: '°C', label: 'CONTROLLER')),
|
|
Positioned(bottom: 0, right: 0, child: Stat(value: '${s.motorTemperature ?? '--'}', unit: '°C', label: 'MOTOR', align: CrossAxisAlignment.end)),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
ControlsRow(state: s, actions: actions),
|
|
const SizedBox(height: 20),
|
|
BatteryBar(level: s.batteryLevel, voltage: s.voltage),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
children: [
|
|
Expanded(child: Stat(value: ClusterData.fmt(data.trip), unit: data.distUnit, label: 'TRIP')),
|
|
Expanded(child: Stat(value: ClusterData.fmt(s.current), unit: 'A', label: 'CURRENT', align: CrossAxisAlignment.center)),
|
|
Expanded(child: Stat(value: ClusterData.fmt(s.voltage), unit: 'V', label: 'VOLTAGE', align: CrossAxisAlignment.end)),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
SignalRow(left: s.leftTurnSignal ?? false, right: s.rightTurnSignal ?? false),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GaugePainter extends CustomPainter {
|
|
_GaugePainter({required this.fraction, required this.battery, required this.accent});
|
|
|
|
final double fraction;
|
|
final int? battery;
|
|
final Color accent;
|
|
|
|
static const _sweep = 1.5 * math.pi;
|
|
static const _start = 0.75 * math.pi;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final stroke = size.width * 0.07;
|
|
final rect = Rect.fromLTWH(stroke / 2, stroke / 2, size.width - stroke, size.height - stroke);
|
|
|
|
canvas.drawArc(
|
|
rect, _start, _sweep, false,
|
|
Paint()
|
|
..color = OsColors.track
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = stroke
|
|
..strokeCap = StrokeCap.round,
|
|
);
|
|
if (fraction > 0) {
|
|
canvas.drawArc(
|
|
rect, _start, _sweep * fraction, false,
|
|
Paint()
|
|
..shader = SweepGradient(
|
|
startAngle: _start,
|
|
endAngle: _start + _sweep,
|
|
colors: [accent.withValues(alpha: 0.55), accent],
|
|
).createShader(rect)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = stroke
|
|
..strokeCap = StrokeCap.round,
|
|
);
|
|
}
|
|
|
|
final b = battery;
|
|
if (b != null) {
|
|
const gapStart = _start + _sweep;
|
|
const gapSweep = 0.5 * math.pi;
|
|
const margin = 0.09;
|
|
final inner = rect.deflate(stroke * 0.15);
|
|
canvas.drawArc(
|
|
inner, gapStart + margin, gapSweep - 2 * margin, false,
|
|
Paint()
|
|
..color = OsColors.track
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = stroke * 0.7
|
|
..strokeCap = StrokeCap.round,
|
|
);
|
|
final frac = (b / 100).clamp(0.0, 1.0);
|
|
if (frac > 0) {
|
|
canvas.drawArc(
|
|
inner, gapStart + margin, (gapSweep - 2 * margin) * frac, false,
|
|
Paint()
|
|
..color = OsColors.batteryColor(b)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = stroke * 0.7
|
|
..strokeCap = StrokeCap.round,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_GaugePainter old) =>
|
|
old.fraction != fraction || old.battery != battery || old.accent != accent;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Digital layout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class DigitalCluster extends StatelessWidget {
|
|
const DigitalCluster({super.key, required this.data, required this.actions});
|
|
final ClusterData data;
|
|
final ClusterActions actions;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = data.state;
|
|
final accent = Theme.of(context).colorScheme.primary;
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
ModeBadge(gear: s.gear),
|
|
SignalRow(left: s.leftTurnSignal ?? false, right: s.rightTurnSignal ?? false, compact: true),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
BigNumber(text: data.speedText, size: 150),
|
|
const SizedBox(width: 10),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 22),
|
|
child: Text(data.speedUnit, style: const TextStyle(color: OsColors.textDim, fontSize: 22)),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: SizedBox(
|
|
height: 14,
|
|
child: Stack(
|
|
children: [
|
|
Container(color: OsColors.track),
|
|
FractionallySizedBox(
|
|
widthFactor: data.speedFraction,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(colors: [accent.withValues(alpha: 0.6), accent]),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const CapsLabel('0'),
|
|
CapsLabel('${data.gaugeMax.round()} ${data.speedUnit}'),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
children: [
|
|
Expanded(child: Stat(value: ClusterData.fmt(data.drivePower, 0), unit: 'W', label: 'POWER')),
|
|
Expanded(child: Stat(value: ClusterData.fmt(data.regenPower, 0), unit: 'W', label: 'REGEN', align: CrossAxisAlignment.center)),
|
|
Expanded(child: Stat(value: ClusterData.fmt(s.current), unit: 'A', label: 'CURRENT', align: CrossAxisAlignment.end)),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
BatteryBar(level: s.batteryLevel, voltage: s.voltage),
|
|
const SizedBox(height: 24),
|
|
ControlsRow(state: s, actions: actions),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
children: [
|
|
Expanded(child: Stat(value: ClusterData.fmt(data.trip), unit: data.distUnit, label: 'TRIP')),
|
|
Expanded(child: Stat(value: ClusterData.fmt(data.odometer), unit: data.distUnit, label: 'ODOMETER', align: CrossAxisAlignment.end)),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
children: [
|
|
Expanded(child: Stat(value: '${s.motorTemperature ?? '--'}', unit: '°C', label: 'MOTOR')),
|
|
Expanded(child: Stat(value: '${s.controllerTemperature ?? '--'}', unit: '°C', label: 'CONTROLLER', align: CrossAxisAlignment.end)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tiles layout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class TilesCluster extends StatelessWidget {
|
|
const TilesCluster({super.key, required this.data, required this.actions});
|
|
final ClusterData data;
|
|
final ClusterActions actions;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = data.state;
|
|
final accent = Theme.of(context).colorScheme.primary;
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Tile(
|
|
accent: true,
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const CapsLabel('SPEED'),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
BigNumber(text: data.speedText, size: 88),
|
|
const SizedBox(width: 8),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Text(data.speedUnit, style: const TextStyle(color: OsColors.textDim, fontSize: 18)),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
ModeBadge(gear: s.gear),
|
|
const SizedBox(height: 12),
|
|
SignalRow(left: s.leftTurnSignal ?? false, right: s.rightTurnSignal ?? false, compact: true),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Tile(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const CapsLabel('BATTERY'),
|
|
const SizedBox(height: 8),
|
|
ValueWithUnit(value: '${s.batteryLevel ?? '--'}', unit: '%', size: 40),
|
|
const SizedBox(height: 10),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: LinearProgressIndicator(
|
|
minHeight: 8,
|
|
value: ((s.batteryLevel ?? 0) / 100).clamp(0.0, 1.0),
|
|
backgroundColor: OsColors.track,
|
|
color: OsColors.batteryColor(s.batteryLevel),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
ValueWithUnit(value: ClusterData.fmt(s.voltage), unit: 'V', size: 18),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Tile(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const CapsLabel('POWER'),
|
|
const SizedBox(height: 8),
|
|
ValueWithUnit(value: ClusterData.fmt(data.drivePower, 0), unit: 'W', size: 40),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
Icon(Icons.bolt_rounded, size: 16, color: accent),
|
|
const SizedBox(width: 4),
|
|
Text('${ClusterData.fmt(s.current)} A', style: const TextStyle(color: OsColors.textDim)),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.replay_rounded, size: 16, color: OsColors.good),
|
|
const SizedBox(width: 4),
|
|
Text('${ClusterData.fmt(data.regenPower, 0)} W regen', style: const TextStyle(color: OsColors.textDim)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _SmallTile(label: 'TRIP', value: ClusterData.fmt(data.trip), unit: data.distUnit)),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: _SmallTile(label: 'ODOMETER', value: ClusterData.fmt(data.odometer), unit: data.distUnit)),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _SmallTile(label: 'MOTOR', value: '${s.motorTemperature ?? '--'}', unit: '°C')),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: _SmallTile(label: 'CONTROLLER', value: '${s.controllerTemperature ?? '--'}', unit: '°C')),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Tile(child: ControlsRow(state: s, actions: actions)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SmallTile extends StatelessWidget {
|
|
const _SmallTile({required this.label, required this.value, required this.unit});
|
|
final String label;
|
|
final String value;
|
|
final String unit;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Tile(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
CapsLabel(label),
|
|
const SizedBox(height: 6),
|
|
ValueWithUnit(value: value, unit: unit, size: 30),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class Tile extends StatelessWidget {
|
|
const Tile({super.key, required this.child, this.accent = false});
|
|
final Widget child;
|
|
final bool accent;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final primary = Theme.of(context).colorScheme.primary;
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: OsColors.surface,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: accent ? primary.withValues(alpha: 0.5) : OsColors.surfaceHigh),
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared pieces
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class BigNumber extends StatelessWidget {
|
|
const BigNumber({super.key, required this.text, required this.size});
|
|
final String text;
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Text(
|
|
text,
|
|
style: TextStyle(
|
|
fontSize: size,
|
|
height: 1.0,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -size * 0.035,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
);
|
|
}
|
|
|
|
class ValueWithUnit extends StatelessWidget {
|
|
const ValueWithUnit({super.key, required this.value, required this.unit, required this.size});
|
|
final String value;
|
|
final String unit;
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Text.rich(
|
|
TextSpan(
|
|
text: value,
|
|
style: TextStyle(
|
|
fontSize: size,
|
|
fontWeight: FontWeight.w700,
|
|
height: 1.0,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
children: [
|
|
TextSpan(
|
|
text: ' $unit',
|
|
style: TextStyle(fontSize: size * 0.5, fontWeight: FontWeight.w400, color: OsColors.textDim),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class Stat extends StatelessWidget {
|
|
const Stat({
|
|
super.key,
|
|
required this.value,
|
|
required this.unit,
|
|
required this.label,
|
|
this.align = CrossAxisAlignment.start,
|
|
});
|
|
|
|
final String value;
|
|
final String unit;
|
|
final String label;
|
|
final CrossAxisAlignment align;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Column(
|
|
crossAxisAlignment: align,
|
|
children: [
|
|
ValueWithUnit(value: value, unit: unit, size: 30),
|
|
const SizedBox(height: 2),
|
|
CapsLabel(label),
|
|
],
|
|
);
|
|
}
|
|
|
|
class CapsLabel extends StatelessWidget {
|
|
const CapsLabel(this.text, {super.key});
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
Text(text, style: const TextStyle(color: OsColors.textDim, fontSize: 11, letterSpacing: 1.2));
|
|
}
|
|
|
|
class ModeBadge extends StatelessWidget {
|
|
const ModeBadge({super.key, required this.gear});
|
|
final int? gear;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final primary = Theme.of(context).colorScheme.primary;
|
|
// INFERRED mode names for gears 1..3; falls back to the raw gear number.
|
|
final (label, color) = switch (gear) {
|
|
1 => ('Eco', OsColors.good),
|
|
2 => ('Comfort', primary),
|
|
3 => ('Sport', OsColors.bad),
|
|
null => ('--', OsColors.surfaceHigh),
|
|
final g => ('Gear $g', OsColors.surfaceHigh),
|
|
};
|
|
final fg = color.computeLuminance() > 0.5 ? OsColors.background : Colors.white;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 6),
|
|
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)),
|
|
child: Text(label, style: TextStyle(fontWeight: FontWeight.w800, fontSize: 17, color: fg)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ControlsRow extends StatelessWidget {
|
|
const ControlsRow({super.key, required this.state, required this.actions});
|
|
final ScooterState state;
|
|
final ClusterActions actions;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = state;
|
|
final locked = s.locked ?? false;
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
RoundToggle(
|
|
icon: Icons.highlight_rounded,
|
|
active: s.headlight ?? false,
|
|
tooltip: 'Headlight',
|
|
onTap: actions.busy ? null : actions.toggleHeadlight,
|
|
),
|
|
RoundToggle(
|
|
icon: Icons.light_mode_outlined,
|
|
active: s.atmosphereLight ?? false,
|
|
tooltip: 'Atmosphere light',
|
|
onTap: () => actions.readOnlyTap('Atmosphere light'),
|
|
),
|
|
RoundToggle(
|
|
icon: Icons.speed_rounded,
|
|
active: s.cruiseControl ?? false,
|
|
tooltip: 'Cruise control',
|
|
onTap: () => actions.readOnlyTap('Cruise control'),
|
|
),
|
|
RoundToggle(
|
|
icon: locked ? Icons.lock_rounded : Icons.lock_open_rounded,
|
|
active: locked,
|
|
activeColor: OsColors.bad,
|
|
tooltip: locked ? 'Locked' : 'Unlocked',
|
|
onTap: actions.busy ? null : actions.toggleLock,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class RoundToggle extends StatelessWidget {
|
|
const RoundToggle({
|
|
super.key,
|
|
required this.icon,
|
|
required this.active,
|
|
required this.tooltip,
|
|
this.onTap,
|
|
this.activeColor,
|
|
});
|
|
|
|
final IconData icon;
|
|
final bool active;
|
|
final String tooltip;
|
|
final VoidCallback? onTap;
|
|
final Color? activeColor;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = activeColor ?? Theme.of(context).colorScheme.primary;
|
|
final bg = active ? color : OsColors.surfaceHigh;
|
|
final fg = active
|
|
? (color.computeLuminance() > 0.5 ? OsColors.background : Colors.white)
|
|
: OsColors.textDim;
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: Material(
|
|
color: bg,
|
|
shape: const CircleBorder(),
|
|
child: InkWell(
|
|
customBorder: const CircleBorder(),
|
|
onTap: onTap,
|
|
child: SizedBox(width: 62, height: 62, child: Icon(icon, color: fg, size: 27)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Battery bar. Both readings are centred as a group inside the fill.
|
|
class BatteryBar extends StatelessWidget {
|
|
const BatteryBar({super.key, required this.level, required this.voltage});
|
|
final int? level;
|
|
final double? voltage;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = level;
|
|
final frac = l == null ? 0.0 : (l / 100).clamp(0.0, 1.0);
|
|
final color = OsColors.batteryColor(l);
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
height: 92,
|
|
decoration: BoxDecoration(
|
|
color: OsColors.surface,
|
|
border: Border.all(color: OsColors.surfaceHigh, width: 2),
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FractionallySizedBox(
|
|
widthFactor: frac,
|
|
heightFactor: 1,
|
|
child: Container(color: color.withValues(alpha: 0.28)),
|
|
),
|
|
),
|
|
Center(
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
ValueWithUnit(value: l?.toString() ?? '--', unit: '%', size: 46),
|
|
Container(
|
|
width: 1,
|
|
height: 40,
|
|
margin: const EdgeInsets.symmetric(horizontal: 22),
|
|
color: OsColors.surfaceHigh,
|
|
),
|
|
ValueWithUnit(value: ClusterData.fmt(voltage), unit: 'V', size: 38),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 5),
|
|
Container(
|
|
width: 8,
|
|
height: 30,
|
|
decoration: const BoxDecoration(
|
|
color: OsColors.surfaceHigh,
|
|
borderRadius: BorderRadius.horizontal(right: Radius.circular(4)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class SignalRow extends StatelessWidget {
|
|
const SignalRow({super.key, required this.left, required this.right, this.compact = false});
|
|
final bool left;
|
|
final bool right;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final size = compact ? 22.0 : 28.0;
|
|
if (!left && !right && !compact) return SizedBox(height: size);
|
|
return Row(
|
|
mainAxisSize: compact ? MainAxisSize.min : MainAxisSize.max,
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Icon(Icons.arrow_back_rounded, size: size, color: left ? OsColors.good : OsColors.track),
|
|
if (compact) const SizedBox(width: 8),
|
|
Icon(Icons.arrow_forward_rounded, size: size, color: right ? OsColors.good : OsColors.track),
|
|
],
|
|
);
|
|
}
|
|
}
|