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),
],
);
}
}
+71
View File
@@ -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),
),
);
},
),
),
],
);
},
),
);
}
}
+246
View File
@@ -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!],
],
),
);
}
}
+730
View File
@@ -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,
),
),
);
}
}