import 'package:flutter/material.dart'; import '../models/scooter_device.dart'; import '../scooters/apollo_scooter.dart'; import 'dart:async'; import 'dart:io'; import '../services/ble_client.dart'; import '../services/ble_permissions.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 createState() => _ScanScreenState(); } /// What is stopping us from scanning, if anything. enum _Gate { checking, permissionDenied, permissionPermanentlyDenied, bluetoothOff, unsupported, ready } class _ScanScreenState extends State with WidgetsBindingObserver { Stream>? _scan; _Gate _gate = _Gate.checking; StreamSubscription? _adapterSub; bool _requesting = false; /// 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(); WidgetsBinding.instance.addObserver(this); _prepare(request: true); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _adapterSub?.cancel(); super.dispose(); } /// Re-check when coming back from system settings or the Bluetooth toggle. @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed && _gate != _Gate.ready) { _prepare(request: false); } } /// Permission first, then adapter state, then scan. Future _prepare({required bool request}) async { if (_requesting) return; _requesting = true; try { final access = request ? await BlePermissions.request() : await BlePermissions.status(); if (!mounted) return; switch (access) { case BleAccess.denied: _setGate(_Gate.permissionDenied); return; case BleAccess.permanentlyDenied: _setGate(_Gate.permissionPermanentlyDenied); return; case BleAccess.unsupported: _setGate(_Gate.unsupported); return; case BleAccess.granted: break; } _watchAdapter(); } finally { _requesting = false; } } void _watchAdapter() { _adapterSub ??= widget.ble.adapterState.listen((s) { if (!mounted) return; switch (s) { case BleAdapterState.on: if (_gate != _Gate.ready) { _setGate(_Gate.ready); _restart(); } case BleAdapterState.off: case BleAdapterState.unknown: _setGate(_Gate.bluetoothOff); case BleAdapterState.unauthorized: _setGate(_Gate.permissionPermanentlyDenied); case BleAdapterState.unsupported: _setGate(_Gate.unsupported); } }); } void _setGate(_Gate g) { if (_gate == g) return; setState(() { _gate = g; if (g != _Gate.ready) _scan = null; }); } void _restart() { if (_gate != _Gate.ready) { _prepare(request: true); return; } setState(() => _scan = widget.ble.scan()); } Future _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: _gate != _Gate.ready ? _gateView() : _scan == null ? const SizedBox.shrink() : StreamBuilder>( 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 []; 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), ], ], ); }, ), ), ], ), ), ); } } extension on _ScanScreenState { Widget _gateView() { final android = Platform.isAndroid; return switch (_gate) { _Gate.checking => const Center(child: CircularProgressIndicator()), _Gate.permissionDenied => _Empty( icon: Icons.bluetooth_rounded, title: 'Bluetooth permission needed', message: android ? 'OpenMotion needs the Nearby devices permission to find and talk to your scooter. ' 'Scan results are never used for location.' : 'OpenMotion needs Bluetooth access to find and talk to your scooter.', action: FilledButton( onPressed: () => _prepare(request: true), child: const Text('Allow Bluetooth'), ), ), _Gate.permissionPermanentlyDenied => _Empty( icon: Icons.bluetooth_disabled_rounded, title: 'Bluetooth permission blocked', message: android ? 'Nearby devices access was denied. Enable it for OpenMotion in system settings, then come back.' : 'Bluetooth access is off for OpenMotion. Enable it in Settings, then come back.', action: FilledButton( onPressed: BlePermissions.openSettings, child: const Text('Open settings'), ), ), _Gate.bluetoothOff => _Empty( icon: Icons.bluetooth_disabled_rounded, title: 'Bluetooth is off', message: android ? 'Turn on Bluetooth to scan for your scooter.' : 'Turn on Bluetooth in Control Center or Settings to scan for your scooter.', action: android ? FilledButton(onPressed: widget.ble.turnOn, child: const Text('Turn on Bluetooth')) : null, ), _Gate.unsupported => const _Empty( icon: Icons.error_outline_rounded, title: 'Bluetooth unavailable', message: 'This device does not support Bluetooth Low Energy.', ), _Gate.ready => const SizedBox.shrink(), }; } } 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 Container( width: double.infinity, 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!], ], ), ); } }