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 createState() => _ScanScreenState(); } class _ScanScreenState extends State { Stream>? _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 _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>( 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), ], ], ); }, ), ), ], ), ), ); } } 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!], ], ), ); } }