Files
OpenMotion/lib/screens/scooter_screen.dart
T
2026-09-21 18:03:21 -04:00

731 lines
24 KiB
Dart

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