diff --git a/lib/pages/devices_page.dart b/lib/pages/devices_page.dart index bec64c7..c8644d7 100644 --- a/lib/pages/devices_page.dart +++ b/lib/pages/devices_page.dart @@ -1,17 +1,16 @@ 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/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'; -import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; -import 'package:abawo_bt_app/widgets/device_listitem.dart'; -import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart'; // Import the new horizontal animation -import 'package:abawo_bt_app/database/database.dart'; -import 'package:drift/drift.dart' show Value; const Duration _scanDuration = Duration(seconds: 10); @@ -22,42 +21,118 @@ class ConnectDevicePage extends ConsumerStatefulWidget { ConsumerState createState() => _ConnectDevicePageState(); } -class _ConnectDevicePageState extends ConsumerState - with TickerProviderStateMixin { - // TickerProviderStateMixin is no longer needed as animations are self-contained or handled by StreamBuilder - int _retryScanCounter = 0; // Used to force animation reset - bool _initialScanTriggered = false; // Track if the first scan was requested - bool _showOnlyAbawoDevices = true; // State for filtering devices - // Function to start scan safely after controller is ready +class _ConnectDevicePageState extends ConsumerState { + int _retryScanCounter = 0; + bool _initialScanTriggered = false; + bool _showOnlyAbawoDevices = true; + void _startScanIfNeeded(BluetoothController controller) { - // Use WidgetsBinding to schedule the scan start after the build phase WidgetsBinding.instance.addPostFrameCallback((_) { - // Start scan only if it hasn't been triggered yet and the widget is mounted if (!_initialScanTriggered && mounted) { controller.startScan(timeout: _scanDuration); - if (mounted) { - setState(() { - _initialScanTriggered = true; - }); - } + setState(() { + _initialScanTriggered = true; + }); } }); } - @override - void initState() { - super.initState(); - super.initState(); - // No animation controllers needed here anymore + void _retryScan(BluetoothController controller) { + if (!mounted) { + return; + } + + setState(() { + _initialScanTriggered = true; + _retryScanCounter++; + }); + controller.startScan(timeout: _scanDuration); } - @override - void dispose() { - // Dispose controllers if they existed (they don't anymore) - super.dispose(); + 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 = isAbawoDeviceIdent(device.manufacturerData); + 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.push('/device/${device.id}'); + } + break; + case Err(:final v): + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Connection unsuccessful:\n${v.toString()}')), + ); + break; + } } - // Removed _startScanProgressAnimation, _startWaveAnimation, _stopAnimations @override Widget build(BuildContext context) { return Scaffold( @@ -67,40 +142,77 @@ class _ConnectDevicePageState extends ConsumerState icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/devices'), ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 12.0), - child: Row( - children: [ - const Text('abawo only'), // Label for the switch - Switch( - value: _showOnlyAbawoDevices, - onChanged: (value) { - if (mounted) { - setState(() { - _showOnlyAbawoDevices = value; - }); - } - }, - ), - ], - ), - ) - ], ), body: Column( - // Use Column instead of Center(Column(...)) children: [ - const Padding( - padding: EdgeInsets.all(16.0), // Add padding around the title - child: Text( - 'Available Devices', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + 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; + }); + }, + ), + ], + ), + ], + ), ), ), - // Use Consumer to get the BluetoothController Expanded( - // Allow the device list to take available space child: Consumer( builder: (context, ref, child) { final btAsyncValue = ref.watch(bluetoothProvider); @@ -112,144 +224,86 @@ class _ConnectDevicePageState extends ConsumerState .toSet(); return btAsyncValue.when( - loading: () => - const Center(child: CircularProgressIndicator()), - error: (err, stack) => Center(child: Text('Error: $err')), + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: _ScanMessageCard( + title: 'Bluetooth unavailable', + message: '$err', + ), + ), data: (controller) { - // Trigger the initial scan if needed _startScanIfNeeded(controller); - // StreamBuilder for Scan Results (Device List) return StreamBuilder>( stream: controller.scanResultsStream, initialData: const [], builder: (context, snapshot) { final results = snapshot.data ?? []; - // Filter results based on the toggle state final filteredResults = _showOnlyAbawoDevices ? results .where((device) => device.serviceUuids.any(isAbawoDeviceGuid)) - .toList() + .toList(growable: false) : results; if (!_initialScanTriggered && filteredResults.isEmpty) { - // Show a message or placeholder before the first scan starts or if no devices found initially - return const Center( - child: Text( - 'Scanning for devices...')); // Or CircularProgressIndicator() + 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) { - // Show 'No devices found' only after the initial scan was triggered - return const Center(child: Text('No devices found.')); + 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.', + ), + ); } - // Display the list - return ListView.builder( + 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 abawoDevice = - // device.serviceUuids.any(isAbawoDeviceGuid); - isAbawoDeviceIdent(device.manufacturerData); - final connectable = device.serviceUuids - .any(isConnectableAbawoDeviceGuid); - final deviceName = device.name.isEmpty - ? 'Unknown Device' - : device.name; + final tone = _ScanResultTone.resolve( + isAlreadyConnected: isAlreadyConnected, + isAbawoDevice: + isAbawoDeviceIdent(device.manufacturerData), + isConnectable: device.serviceUuids + .any(isConnectableAbawoDeviceGuid), + ); return InkWell( - onTap: () async { - if (isAlreadyConnected) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'This device is already connected in the app.'), - ), - ); - return; - } - if (!abawoDevice) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'This app can only connect to abawo devices.')), - ); - return; - } else if (!connectable) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'This device is not connectable with the app.')), - ); - return; - } else { - final res = await controller.connect(device); - if (!mounted) { - return; - } - - switch (res) { - case Ok(): - // trigger pairing/permission prompt if needed - if (!Platform.isAndroid) { - controller.readCharacteristic( - device.id, - '0993826f-0ee4-4b37-9614-d13ecba4ffc2', - '0993826f-0ee4-4b37-9614-d13ecba40000'); - } - // Save to DB and navigate - 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); - - // Check if mounted before using context - if (!context.mounted) break; - - 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): - if (!context.mounted) { - break; - } - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar( - content: Text( - 'Connection unsuccessful:\n${v.toString()}'), - )); - break; - } - } - }, - child: DeviceListItem( - deviceName: deviceName, + onTap: () => _connectDevice( + controller, + device, + isAlreadyConnected, + ), + borderRadius: BorderRadius.circular(22), + child: _DiscoveredDeviceCard( + deviceName: device.name.isEmpty + ? 'Unknown Device' + : device.name, deviceId: device.id, - type: deviceTypeFromUuids(device.serviceUuids), + deviceType: + deviceTypeFromUuids(device.serviceUuids), + rssi: device.rssi, + tone: tone, ), ); }, @@ -261,97 +315,323 @@ class _ConnectDevicePageState extends ConsumerState }, ), ), - // Bottom section: Scanning Animation and Retry Button (visible only when scanning) Consumer( - // Use Consumer to get the controller for the retry button action - builder: (context, ref, child) { - final btController = ref - .watch(bluetoothProvider) - .asData - ?.value; // Get controller safely + 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; - - // Show bottom section only if scanning - if (!isScanning) { - // Show only the retry button when not scanning (optional, could be hidden) - // For now, let's keep the button always visible but disabled when not scannable. - // A better approach might be to hide the button when not scanning. - // Let's show the button but potentially disabled later if controller is null. + return StreamBuilder( + stream: btController?.isScanningStream ?? Stream.empty(), + initialData: false, + builder: (context, snapshot) { + final isScanning = snapshot.data ?? false; return Padding( - padding: const EdgeInsets.all(16.0), - child: ElevatedButton( - onPressed: btController != null - ? () { - // Retry scan ONLY when NOT currently scanning - if (mounted) { - setState(() { - _initialScanTriggered = - true; // Ensure state reflects scan attempt - _retryScanCounter++; // Increment key counter - }); - } - btController.startScan(timeout: _scanDuration); - } - : null, // Disable if controller not ready - child: const Text('Retry Scan'), + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: _ScanFooterCard( + isScanning: isScanning, + retryKey: _retryScanCounter, + onPressed: isScanning || btController == null + ? null + : () => _retryScan(btController), ), ); - } - - // If scanning, show animation and button - return Container( - padding: const EdgeInsets.symmetric( - vertical: 8.0, horizontal: 16.0), - decoration: BoxDecoration( - color: Theme.of(context) - .scaffoldBackgroundColor - .withValues(alpha: 0.9), // Slight overlay effect - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, -4), // Shadow upwards - ) - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, // Keep column compact - children: [ - // Pass isScanning and the ValueKey - HorizontalScanningAnimation( - key: ValueKey( - _retryScanCounter), // Force state rebuild on counter change - isScanning: isScanning, - height: 40, - ), - const SizedBox(height: 8), - ElevatedButton( - // Button does nothing if pressed *while* scanning. - // It just indicates the status. - onPressed: null, // Disable button while scanning - style: ElevatedButton.styleFrom( - disabledBackgroundColor: Theme.of(context) - .primaryColor - .withValues(alpha: 0.5), // Custom disabled color - disabledForegroundColor: Colors.white70, - ), - child: - const Text('Scanning...'), // Just indicate status - ), - const SizedBox(height: 8), // Add some bottom padding - ], - ), - ); - }, - ); - }), - ], // End of outer Column children - ), // End of Scaffold + }, + ); + }, + ), + ], + ), ); } } + +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); +} diff --git a/lib/widgets/bike_scan_dialog.dart b/lib/widgets/bike_scan_dialog.dart index 0c144d0..7f5e705 100644 --- a/lib/widgets/bike_scan_dialog.dart +++ b/lib/widgets/bike_scan_dialog.dart @@ -53,13 +53,17 @@ class _BikeScanDialogState extends ConsumerState { @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; return Dialog( clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + insetPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), child: SizedBox( - width: 520, - height: 520, + width: dialogWidth, + height: dialogHeight, child: btAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, _) => Center(child: Text('Bluetooth error: $err')), @@ -67,54 +71,146 @@ class _BikeScanDialogState extends ConsumerState { _controller ??= controller; return Column( children: [ - _buildHeader(context), - const Divider(height: 1), + _DialogHeader( + showAll: _showAll, + onChanged: (value) { + setState(() { + _showAll = value; + }); + }, + onRescan: _startScan, + ), Expanded( child: StreamBuilder>( stream: controller.scanResultsStream, initialData: controller.scanResults, builder: (context, snapshot) { - final devices = - _filteredDevices(snapshot.data ?? const []); + final devices = _filteredDevices(snapshot.data ?? const []); if (devices.isEmpty) { - return const Center( - child: Text('No matching devices nearby.'), + return const Padding( + padding: EdgeInsets.all(20), + child: Center( + child: Text( + 'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.', + textAlign: TextAlign.center, + ), + ), ); } return ListView.separated( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), itemCount: devices.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { final device = devices[index]; - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + final isFtms = + device.serviceUuids.contains(Uuid.parse(ftmsServiceUuid)); + 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), + ), + ], + ), + ], + ), + ), ), - leading: CircleAvatar( - backgroundColor: Theme.of(context) - .colorScheme - .primaryContainer, - child: const Icon(Icons.pedal_bike), - ), - title: Text( - device.name.isEmpty - ? 'Unknown Device' - : device.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - device.id, - style: const TextStyle(fontFamily: 'monospace'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: _RssiBadge(rssi: device.rssi), - onTap: () { - Navigator.of(context).pop(device); - }, ); }, ); @@ -129,45 +225,6 @@ class _BikeScanDialogState extends ConsumerState { ); } - Widget _buildHeader(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), - child: Row( - children: [ - const Expanded( - child: Text( - 'Select Bike', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), - ), - ), - Row( - children: [ - const Text('Show All'), - Switch( - value: _showAll, - onChanged: (value) { - setState(() { - _showAll = value; - }); - }, - ), - ], - ), - IconButton( - tooltip: 'Rescan', - onPressed: _startScan, - icon: const Icon(Icons.refresh), - ), - IconButton( - tooltip: 'Close', - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - ); - } - List _filteredDevices(List devices) { final ftmsUuid = Uuid.parse(ftmsServiceUuid); return devices.where((device) { @@ -182,6 +239,86 @@ class _BikeScanDialogState extends ConsumerState { } } +class _DialogHeader extends StatelessWidget { + const _DialogHeader({ + required this.showAll, + required this.onChanged, + required this.onRescan, + }); + + final bool showAll; + 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( + 'Tap a nearby trainer to assign it to the connected shifter.', + 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( + 'Show All', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Switch(value: showAll, onChanged: onChanged), + ], + ), + ), + OutlinedButton.icon( + onPressed: onRescan, + icon: const Icon(Icons.refresh), + label: const Text('Rescan'), + ), + ], + ), + ], + ), + ); + } +} + class _RssiBadge extends StatelessWidget { const _RssiBadge({required this.rssi}); @@ -190,22 +327,22 @@ class _RssiBadge extends StatelessWidget { @override Widget build(BuildContext context) { final color = rssi > -65 - ? Colors.green + ? const Color(0xFF40C979) : rssi > -80 - ? Colors.orange - : Colors.red; + ? 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(8), + borderRadius: BorderRadius.circular(999), ), child: Text( '$rssi dBm', style: TextStyle( color: color, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontSize: 12, ), ),