import 'dart:io'; import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/database/database.dart'; import 'package:abawo_bt_app/model/bluetooth_device_model.dart'; import 'package:abawo_bt_app/util/bluetooth_settings.dart'; import 'package:abawo_bt_app/util/constants.dart'; import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart'; import 'package:anyhow/anyhow.dart'; import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; const Duration _scanDuration = Duration(seconds: 10); class ConnectDevicePage extends ConsumerStatefulWidget { const ConnectDevicePage({super.key}); @override ConsumerState createState() => _ConnectDevicePageState(); } class _ConnectDevicePageState extends ConsumerState { int _retryScanCounter = 0; bool _initialScanTriggered = false; bool _showOnlyAbawoDevices = true; void _startScanIfNeeded(BluetoothController controller) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_initialScanTriggered && mounted) { controller.startScan(timeout: _scanDuration); setState(() { _initialScanTriggered = true; }); } }); } void _retryScan(BluetoothController controller) { if (!mounted) { return; } setState(() { _initialScanTriggered = true; _retryScanCounter++; }); controller.startScan(timeout: _scanDuration); } Future _connectDevice( BluetoothController controller, DiscoveredDevice device, bool isAlreadyConnected, ) async { if (isAlreadyConnected) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('This device is already connected in the app.'), ), ); return; } final isAbawoDevice = device.serviceUuids.any(isAbawoDeviceGuid); final isConnectable = device.serviceUuids.any(isConnectableAbawoDeviceGuid); if (!isAbawoDevice) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('This app can only connect to abawo devices.'), ), ); return; } if (!isConnectable) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('This device is not connectable with the app.'), ), ); return; } final res = await controller.connect(device); if (!mounted) { return; } switch (res) { case Ok(): if (!Platform.isAndroid) { controller.readCharacteristic( device.id, '0993826f-0ee4-4b37-9614-d13ecba4ffc2', '0993826f-0ee4-4b37-9614-d13ecba40000', ); } final notifier = ref.read(nConnectedDevicesProvider.notifier); final name = device.name.isNotEmpty ? device.name : 'Unknown Device'; final deviceCompanion = ConnectedDevicesCompanion( deviceName: Value(name), deviceAddress: Value(device.id), deviceType: Value( deviceTypeToString(deviceTypeFromUuids(device.serviceUuids)), ), lastConnectedAt: Value(DateTime.now()), ); final addResult = await notifier.addConnectedDevice(deviceCompanion); if (!mounted) { return; } if (addResult.isErr()) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to save device: ${addResult.unwrapErr()}'), ), ); } else { context.go('/device/${device.id}'); } break; case Err(:final v): final error = v.toString(); if (error.toLowerCase().contains('disconnected')) { await showBluetoothPairingRecoveryDialog(context); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Connection unsuccessful:\n$error')), ); } break; } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Connect Device'), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/devices'), ), ), body: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), child: Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(24), border: Border.all( color: Theme.of(context) .colorScheme .outlineVariant .withValues(alpha: 0.55), ), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Scan for devices', style: Theme.of(context) .textTheme .titleLarge ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 6), Text( 'Look for nearby abawo hardware, then add it to your saved devices.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.68), ), ), ], ), ), const SizedBox(width: 16), Column( children: [ Text( 'abawo only', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.62), ), ), Switch( value: _showOnlyAbawoDevices, onChanged: (value) { setState(() { _showOnlyAbawoDevices = value; }); }, ), ], ), ], ), ), ), Expanded( child: Consumer( builder: (context, ref, child) { final btAsyncValue = ref.watch(bluetoothProvider); final connectedDevices = ref.watch(nConnectedDevicesProvider).valueOrNull ?? const []; final connectedDeviceAddresses = connectedDevices .map((device) => device.deviceAddress) .toSet(); return btAsyncValue.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: _ScanMessageCard( title: 'Bluetooth unavailable', message: '$err', ), ), data: (controller) { _startScanIfNeeded(controller); return StreamBuilder>( stream: controller.scanResultsStream, initialData: const [], builder: (context, snapshot) { final results = snapshot.data ?? []; final filteredResults = _showOnlyAbawoDevices ? results .where((device) => device.serviceUuids.any(isAbawoDeviceGuid)) .toList(growable: false) : results; if (!_initialScanTriggered && filteredResults.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(horizontal: 20), child: _ScanMessageCard( title: 'Looking for devices', message: 'The scan has started. Nearby hardware will appear here as soon as it is discovered.', ), ); } if (filteredResults.isEmpty && _initialScanTriggered) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: _ScanMessageCard( title: _showOnlyAbawoDevices ? 'No abawo devices found' : 'No devices found', message: _showOnlyAbawoDevices ? 'Try moving closer, waking the shifter, or temporarily showing all nearby devices.' : 'Try scanning again in a less crowded Bluetooth environment.', ), ); } return ListView.separated( padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), itemCount: filteredResults.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { final device = filteredResults[index]; final isAlreadyConnected = connectedDeviceAddresses.contains(device.id); final tone = _ScanResultTone.resolve( isAlreadyConnected: isAlreadyConnected, isAbawoDevice: hasConnectableAbawoDeviceGuid( device.serviceUuids), isConnectable: device.serviceUuids .any(isConnectableAbawoDeviceGuid), ); return InkWell( onTap: () => _connectDevice( controller, device, isAlreadyConnected, ), borderRadius: BorderRadius.circular(22), child: _DiscoveredDeviceCard( deviceName: device.name.isEmpty ? 'Unknown Device' : device.name, deviceId: device.id, deviceType: deviceTypeFromUuids(device.serviceUuids), rssi: device.rssi, tone: tone, ), ); }, ); }, ); }, ); }, ), ), Consumer( builder: (context, ref, child) { final btController = ref.watch(bluetoothProvider).asData?.value; return StreamBuilder( stream: btController?.isScanningStream ?? Stream.empty(), initialData: false, builder: (context, snapshot) { final isScanning = snapshot.data ?? false; return Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), child: _ScanFooterCard( isScanning: isScanning, retryKey: _retryScanCounter, onPressed: isScanning || btController == null ? null : () => _retryScan(btController), ), ); }, ); }, ), ], ), ); } } class _DiscoveredDeviceCard extends StatelessWidget { const _DiscoveredDeviceCard({ required this.deviceName, required this.deviceId, required this.deviceType, required this.rssi, required this.tone, }); final String deviceName; final String deviceId; final DeviceType deviceType; final int rssi; final _ScanResultTone tone; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Material( color: colorScheme.surface, borderRadius: BorderRadius.circular(22), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(22), border: Border.all(color: tone.borderColor(context)), ), child: Row( children: [ Container( width: 52, height: 52, decoration: BoxDecoration( shape: BoxShape.circle, color: tone.accent(context).withValues(alpha: 0.12), ), child: Icon( deviceType == DeviceType.universalShifters ? Icons.bluetooth_rounded : Icons.devices_other_rounded, color: tone.accent(context), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( deviceName, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( tone.subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: tone.accent(context), fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( deviceId, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.62), ), ), ], ), ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ _RssiBars(rssi: rssi), const SizedBox(height: 12), Icon( tone.trailingIcon, color: tone.accent(context), ), ], ), ], ), ), ); } } class _ScanFooterCard extends StatelessWidget { const _ScanFooterCard({ required this.isScanning, required this.onPressed, this.retryKey = 0, }); final bool isScanning; final VoidCallback? onPressed; final int retryKey; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(24), border: Border.all( color: Theme.of(context) .colorScheme .outlineVariant .withValues(alpha: 0.55), ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ HorizontalScanningAnimation( key: ValueKey(retryKey), isScanning: isScanning, height: 36, ), const SizedBox(height: 10), Text( isScanning ? 'Scanning for nearby devices...' : 'Scan finished.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: onPressed, icon: Icon(isScanning ? Icons.radar_rounded : Icons.refresh), label: Text(isScanning ? 'Scanning...' : 'Scan Again'), ), ), ], ), ); } } class _ScanMessageCard extends StatelessWidget { const _ScanMessageCard({ required this.title, required this.message, }); final String title; final String message; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(22), border: Border.all( color: Theme.of(context) .colorScheme .outlineVariant .withValues(alpha: 0.55), ), ), 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), ), ), ], ), ); } } class _RssiBars extends StatelessWidget { const _RssiBars({required this.rssi}); final int rssi; @override Widget build(BuildContext context) { final barCount = rssi > -60 ? 4 : rssi > -72 ? 3 : rssi > -84 ? 2 : 1; final color = rssi > -72 ? const Color(0xFF40C979) : rssi > -84 ? const Color(0xFFFFB649) : Theme.of(context).colorScheme.error; return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: List.generate(4, (index) { final height = 5.0 + (index * 3); final active = index < barCount; return Padding( padding: EdgeInsets.only(right: index == 3 ? 0 : 2), child: Container( width: 4, height: height, decoration: BoxDecoration( color: active ? color : color.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(999), ), ), ); }), ); } } class _ScanResultTone { const _ScanResultTone({ required this.subtitle, required this.trailingIcon, required Color Function(BuildContext context) accent, }) : _accent = accent; final String subtitle; final IconData trailingIcon; final Color Function(BuildContext context) _accent; static _ScanResultTone resolve({ required bool isAlreadyConnected, required bool isAbawoDevice, required bool isConnectable, }) { if (isAlreadyConnected) { return _ScanResultTone( subtitle: 'Already added', trailingIcon: Icons.check_circle, accent: (context) => Theme.of(context).colorScheme.primary, ); } if (!isAbawoDevice) { return _ScanResultTone( subtitle: 'Unsupported for this app', trailingIcon: Icons.block, accent: (context) => Theme.of(context).colorScheme.error, ); } if (!isConnectable) { return _ScanResultTone( subtitle: 'Detected but not connectable yet', trailingIcon: Icons.info_outline, accent: _warningAccent, ); } return _ScanResultTone( subtitle: 'Tap to connect', trailingIcon: Icons.arrow_forward_rounded, accent: (context) => Theme.of(context).colorScheme.primary, ); } static Color _warningAccent(BuildContext context) => const Color(0xFFFFB649); Color accent(BuildContext context) => _accent(context); Color borderColor(BuildContext context) => accent(context).withValues(alpha: 0.4); }