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 createState() => _ScooterScreenState(); } class _ScooterScreenState extends State { 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 _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 _forgetPin() async { await _pinStore.forget(scooter.device.id); if (!mounted) return; setState(() { _hasSavedPin = false; _pin.clear(); }); } Future _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 _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 _run(Future 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( 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 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( 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, ), ), ); } }