Initial version
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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<ScanScreen> createState() => _ScanScreenState();
|
||||
}
|
||||
|
||||
class _ScanScreenState extends State<ScanScreen> {
|
||||
Stream<List<ScooterDevice>>? _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<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: _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),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user