Initial version

This commit is contained in:
2026-09-21 18:03:21 -04:00
commit e8e7dc4a81
83 changed files with 7177 additions and 0 deletions
+754
View File
@@ -0,0 +1,754 @@
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),
],
);
}
}