import 'dart:async'; import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart'; import 'package:abawo_bt_app/database/database.dart'; import 'package:abawo_bt_app/model/bluetooth_device_model.dart'; import 'package:abawo_bt_app/model/firmware_file_selection.dart'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/pages/bootloader_recovery_update_page.dart'; import 'package:abawo_bt_app/service/firmware_file_selection_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' show DiscoveredDevice, ScanMode, Uuid; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; class DevicesTabPage extends ConsumerStatefulWidget { const DevicesTabPage({super.key}); @override ConsumerState createState() => _DevicesTabPageState(); } class _DevicesTabPageState extends ConsumerState { static const Duration _bootloaderScanTimeout = Duration(seconds: 10); StreamSubscription>? _scanSubscription; DiscoveredDevice? _dfuDevice; bool _isBootloaderScanStarting = false; @override void initState() { super.initState(); unawaited(_startBootloaderBackgroundScan()); } @override void dispose() { final bluetooth = ref.read(bluetoothProvider).valueOrNull; unawaited(_scanSubscription?.cancel()); unawaited(_stopBootloaderScan(bluetooth)); super.dispose(); } Future _startBootloaderBackgroundScan() async { if (_isBootloaderScanStarting || _scanSubscription != null) { return; } _isBootloaderScanStarting = true; try { final bluetooth = await ref.read(bluetoothProvider.future); if (!mounted) { return; } final scanResult = await bluetooth.startScan( timeout: _bootloaderScanTimeout, scanMode: ScanMode.lowLatency, ); if (scanResult.isErr()) { return; } _updateBootloaderDevice(bluetooth.scanResults); _scanSubscription = bluetooth.scanResultsStream.listen( _updateBootloaderDevice, ); } finally { _isBootloaderScanStarting = false; } } Future _stopBootloaderScan([BluetoothController? bluetooth]) async { await _scanSubscription?.cancel(); _scanSubscription = null; await bluetooth?.stopScan(); } void _updateBootloaderDevice(List devices) { final dfuDevice = devices.cast().firstWhere( (device) => device != null && _isBootloaderAdvertisement(device), orElse: () => null, ); if (!mounted || dfuDevice == null || dfuDevice.id == _dfuDevice?.id) { return; } setState(() { _dfuDevice = dfuDevice; }); } bool _isBootloaderAdvertisement(DiscoveredDevice device) { final name = device.name.trim(); if (name == 'US-DFU' || name == 'UniversalShifters DFU') { return true; } return name.toLowerCase().contains('dfu') && device.serviceUuids.any( (uuid) => uuid.expanded == Uuid.parse(universalShifterControlServiceUuid).expanded, ); } Future _openBootloaderRecovery() async { final device = _dfuDevice; if (device == null) { return; } final firmware = await Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, builder: (_) => _BootloaderRecoverySetupPage(device: device), ), ); if (!mounted || firmware == null) { return; } await _stopBootloaderScan(); if (!mounted) { return; } context.push( '/bootloader_recovery_update', extra: BootloaderRecoveryUpdateArgs( bootloaderDeviceId: device.id, firmware: firmware, ), ); } @override Widget build(BuildContext context) { final devicesAsync = ref.watch(nConnectedDevicesProvider); final connectionData = ref.watch(connectionStatusProvider).valueOrNull; final dfuDevice = _dfuDevice; return ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Devices', style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 6), Text( 'Manage connected hardware and jump back into setup.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.68), ), ), ], ), ), IconButton.filledTonal( tooltip: 'Connect a device', onPressed: () => context.push('/connect_device'), icon: const Icon(Icons.add), ), ], ), const SizedBox(height: 20), if (dfuDevice != null) ...[ _BootloaderRecoveryCard( device: dfuDevice, onStartRecovery: _openBootloaderRecovery, ), const SizedBox(height: 20), ], devicesAsync.when( loading: () => const _LoadingCard(), error: (error, _) => _MessageCard( title: 'Could not load devices', message: error.toString(), ), data: (devices) => _ActiveDeviceCard( devices: devices, connectionData: connectionData, ), ), const SizedBox(height: 20), Row( children: [ Expanded( child: Text( 'My Devices', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ), TextButton.icon( onPressed: () => context.push('/connect_device'), icon: const Icon(Icons.add_circle_outline), label: const Text('Add Device'), ), ], ), const SizedBox(height: 10), const _SavedDevicesList(), ], ); } } class _BootloaderRecoveryCard extends StatelessWidget { const _BootloaderRecoveryCard({ required this.device, required this.onStartRecovery, }); final DiscoveredDevice device; final VoidCallback onStartRecovery; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Card( color: colorScheme.errorContainer.withValues(alpha: 0.45), child: Padding( padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.system_update_alt, color: colorScheme.error), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'US-DFU Device Detected', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), const Text( 'US-DFU (Universal Shifters Firmware Update) device detected. Maybe a previous update failed?', ), ], ), ), ], ), const SizedBox(height: 12), Text( device.name.isEmpty ? device.id : '${device.name} - ${device.id}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.68), ), ), const SizedBox(height: 12), FilledButton.icon( onPressed: onStartRecovery, icon: const Icon(Icons.build_circle_outlined), label: const Text('Start Recovery'), ), ], ), ), ); } } class _BootloaderRecoverySetupPage extends ConsumerStatefulWidget { const _BootloaderRecoverySetupPage({required this.device}); final DiscoveredDevice device; @override ConsumerState<_BootloaderRecoverySetupPage> createState() => _BootloaderRecoverySetupPageState(); } class _BootloaderRecoverySetupPageState extends ConsumerState<_BootloaderRecoverySetupPage> { final FirmwareFileSelectionService _firmwareFileSelectionService = FirmwareFileSelectionService(filePicker: LocalFirmwareFilePicker()); BootloaderDfuPreparedFirmware? _selectedFirmware; bool _isSelectingFirmware = false; String? _message; Future _selectFirmwareFile() async { if (_isSelectingFirmware) { return; } setState(() { _isSelectingFirmware = true; _message = null; }); final suppressionCount = ref.read( backgroundBluetoothDisconnectSuppressionCountProvider.notifier, ); suppressionCount.state += 1; final FirmwareFileSelectionResult result; try { result = await _firmwareFileSelectionService.selectAndPrepareBootloaderDfu(); } finally { suppressionCount.state = suppressionCount.state <= 0 ? 0 : suppressionCount.state - 1; } if (!mounted) { return; } setState(() { _isSelectingFirmware = false; if (result.isSuccess) { _selectedFirmware = result.firmware; _message = 'Validated ${result.firmware!.fileName}. Ready to start recovery.'; } else if (!result.isCanceled) { _message = result.failure?.message; } }); } void _startRecovery() { final firmware = _selectedFirmware; if (firmware == null) { setState(() { _message = 'Select a firmware .bin file before starting recovery.'; }); return; } Navigator.of(context).pop(firmware); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final selectedFirmware = _selectedFirmware; return Scaffold( appBar: AppBar( title: const Text('US-DFU Recovery'), leading: IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), ), body: SafeArea( child: ListView( padding: const EdgeInsets.all(20), children: [ Card( child: Padding( padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.system_update_alt_rounded, color: colorScheme.primary), const SizedBox(width: 10), Text( 'Recover Firmware Update', style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ], ), const SizedBox(height: 10), Text( 'Select a raw app image for the detected US-DFU bootloader. Starting recovery opens the firmware update screen.', style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.68), ), ), const SizedBox(height: 14), Text( widget.device.name.isEmpty ? widget.device.id : '${widget.device.name} - ${widget.device.id}', style: theme.textTheme.bodySmall, ), ], ), ), ), const SizedBox(height: 16), Card( child: Padding( padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( selectedFirmware == null ? 'Selected file: none' : 'Selected file: ${selectedFirmware.fileName}', style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), if (selectedFirmware != null) ...[ const SizedBox(height: 6), Text( 'Size: ${selectedFirmware.fileBytes.length} bytes | Session: ${selectedFirmware.metadata.sessionId} | CRC32: 0x${selectedFirmware.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}', style: theme.textTheme.bodySmall, ), ], const SizedBox(height: 14), Wrap( spacing: 8, runSpacing: 8, children: [ OutlinedButton.icon( onPressed: _isSelectingFirmware ? null : _selectFirmwareFile, icon: _isSelectingFirmware ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.upload_file), label: const Text('Select Firmware'), ), FilledButton.icon( onPressed: selectedFirmware == null ? null : _startRecovery, icon: const Icon(Icons.system_update_alt), label: const Text('Start Update'), ), ], ), if (_message != null && _message!.isNotEmpty) ...[ const SizedBox(height: 12), Text(_message!), ], ], ), ), ), ], ), ), ); } } class _SavedDevicesList extends ConsumerStatefulWidget { const _SavedDevicesList(); @override ConsumerState<_SavedDevicesList> createState() => _SavedDevicesListState(); } class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> { String? _connectingDeviceId; Future _removeDevice(ConnectedDevice device) async { final shouldRemove = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Remove device?'), content: Text('Do you want to remove ${device.deviceName} from the app?'), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Remove'), ), ], ), ); if (shouldRemove != true || !mounted) { return; } final result = await ref .read(nConnectedDevicesProvider.notifier) .deleteConnectedDevice(device.id); if (!mounted) { return; } if (result.isErr()) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to remove device: ${result.unwrapErr()}'), ), ); return; } if (_connectingDeviceId == device.deviceAddress) { setState(() { _connectingDeviceId = null; }); } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${device.deviceName} removed from the app.')), ); } Future _openDevice(ConnectedDevice device) async { if (_connectingDeviceId != null) { return; } setState(() { _connectingDeviceId = device.deviceAddress; }); try { final controller = await ref.read(bluetoothProvider.future); final result = await controller.connectById( device.deviceAddress, timeout: const Duration(seconds: 10), ); if (!mounted) { return; } if (result.isOk()) { await ref .read(nConnectedDevicesProvider.notifier) .updateConnectedDeviceLastConnected(device.id); if (!mounted) { return; } context.push('/device/${device.deviceAddress}'); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Connection failed. Is the device turned on and in range?', ), duration: Duration(seconds: 3), ), ); } } catch (error) { if (!mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error: $error')), ); } finally { if (mounted) { setState(() { _connectingDeviceId = null; }); } } } @override Widget build(BuildContext context) { final devicesAsync = ref.watch(nConnectedDevicesProvider); final connectionData = ref.watch(connectionStatusProvider).valueOrNull; final connectedDeviceId = connectionData?.$2; final connectionState = connectionData?.$1; return devicesAsync.when( loading: () => const _LoadingCard(), error: (error, _) => _MessageCard( title: 'Could not load saved devices', message: error.toString(), ), data: (devices) { if (devices.isEmpty) { return _MessageCard( title: 'No devices yet', message: 'Add your first shifter to start configuring it.', actionLabel: 'Connect Device', onAction: () => context.push('/connect_device'), ); } return Column( children: [ for (final device in devices) ...[ _SavedDeviceTile( device: device, isConnecting: device.deviceAddress == _connectingDeviceId, isConnected: connectedDeviceId == device.deviceAddress && connectionState == ConnectionStatus.connected, onTap: () => _openDevice(device), onRemove: () => _removeDevice(device), ), if (device != devices.last) const SizedBox(height: 12), ], const SizedBox(height: 12), OutlinedButton.icon( onPressed: () => context.push('/connect_device'), icon: const Icon(Icons.bluetooth_searching), label: const Text('Connect Device'), ), ], ); }, ); } } class _ActiveDeviceCard extends ConsumerWidget { const _ActiveDeviceCard({ required this.devices, required this.connectionData, }); final List devices; final (ConnectionStatus, String?)? connectionData; @override Widget build(BuildContext context, WidgetRef ref) { final shifterDevices = devices .where( (device) => deviceTypeFromString(device.deviceType) == DeviceType.universalShifters, ) .toList() ..sort((a, b) { final aLastConnected = a.lastConnectedAt ?? a.createdAt; final bLastConnected = b.lastConnectedAt ?? b.createdAt; return bLastConnected.compareTo(aLastConnected); }); if (shifterDevices.isEmpty) { return const SizedBox.shrink(); } final connectedId = connectionData?.$2; final primaryDevice = connectedId == null ? shifterDevices.first : shifterDevices.firstWhere( (device) => device.deviceAddress == connectedId, orElse: () => shifterDevices.first, ); final isConnected = connectedId == primaryDevice.deviceAddress && connectionData?.$1 == ConnectionStatus.connected; final telemetry = ref.watch( shifterDeviceTelemetryCacheProvider.select( (cache) => cache[primaryDevice.deviceAddress], ), ); final batteryLabel = telemetry?.batteryLabel ?? '--'; const signalLabel = 'Ready'; final firmwareLabel = telemetry?.firmwareLabel ?? '--'; return Card( child: Padding( padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(18), child: Image.asset( 'assets/images/shifter-wireframe.png', width: 96, height: 72, fit: BoxFit.cover, ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( primaryDevice.deviceName, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), _StatusChip( label: isConnected ? 'Connected' : 'Saved device', color: isConnected ? const Color(0xFF40C979) : Theme.of(context).colorScheme.primary, ), const SizedBox(height: 10), Text( primaryDevice.deviceAddress, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.62), ), ), ], ), ), ], ), const SizedBox(height: 18), Row( children: [ Expanded( child: _MetricTile( label: 'Battery', value: batteryLabel, icon: Icons.battery_charging_full_rounded, ), ), const SizedBox(width: 10), const Expanded( child: _MetricTile( label: 'Signal', value: signalLabel, icon: Icons.signal_cellular_alt, ), ), const SizedBox(width: 10), Expanded( child: _MetricTile( label: 'Firmware', value: firmwareLabel, icon: Icons.memory_rounded, ), ), ], ), ], ), ), ); } } class _SavedDeviceTile extends StatelessWidget { const _SavedDeviceTile({ required this.device, required this.isConnecting, required this.isConnected, required this.onTap, required this.onRemove, }); final ConnectedDevice device; final bool isConnecting; final bool isConnected; final VoidCallback onTap; final VoidCallback onRemove; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Material( color: colorScheme.surface, borderRadius: BorderRadius.circular(22), child: InkWell( borderRadius: BorderRadius.circular(22), onTap: isConnecting ? null : onTap, child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(22), border: Border.all( color: isConnected ? colorScheme.primary.withValues(alpha: 0.65) : colorScheme.outlineVariant.withValues(alpha: 0.55), ), ), child: Row( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( shape: BoxShape.circle, color: isConnected ? colorScheme.primary.withValues(alpha: 0.14) : colorScheme.surfaceContainerHighest .withValues(alpha: 0.7), ), child: Icon( deviceTypeFromString(device.deviceType) == DeviceType.universalShifters ? Icons.bluetooth_rounded : Icons.memory_rounded, color: isConnected ? colorScheme.primary : colorScheme.onSurface, ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( device.deviceName, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( isConnected ? 'Connected' : 'Saved device', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: isConnected ? const Color(0xFF40C979) : colorScheme.primary, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( device.deviceAddress, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.62), ), ), ], ), ), if (isConnecting) const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2.4), ) else Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Remove device', onPressed: onRemove, icon: const Icon(Icons.delete_outline), ), Icon( Icons.chevron_right_rounded, color: colorScheme.onSurface.withValues(alpha: 0.55), ), ], ), ], ), ), ), ); } } class _MetricTile extends StatelessWidget { const _MetricTile({ required this.label, required this.value, required this.icon, }); final String label; final String value; final IconData icon; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54), borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 18, color: colorScheme.primary), const SizedBox(height: 10), Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.62), ), ), const SizedBox(height: 2), Text( value, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), ], ), ); } } class _MessageCard extends StatelessWidget { const _MessageCard({ required this.title, required this.message, this.actionLabel, this.onAction, }); final String title; final String message; final String? actionLabel; final VoidCallback? onAction; @override Widget build(BuildContext context) { return Card( child: Padding( padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), Text( message, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.68), ), ), if (actionLabel != null && onAction != null) ...[ const SizedBox(height: 14), FilledButton.icon( onPressed: onAction, icon: const Icon(Icons.add_circle_outline), label: Text(actionLabel!), ), ], ], ), ), ); } } class _LoadingCard extends StatelessWidget { const _LoadingCard(); @override Widget build(BuildContext context) { return const Card( child: Padding( padding: EdgeInsets.all(28), child: Center(child: CircularProgressIndicator()), ), ); } } class _StatusChip extends StatelessWidget { const _StatusChip({ required this.label, required this.color, }); final String label; final Color color; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), border: Border.all(color: color.withValues(alpha: 0.24)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_circle_outline, size: 14, color: color), const SizedBox(width: 6), Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: color, fontWeight: FontWeight.w700, ), ), ], ), ); } }