From 5285c4417305dbb07a1cca446ddbab7731c39fc7 Mon Sep 17 00:00:00 2001 From: Yandrik Date: Tue, 28 Apr 2026 21:31:52 +0200 Subject: [PATCH] feat: use shifter trainer scan flow --- lib/pages/device_details_page.dart | 44 +-- lib/widgets/bike_scan_dialog.dart | 455 +++++++++++++++-------------- 2 files changed, 265 insertions(+), 234 deletions(-) diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index 726e39f..b88f4ba 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -10,8 +10,6 @@ import 'package:abawo_bt_app/util/bluetooth_settings.dart'; import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart'; import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' - show DiscoveredDevice; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:nb_utils/nb_utils.dart'; @@ -438,20 +436,6 @@ class _DeviceDetailsPageState extends ConsumerState { return; } - _isAssignTrainerDialogOpen = true; - final DiscoveredDevice? selectedBike; - try { - selectedBike = await BikeScanDialog.show( - context, - excludedDeviceId: widget.deviceAddress, - ); - } finally { - _isAssignTrainerDialogOpen = false; - } - if (selectedBike == null || !mounted) { - return; - } - await _startStatusStreamingIfNeeded(); final shifter = _shifterService; if (shifter == null) { @@ -463,8 +447,26 @@ class _DeviceDetailsPageState extends ConsumerState { ); return; } + if (!mounted) { + return; + } - final result = await shifter.connectButtonToBike(selectedBike.id); + _isAssignTrainerDialogOpen = true; + final TrainerScanResult? selectedTrainer; + try { + selectedTrainer = await BikeScanDialog.show( + context, + shifter: shifter, + ); + } finally { + _isAssignTrainerDialogOpen = false; + } + if (selectedTrainer == null || !mounted) { + return; + } + + final result = + await shifter.connectButtonToTrainer(selectedTrainer.address); if (!mounted) { return; } @@ -479,7 +481,13 @@ class _DeviceDetailsPageState extends ConsumerState { } ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Sent connect request for ${selectedBike.id}.')), + SnackBar( + content: Text( + selectedTrainer.name.isEmpty + ? 'Sent connect request for trainer.' + : 'Sent connect request for ${selectedTrainer.name}.', + ), + ), ); } diff --git a/lib/widgets/bike_scan_dialog.dart b/lib/widgets/bike_scan_dialog.dart index dc249e7..1446a22 100644 --- a/lib/widgets/bike_scan_dialog.dart +++ b/lib/widgets/bike_scan_dialog.dart @@ -1,39 +1,39 @@ import 'dart:async'; -import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:abawo_bt_app/service/shifter_service.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -class BikeScanDialog extends ConsumerStatefulWidget { +class BikeScanDialog extends StatefulWidget { const BikeScanDialog({ - required this.excludedDeviceId, + required this.shifter, super.key, }); - final String excludedDeviceId; + final ShifterService shifter; - static Future show( + static Future show( BuildContext context, { - required String excludedDeviceId, + required ShifterService shifter, }) { - return showDialog( + return showDialog( context: context, barrierDismissible: true, - builder: (_) => BikeScanDialog(excludedDeviceId: excludedDeviceId), + builder: (_) => BikeScanDialog(shifter: shifter), ); } @override - ConsumerState createState() => _BikeScanDialogState(); + State createState() => _BikeScanDialogState(); } -class _BikeScanDialogState extends ConsumerState { - bool _showAll = false; +class _BikeScanDialogState extends State { + bool _showOnlyFtms = true; bool _isStartingScan = true; + bool _isScanning = false; String? _scanError; - BluetoothController? _controller; + final Map _resultsByAddress = {}; + StreamSubscription? _scanSubscription; @override void initState() { @@ -42,16 +42,39 @@ class _BikeScanDialogState extends ConsumerState { } Future _startScan() async { + await _scanSubscription?.cancel(); + if (_isScanning) { + await widget.shifter.stopTrainerScan(); + } + setState(() { _isStartingScan = true; + _isScanning = false; _scanError = null; + _resultsByAddress.clear(); }); try { - final controller = await ref.read(bluetoothProvider.future); - _controller = controller; - await controller.stopScan(); - await controller.startScan(); + _scanSubscription = widget.shifter.subscribeToTrainerScanResults().listen( + _handleScanEvent, + onError: (Object error) { + if (!mounted) { + return; + } + setState(() { + _scanError = error.toString(); + _isStartingScan = false; + _isScanning = false; + }); + }, + ); + + final startResult = await widget.shifter.startTrainerScan(); + if (startResult.isErr()) { + _scanError = startResult.unwrapErr().toString(); + } else { + _isScanning = true; + } } catch (error) { _scanError = error.toString(); } finally { @@ -63,15 +86,43 @@ class _BikeScanDialogState extends ConsumerState { } } + void _handleScanEvent(TrainerScanEvent event) { + if (!mounted) { + return; + } + + setState(() { + _isStartingScan = false; + switch (event.kind) { + case TrainerScanEventKind.scanStarted: + _isScanning = true; + _scanError = null; + break; + case TrainerScanEventKind.device: + final result = event.result; + if (result != null) { + _resultsByAddress[result.address.key] = result; + } + break; + case TrainerScanEventKind.scanFinished: + case TrainerScanEventKind.scanCancelled: + _isScanning = false; + break; + } + }); + } + @override void dispose() { - _controller?.stopScan(); + _scanSubscription?.cancel(); + if (_isScanning) { + unawaited(widget.shifter.stopTrainerScan()); + } super.dispose(); } @override Widget build(BuildContext context) { - final btAsync = ref.watch(bluetoothProvider); final size = MediaQuery.of(context).size; final dialogWidth = size.width < 640 ? size.width - 24 : 560.0; final dialogHeight = size.height < 780 ? size.height * 0.78 : 560.0; @@ -83,216 +134,85 @@ class _BikeScanDialogState extends ConsumerState { child: SizedBox( width: dialogWidth, height: dialogHeight, - child: btAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, _) => Center(child: Text('Bluetooth error: $err')), - data: (controller) { - _controller ??= controller; - return Column( - children: [ - _DialogHeader( - showAll: _showAll, - isScanning: _isStartingScan, - onChanged: (value) { - setState(() { - _showAll = value; - }); - }, - onRescan: _startScan, - ), - Expanded( - child: _scanError != null - ? _ScanMessage( - message: 'Could not start trainer scan: $_scanError', - action: TextButton.icon( - onPressed: _startScan, - icon: const Icon(Icons.refresh), - label: const Text('Retry'), - ), - ) - : StreamBuilder>( - stream: controller.scanResultsStream, - initialData: controller.scanResults, - builder: (context, snapshot) { - if (_isStartingScan && - (snapshot.data == null || - snapshot.data!.isEmpty)) { - return const Center( - child: CircularProgressIndicator()); - } - - final devices = - _filteredDevices(snapshot.data ?? const []); - if (devices.isEmpty) { - return const _ScanMessage( - message: - 'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.', - ); - } - - return ListView.separated( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), - itemCount: devices.length, - separatorBuilder: (_, __) => - const SizedBox(height: 12), - itemBuilder: (context, index) { - final device = devices[index]; - final isFtms = _advertisesFtms(device); - return Material( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(22), - child: InkWell( - borderRadius: BorderRadius.circular(22), - onTap: () => - Navigator.of(context).pop(device), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(22), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outlineVariant - .withValues(alpha: 0.55), - ), - ), - child: Row( - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.12), - ), - child: Icon( - Icons.pedal_bike_rounded, - color: Theme.of(context) - .colorScheme - .primary, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - device.name.isEmpty - ? 'Unknown Device' - : device.name, - maxLines: 1, - overflow: - TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: - FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - isFtms - ? 'FTMS' - : 'Nearby trainer', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: Theme.of(context) - .colorScheme - .primary, - fontWeight: - FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - device.id, - maxLines: 1, - overflow: - TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues( - alpha: 0.62), - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: - CrossAxisAlignment.end, - children: [ - _RssiBadge(rssi: device.rssi), - const SizedBox(height: 12), - Icon( - Icons.chevron_right_rounded, - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.55), - ), - ], - ), - ], - ), - ), - ), - ); - }, - ); - }, - ), - ), - ], - ); - }, + child: Column( + children: [ + _DialogHeader( + showOnlyFtms: _showOnlyFtms, + isScanning: _isStartingScan || _isScanning, + onChanged: (value) { + setState(() { + _showOnlyFtms = value; + }); + }, + onRescan: _startScan, + ), + Expanded(child: _buildBody(context)), + ], ), ), ); } - List _filteredDevices(List devices) { - return devices.where((device) { - if (device.id == widget.excludedDeviceId) { - return false; - } - if (_showAll) { - return true; - } - return _advertisesFtms(device); - }).toList(growable: false); + Widget _buildBody(BuildContext context) { + if (_scanError != null) { + return _ScanMessage( + message: 'Could not start shifter trainer scan: $_scanError', + action: TextButton.icon( + onPressed: _startScan, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ); + } + + if (_isStartingScan && _resultsByAddress.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + final devices = _filteredDevices(); + if (devices.isEmpty) { + return _ScanMessage( + message: _isScanning + ? 'The shifter is scanning. Nearby trainers will appear here as soon as the shifter reports them.' + : 'No matching trainers were reported by the shifter. Rescan with the trainer nearby and awake.', + ); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + itemCount: devices.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) => _TrainerScanResultTile( + result: devices[index], + onTap: () => Navigator.of(context).pop(devices[index]), + ), + ); } - bool _advertisesFtms(DiscoveredDevice device) { - return device.serviceUuids.any(isFtmsUuid) || - device.serviceData.keys.any(isFtmsUuid); + List _filteredDevices() { + final devices = _resultsByAddress.values.where((device) { + return !_showOnlyFtms || device.ftmsDetected; + }).toList(growable: false); + devices.sort((a, b) { + final ftmsCompare = (b.ftmsDetected ? 1 : 0) - (a.ftmsDetected ? 1 : 0); + if (ftmsCompare != 0) { + return ftmsCompare; + } + return b.rssi.compareTo(a.rssi); + }); + return devices; } } class _DialogHeader extends StatelessWidget { const _DialogHeader({ - required this.showAll, + required this.showOnlyFtms, required this.isScanning, required this.onChanged, required this.onRescan, }); - final bool showAll; + final bool showOnlyFtms; final bool isScanning; final ValueChanged onChanged; final VoidCallback onRescan; @@ -319,7 +239,7 @@ class _DialogHeader extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'Tap a nearby trainer to assign it to the connected shifter.', + 'The shifter scans nearby trainers. Tap one to assign it.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context) .colorScheme @@ -344,13 +264,13 @@ class _DialogHeader extends StatelessWidget { child: Row( children: [ Text( - 'Show All', + 'FTMS only', style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(width: 8), - Switch(value: showAll, onChanged: onChanged), + Switch(value: showOnlyFtms, onChanged: onChanged), ], ), ), @@ -379,6 +299,109 @@ class _DialogHeader extends StatelessWidget { } } +class _TrainerScanResultTile extends StatelessWidget { + const _TrainerScanResultTile({ + required this.result, + required this.onTap, + }); + + final TrainerScanResult result; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final name = result.name.isEmpty ? 'Unknown Trainer' : result.name; + final typeLabel = result.ftmsDetected ? 'FTMS trainer' : 'Nearby device'; + final addressText = _formatTrainerAddress(result.address); + + return Material( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(22), + child: InkWell( + borderRadius: BorderRadius.circular(22), + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.primary.withValues(alpha: 0.12), + ), + child: Icon( + Icons.pedal_bike_rounded, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + typeLabel, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + addressText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: + colorScheme.onSurface.withValues(alpha: 0.62), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _RssiBadge(rssi: result.rssi), + const SizedBox(height: 12), + Icon( + Icons.chevron_right_rounded, + color: colorScheme.onSurface.withValues(alpha: 0.55), + ), + ], + ), + ], + ), + ), + ), + ); + } + + String _formatTrainerAddress(TrainerAddress address) { + final flags = address.flags.toRadixString(16).padLeft(2, '0'); + return '${formatMacAddressFromLittleEndian(address.bytes)} ยท flags 0x$flags'; + } +} + class _ScanMessage extends StatelessWidget { const _ScanMessage({ required this.message,