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), ], ); } }