Files

384 lines
14 KiB
Dart

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<ScanScreen> createState() => _ScanScreenState();
}
/// What is stopping us from scanning, if anything.
enum _Gate { checking, permissionDenied, permissionPermanentlyDenied, bluetoothOff, unsupported, ready }
class _ScanScreenState extends State<ScanScreen> with WidgetsBindingObserver {
Stream<List<ScooterDevice>>? _scan;
_Gate _gate = _Gate.checking;
StreamSubscription<BleAdapterState>? _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<void> _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<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: _gate != _Gate.ready
? _gateView()
: _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),
],
],
);
},
),
),
],
),
),
);
}
}
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!],
],
),
);
}
}