import 'dart:async'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/service/shifter_service.dart'; import 'package:flutter/material.dart'; class BikeScanDialog extends StatefulWidget { const BikeScanDialog({ required this.shifter, super.key, }); final ShifterService shifter; static Future show( BuildContext context, { required ShifterService shifter, }) { return showDialog( context: context, barrierDismissible: true, builder: (_) => BikeScanDialog(shifter: shifter), ); } @override State createState() => _BikeScanDialogState(); } class _BikeScanDialogState extends State { bool _showOnlyFtms = true; bool _isStartingScan = true; bool _isScanning = false; String? _scanError; final Map _resultsByAddress = {}; StreamSubscription? _scanSubscription; @override void initState() { super.initState(); unawaited(_startScan()); } Future _startScan() async { await _scanSubscription?.cancel(); if (_isScanning) { await widget.shifter.stopTrainerScan(); } setState(() { _isStartingScan = true; _isScanning = false; _scanError = null; _resultsByAddress.clear(); }); try { _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 { if (mounted) { setState(() { _isStartingScan = false; }); } } } 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() { _scanSubscription?.cancel(); if (_isScanning) { unawaited(widget.shifter.stopTrainerScan()); } super.dispose(); } @override Widget build(BuildContext context) { 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; return Dialog( clipBehavior: Clip.antiAlias, insetPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), child: SizedBox( width: dialogWidth, height: dialogHeight, child: Column( children: [ _DialogHeader( showOnlyFtms: _showOnlyFtms, isScanning: _isStartingScan || _isScanning, onChanged: (value) { setState(() { _showOnlyFtms = value; }); }, onRescan: _startScan, ), Expanded(child: _buildBody(context)), ], ), ), ); } 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]), ), ); } 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.showOnlyFtms, required this.isScanning, required this.onChanged, required this.onRescan, }); final bool showOnlyFtms; final bool isScanning; final ValueChanged onChanged; final VoidCallback onRescan; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Assign Trainer', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 6), Text( 'The shifter scans nearby trainers. Tap one to assign it.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.68), ), ), ], ), ), IconButton( tooltip: 'Close', onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ), ], ), const SizedBox(height: 16), Row( children: [ Expanded( child: Row( children: [ Text( 'FTMS only', style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(width: 8), Switch(value: showOnlyFtms, onChanged: onChanged), ], ), ), SizedBox( width: 132, child: OutlinedButton.icon( style: OutlinedButton.styleFrom( minimumSize: const Size(0, 48), ), onPressed: isScanning ? null : onRescan, icon: isScanning ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh), label: const Text('Rescan'), ), ), ], ), ], ), ); } } 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, this.action, }); final String message; final Widget? action; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(20), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( message, textAlign: TextAlign.center, ), if (action != null) ...[ const SizedBox(height: 12), SizedBox(width: 132, child: action!), ], ], ), ), ); } } class _RssiBadge extends StatelessWidget { const _RssiBadge({required this.rssi}); final int rssi; @override Widget build(BuildContext context) { final color = rssi > -65 ? const Color(0xFF40C979) : rssi > -80 ? const Color(0xFFFFB649) : Theme.of(context).colorScheme.error; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(999), ), child: Text( '$rssi dBm', style: TextStyle( color: color, fontWeight: FontWeight.w700, fontSize: 12, ), ), ); } }