Initial version
This commit is contained in:
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../services/protocol_log.dart';
|
||||
|
||||
/// In-app view of the persistent protocol log, for field debugging.
|
||||
class LogScreen extends StatelessWidget {
|
||||
const LogScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final log = ProtocolLog.instance;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Protocol log'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy all',
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: log.lines.join('\n')));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Log copied to clipboard')));
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: log.clear,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: log,
|
||||
builder: (context, _) {
|
||||
final lines = log.lines;
|
||||
return Column(
|
||||
children: [
|
||||
if (log.path != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: SelectableText(
|
||||
'adb pull ${log.path}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
itemCount: lines.length,
|
||||
itemBuilder: (context, i) {
|
||||
final line = lines[lines.length - 1 - i];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
child: SelectableText(
|
||||
line,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/scooter_device.dart';
|
||||
import '../scooters/apollo_scooter.dart';
|
||||
import '../services/ble_client.dart';
|
||||
import '../services/demo_ble_client.dart';
|
||||
import '../theme.dart';
|
||||
import 'scooter_screen.dart';
|
||||
|
||||
class ScanScreen extends StatefulWidget {
|
||||
const ScanScreen({super.key, required this.ble});
|
||||
|
||||
final BleClient ble;
|
||||
|
||||
@override
|
||||
State<ScanScreen> createState() => _ScanScreenState();
|
||||
}
|
||||
|
||||
class _ScanScreenState extends State<ScanScreen> {
|
||||
Stream<List<ScooterDevice>>? _scan;
|
||||
|
||||
/// Development path: list every BLE device so a scooter that does not
|
||||
/// advertise F1F0/F2F0 can still be selected and classified after GATT
|
||||
/// discovery.
|
||||
bool _showAll = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scan = widget.ble.scan();
|
||||
}
|
||||
|
||||
void _restart() => setState(() => _scan = widget.ble.scan());
|
||||
|
||||
Future<void> _connect(ScooterDevice device, {BleClient? ble}) async {
|
||||
setState(() => _scan = null);
|
||||
await widget.ble.stopScan();
|
||||
if (!mounted) return;
|
||||
// Create the scooter ONCE. Route builders re-run on every rebuild (for
|
||||
// example a theme change), so constructing it inside the builder would
|
||||
// silently swap in a fresh, unconnected instance.
|
||||
final scooter = ApolloScooter(ble ?? widget.ble, device);
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => ScooterScreen(scooter: scooter)),
|
||||
);
|
||||
if (mounted) _restart();
|
||||
}
|
||||
|
||||
/// Replays real Apollo Go frames through a fake link so layouts and colours
|
||||
/// can be previewed without a vehicle nearby. Not linked from the UI for
|
||||
/// now; kept for development.
|
||||
// ignore: unused_element
|
||||
void _openDemo() => _connect(DemoBleClient.device, ble: DemoBleClient());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('OpenMotion',
|
||||
style: TextStyle(fontSize: 32, fontWeight: FontWeight.w800, letterSpacing: -1)),
|
||||
SizedBox(height: 4),
|
||||
Text('Open source scooting!',
|
||||
style: TextStyle(color: OsColors.textDim)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: _showAll ? 'Show scooters only' : 'Show all BLE devices',
|
||||
icon: Icon(_showAll ? Icons.filter_alt_off_rounded : Icons.filter_alt_rounded),
|
||||
onPressed: () => setState(() => _showAll = !_showAll),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Restart scan',
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
onPressed: _restart,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _scan == null
|
||||
? const SizedBox.shrink()
|
||||
: StreamBuilder<List<ScooterDevice>>(
|
||||
stream: _scan,
|
||||
builder: (context, snap) {
|
||||
if (snap.hasError) {
|
||||
return _Empty(
|
||||
icon: Icons.bluetooth_disabled_rounded,
|
||||
title: 'Bluetooth scan failed',
|
||||
message: '${snap.error}',
|
||||
action: FilledButton(onPressed: _restart, child: const Text('Retry')),
|
||||
);
|
||||
}
|
||||
final all = snap.data ?? const <ScooterDevice>[];
|
||||
final devices = (_showAll
|
||||
? all
|
||||
: all.where((d) => ApolloScooter.matches(d) || ApolloScooter.nameHint(d)))
|
||||
.toList()
|
||||
..sort((a, b) => b.rssi.compareTo(a.rssi));
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_showAll ? 'ALL BLE DEVICES' : 'NEARBY SCOOTERS',
|
||||
style: const TextStyle(
|
||||
color: OsColors.textDim, fontSize: 12, letterSpacing: 1.2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (devices.isEmpty)
|
||||
_Empty(
|
||||
icon: Icons.electric_scooter_rounded,
|
||||
title: 'Searching',
|
||||
message: 'Turn the scooter on and keep it nearby.',
|
||||
),
|
||||
for (final d in devices) ...[
|
||||
_DeviceCard(device: d, onConnect: () => _connect(d)),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceCard extends StatelessWidget {
|
||||
const _DeviceCard({required this.device, required this.onConnect});
|
||||
|
||||
final ScooterDevice device;
|
||||
final VoidCallback onConnect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isApollo = ApolloScooter.matches(device);
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final bars = device.rssi > -60 ? 4 : (device.rssi > -70 ? 3 : (device.rssi > -80 ? 2 : 1));
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onConnect,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: isApollo ? accent.withValues(alpha: 0.15) : OsColors.surfaceHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
isApollo ? Icons.electric_scooter_rounded : Icons.bluetooth_rounded,
|
||||
color: isApollo ? accent : OsColors.textDim,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
device.name.isEmpty ? 'Unnamed device' : device.name,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
isApollo
|
||||
? 'Apollo · ${device.rssi} dBm'
|
||||
: '${device.id} · ${device.rssi} dBm',
|
||||
style: const TextStyle(color: OsColors.textDim, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
switch (bars) {
|
||||
4 => Icons.signal_cellular_alt_rounded,
|
||||
3 => Icons.signal_cellular_alt_2_bar_rounded,
|
||||
_ => Icons.signal_cellular_alt_1_bar_rounded,
|
||||
},
|
||||
color: OsColors.textDim,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.chevron_right_rounded, color: OsColors.textDim),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Empty extends StatelessWidget {
|
||||
const _Empty({required this.icon, required this.title, required this.message, this.action});
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String message;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 48, 24, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 56, color: OsColors.track),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 6),
|
||||
Text(message, textAlign: TextAlign.center, style: const TextStyle(color: OsColors.textDim)),
|
||||
if (action != null) ...[const SizedBox(height: 16), action!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/scooter_state.dart';
|
||||
import '../scooters/apollo_protocol.dart';
|
||||
import '../scooters/apollo_scooter.dart';
|
||||
import '../scooters/scooter.dart';
|
||||
import '../services/pin_store.dart';
|
||||
import '../settings.dart';
|
||||
import '../theme.dart';
|
||||
import 'clusters.dart';
|
||||
import 'log_screen.dart';
|
||||
|
||||
class ScooterScreen extends StatefulWidget {
|
||||
const ScooterScreen({super.key, required this.scooter});
|
||||
|
||||
final ApolloScooter scooter;
|
||||
|
||||
@override
|
||||
State<ScooterScreen> createState() => _ScooterScreenState();
|
||||
}
|
||||
|
||||
class _ScooterScreenState extends State<ScooterScreen> {
|
||||
final _pin = TextEditingController();
|
||||
final _pinStore = PinStore();
|
||||
String? _pinError;
|
||||
bool _busy = false;
|
||||
bool _hasSavedPin = false;
|
||||
bool _autoAuthTried = false;
|
||||
|
||||
ApolloScooter get scooter => widget.scooter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
scooter.addListener(_onScooterChanged);
|
||||
_loadSavedPin();
|
||||
scooter.connect().catchError((_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scooter.removeListener(_onScooterChanged);
|
||||
_pin.dispose();
|
||||
scooter.disposeScooter();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ---- PIN -----------------------------------------------------------------
|
||||
|
||||
Future<void> _loadSavedPin() async {
|
||||
final saved = await _pinStore.read(scooter.device.id);
|
||||
if (!mounted || saved == null) return;
|
||||
setState(() {
|
||||
_pin.text = saved;
|
||||
_hasSavedPin = true;
|
||||
});
|
||||
_maybeAutoAuthenticate();
|
||||
}
|
||||
|
||||
void _onScooterChanged() => _maybeAutoAuthenticate();
|
||||
|
||||
void _maybeAutoAuthenticate() {
|
||||
if (_autoAuthTried || !_hasSavedPin || _busy) return;
|
||||
if (scooter.state.connectionStatus != ScooterConnectionStatus.connected) return;
|
||||
_autoAuthTried = true;
|
||||
_authenticate();
|
||||
}
|
||||
|
||||
Future<void> _forgetPin() async {
|
||||
await _pinStore.forget(scooter.device.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasSavedPin = false;
|
||||
_pin.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _authenticate() async {
|
||||
final pin = _pin.text.trim();
|
||||
if (!RegExp(r'^\d{6}$').hasMatch(pin)) {
|
||||
setState(() => _pinError = 'Enter the six-digit scooter PIN.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_pinError = null;
|
||||
_busy = true;
|
||||
});
|
||||
try {
|
||||
final result = await scooter.authenticate(pin);
|
||||
if (result == AuthenticationResult.invalidCredential) {
|
||||
setState(() => _pinError = 'Incorrect scooter PIN.');
|
||||
if (_hasSavedPin) await _forgetPin();
|
||||
} else {
|
||||
await _pinStore.save(scooter.device.id, pin);
|
||||
if (mounted) setState(() => _hasSavedPin = true);
|
||||
}
|
||||
} on TimeoutException {
|
||||
setState(() => _pinError = 'The scooter did not respond.');
|
||||
} on ScooterConnectionLostException {
|
||||
// Shown by the status overlay.
|
||||
} catch (e) {
|
||||
setState(() => _pinError = 'Authentication failed: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconnect() async {
|
||||
_autoAuthTried = false;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await scooter.connect();
|
||||
} catch (_) {
|
||||
// Reflected in scooter.state and shown by the overlay.
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- controls ------------------------------------------------------------
|
||||
|
||||
Future<void> _run(Future<void> Function() action) async {
|
||||
if (!scooter.controlWritesEnabled) {
|
||||
_snack('Control writes are currently disabled in this build.');
|
||||
return;
|
||||
}
|
||||
if (!scooter.canWrite) {
|
||||
_snack('Scooter connection is still initializing... Try this action again shortly.');
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await action();
|
||||
} on TimeoutException catch (e) {
|
||||
_snack(e.message ?? 'The scooter did not confirm the change.');
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String text) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(text)));
|
||||
}
|
||||
|
||||
bool _isImperial(ScooterState s) => switch (AppSettings.instance.units) {
|
||||
UnitPreference.auto => s.imperial ?? false,
|
||||
UnitPreference.metric => false,
|
||||
UnitPreference.imperial => true,
|
||||
};
|
||||
|
||||
// ---- build ---------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: ListenableBuilder(
|
||||
listenable: Listenable.merge([scooter, AppSettings.instance]),
|
||||
builder: (context, _) {
|
||||
final s = scooter.state;
|
||||
final actions = ClusterActions(
|
||||
busy: _busy,
|
||||
toggleHeadlight: () => _run(() => scooter.setHeadlight(!(s.headlight ?? false))),
|
||||
toggleLock: () => _run((s.locked ?? false) ? scooter.unlock : scooter.lock),
|
||||
readOnlyTap: (name) => _snack('$name is read-only for now.'),
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
_topBar(s),
|
||||
Expanded(
|
||||
child: buildCluster(
|
||||
AppSettings.instance.layout,
|
||||
ClusterData(state: s, imperial: _isImperial(s)),
|
||||
actions,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
?_overlayFor(s),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar(ScooterState s) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
scooter.device.name.isEmpty ? 'Scooter' : scooter.device.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
|
||||
),
|
||||
_ConnectionPill(status: s.connectionStatus),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Settings and diagnostics',
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
onPressed: () => _openSettings(s),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Full-screen overlays for the states where the dashboard has nothing
|
||||
/// meaningful to show yet.
|
||||
Widget? _overlayFor(ScooterState s) {
|
||||
switch (s.connectionStatus) {
|
||||
case ScooterConnectionStatus.connecting:
|
||||
return _Overlay(child: _ConnectingCard(step: s.connectionStep ?? 0));
|
||||
case ScooterConnectionStatus.connected:
|
||||
case ScooterConnectionStatus.authenticating:
|
||||
return _Overlay(child: _pinCard(s));
|
||||
case ScooterConnectionStatus.error:
|
||||
case ScooterConnectionStatus.disconnected:
|
||||
return _Overlay(
|
||||
child: _MessageCard(
|
||||
icon: Icons.bluetooth_disabled_rounded,
|
||||
title: s.connectionStatus == ScooterConnectionStatus.error
|
||||
? 'Connection problem'
|
||||
: 'Disconnected',
|
||||
message: s.errorMessage ?? 'The scooter is not connected.',
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _reconnect,
|
||||
child: const Text('Reconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
case ScooterConnectionStatus.authenticated:
|
||||
return const _Overlay(
|
||||
dim: 0.7,
|
||||
child: _MessageCard(
|
||||
icon: Icons.podcasts_rounded,
|
||||
title: 'PIN accepted',
|
||||
message: 'Waiting for the scooter to start streaming telemetry.',
|
||||
body: _Spinner(label: 'Usually under a second'),
|
||||
),
|
||||
);
|
||||
case ScooterConnectionStatus.ready:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _pinCard(ScooterState s) {
|
||||
final authenticating = s.connectionStatus == ScooterConnectionStatus.authenticating;
|
||||
return _MessageCard(
|
||||
icon: Icons.lock_outline_rounded,
|
||||
title: 'Enter scooter PIN',
|
||||
message: 'The six-digit Bluetooth PIN from your scooter.',
|
||||
body: TextField(
|
||||
controller: _pin,
|
||||
enabled: !authenticating && !_busy,
|
||||
autofocus: !_hasSavedPin,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
obscureText: true,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 30, letterSpacing: 14, fontWeight: FontWeight.w700),
|
||||
decoration: InputDecoration(counterText: '', hintText: '••••••', errorText: _pinError),
|
||||
onSubmitted: (_) => _authenticate(),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: authenticating || _busy ? null : _authenticate,
|
||||
child: authenticating
|
||||
? const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
SizedBox(width: 8),
|
||||
Text('Unlocking...'),
|
||||
],
|
||||
)
|
||||
: const Text('Unlock Scooter'),
|
||||
),
|
||||
if (_hasSavedPin)
|
||||
TextButton(onPressed: _forgetPin, child: const Text('Forget saved PIN')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openSettings(ScooterState s) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: OsColors.surface,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => ListenableBuilder(
|
||||
listenable: Listenable.merge([scooter, AppSettings.instance]),
|
||||
builder: (ctx, _) => StatefulBuilder(
|
||||
builder: (ctx, setSheet) => _SettingsSheet(
|
||||
scooter: scooter,
|
||||
hasSavedPin: _hasSavedPin,
|
||||
onForgetPin: () async {
|
||||
await _forgetPin();
|
||||
setSheet(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlays and shared bits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ConnectionPill extends StatelessWidget {
|
||||
const _ConnectionPill({required this.status});
|
||||
final ScooterConnectionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (status) {
|
||||
ScooterConnectionStatus.disconnected => ('Disconnected', OsColors.textDim),
|
||||
ScooterConnectionStatus.connecting => ('Connecting', OsColors.warn),
|
||||
ScooterConnectionStatus.connected => ('PIN required', OsColors.warn),
|
||||
ScooterConnectionStatus.authenticating => ('Checking PIN', OsColors.warn),
|
||||
ScooterConnectionStatus.authenticated => ('Waiting for data', OsColors.warn),
|
||||
ScooterConnectionStatus.ready => ('Connected', OsColors.good),
|
||||
ScooterConnectionStatus.error => ('Error', OsColors.bad),
|
||||
};
|
||||
return Row(
|
||||
children: [
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: const TextStyle(color: OsColors.textDim, fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Overlay extends StatelessWidget {
|
||||
const _Overlay({required this.child, this.dim = 0.92});
|
||||
final Widget child;
|
||||
final double dim;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: Container(
|
||||
color: OsColors.background.withValues(alpha: dim),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView(child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Step-by-step view of what the app is doing while the link comes up.
|
||||
class _ConnectingCard extends StatelessWidget {
|
||||
const _ConnectingCard({required this.step});
|
||||
final int step;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final steps = ScooterState.connectionSteps;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(Icons.bluetooth_searching_rounded, size: 40, color: primary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Connecting', textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'This should only take a few moments.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: OsColors.textDim),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
for (var i = 0; i < steps.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: i < step
|
||||
? Icon(Icons.check_circle_rounded, color: OsColors.good, size: 22)
|
||||
: i == step
|
||||
? CircularProgressIndicator(strokeWidth: 2.5, color: primary)
|
||||
: const Icon(Icons.circle_outlined, color: OsColors.track, size: 22),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
steps[i],
|
||||
style: TextStyle(
|
||||
color: i <= step ? OsColors.text : OsColors.textDim,
|
||||
fontWeight: i == step ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Spinner extends StatelessWidget {
|
||||
const _Spinner({required this.label});
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3)),
|
||||
const SizedBox(height: 20),
|
||||
Text(label, style: const TextStyle(color: OsColors.textDim, fontSize: 16)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageCard extends StatelessWidget {
|
||||
const _MessageCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.body,
|
||||
this.actions = const [],
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String message;
|
||||
final Widget? body;
|
||||
final List<Widget> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(icon, size: 40, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 6),
|
||||
Text(message, textAlign: TextAlign.center, style: const TextStyle(color: OsColors.textDim)),
|
||||
if (body != null) ...[const SizedBox(height: 20), body!],
|
||||
const SizedBox(height: 20),
|
||||
...actions,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsSheet extends StatelessWidget {
|
||||
const _SettingsSheet({
|
||||
required this.scooter,
|
||||
required this.hasSavedPin,
|
||||
required this.onForgetPin,
|
||||
});
|
||||
|
||||
final ApolloScooter scooter;
|
||||
final bool hasSavedPin;
|
||||
final VoidCallback onForgetPin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = scooter.state;
|
||||
final settings = AppSettings.instance;
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
child: Text('Settings', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800)),
|
||||
),
|
||||
const _SectionLabel('CLUSTER LAYOUT'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final layout in ClusterLayout.values) ...[
|
||||
Expanded(
|
||||
child: _LayoutChoice(
|
||||
layout: layout,
|
||||
selected: settings.layout == layout,
|
||||
onTap: () => settings.layout = layout,
|
||||
),
|
||||
),
|
||||
if (layout != ClusterLayout.values.last) const SizedBox(width: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _SectionLabel('ACCENT COLOR'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final a in AccentColor.values)
|
||||
_ColorDot(
|
||||
color: a.color,
|
||||
label: a.label,
|
||||
selected: settings.accent == a,
|
||||
onTap: () => settings.accent = a,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const _SectionLabel('UNITS'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||
child: SegmentedButton<UnitPreference>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: UnitPreference.auto, label: Text('Auto')),
|
||||
ButtonSegment(value: UnitPreference.metric, label: Text('km')),
|
||||
ButtonSegment(value: UnitPreference.imperial, label: Text('mi')),
|
||||
],
|
||||
selected: {settings.units},
|
||||
onSelectionChanged: (v) => settings.units = v.first,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
|
||||
child: Text(
|
||||
'Auto follows the scooter. Distances assume native km until a ride confirms it.',
|
||||
style: TextStyle(color: OsColors.textDim, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
SwitchListTile(
|
||||
title: const Text('Keepalive'),
|
||||
subtitle: const Text('The scooter only streams data while it receives this every second.'),
|
||||
value: scooter.keepaliveInterval != null,
|
||||
onChanged: (v) => scooter.setKeepaliveInterval(v ? apolloDefaultKeepaliveInterval : null),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Forget saved PIN'),
|
||||
enabled: hasSavedPin,
|
||||
trailing: const Icon(Icons.delete_outline_rounded),
|
||||
onTap: hasSavedPin ? onForgetPin : null,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Protocol log'),
|
||||
subtitle: const Text('Raw BLE traffic for debugging'),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const LogScreen()));
|
||||
},
|
||||
),
|
||||
const Divider(height: 24),
|
||||
const _SectionLabel('SCOOTER DETAILS'),
|
||||
_kv('Address', scooter.device.id),
|
||||
_kv('Speed limits', s.maxSpeedLimit == null ? '--' : 'current gear ${s.speedLimit ?? '--'}, max ${s.maxSpeedLimit}'),
|
||||
_kv('Battery temperature', s.batteryTemperature == null ? '--' : '${s.batteryTemperature} °C'),
|
||||
_kv('Battery cycles', '${s.batteryCycles ?? '--'}'),
|
||||
_kv('Display', '${s.displayId ?? 'none'} ${s.displayVersion ?? ''}'),
|
||||
_kv('Scooter unit bit', s.imperial == null ? '--' : (s.imperial! ? 'imperial' : 'metric')),
|
||||
_kv('Control writes', scooter.controlWritesEnabled ? (scooter.canWrite ? 'enabled' : 'waiting') : 'read-only build'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kv(String k, String v) => ListTile(
|
||||
dense: true,
|
||||
title: Text(k),
|
||||
trailing: Text(v, style: const TextStyle(color: OsColors.textDim)),
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
child: CapsLabel(text),
|
||||
);
|
||||
}
|
||||
|
||||
class _LayoutChoice extends StatelessWidget {
|
||||
const _LayoutChoice({required this.layout, required this.selected, required this.onTap});
|
||||
final ClusterLayout layout;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: OsColors.surfaceHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: selected ? primary : Colors.transparent, width: 2),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 44, child: _LayoutPreview(layout: layout, color: selected ? primary : OsColors.textDim)),
|
||||
const SizedBox(height: 8),
|
||||
Text(layout.label, style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
Text(layout.description, style: const TextStyle(color: OsColors.textDim, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny schematic of each layout for the picker.
|
||||
class _LayoutPreview extends StatelessWidget {
|
||||
const _LayoutPreview({required this.layout, required this.color});
|
||||
final ClusterLayout layout;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget bar(double w, double h) => Container(
|
||||
width: w,
|
||||
height: h,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(3)),
|
||||
);
|
||||
return switch (layout) {
|
||||
ClusterLayout.arc => Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: color, width: 4)),
|
||||
),
|
||||
),
|
||||
ClusterLayout.digital => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [bar(36, 18), const SizedBox(height: 6), bar(60, 6)],
|
||||
),
|
||||
ClusterLayout.tiles => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(children: [bar(26, 16), const SizedBox(width: 4), bar(26, 16)]),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [bar(26, 16), const SizedBox(width: 4), bar(26, 16)]),
|
||||
],
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorDot extends StatelessWidget {
|
||||
const _ColorDot({required this.color, required this.label, required this.selected, required this.onTap});
|
||||
final Color color;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: label,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: selected ? Colors.white : Colors.transparent, width: 3),
|
||||
),
|
||||
child: selected
|
||||
? Icon(Icons.check_rounded, color: color.computeLuminance() > 0.5 ? OsColors.background : Colors.white)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user