feat(ui): restyle device scanning flows

This commit is contained in:
2026-04-23 22:24:03 +02:00
parent 9016b9de77
commit 87193c3ae9
2 changed files with 751 additions and 334 deletions

View File

@ -1,17 +1,16 @@
import 'dart:io'; import 'dart:io';
import 'package:abawo_bt_app/controller/bluetooth.dart'; 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/model/bluetooth_device_model.dart';
import 'package:abawo_bt_app/util/constants.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:anyhow/anyhow.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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); const Duration _scanDuration = Duration(seconds: 10);
@ -22,42 +21,118 @@ class ConnectDevicePage extends ConsumerStatefulWidget {
ConsumerState<ConnectDevicePage> createState() => _ConnectDevicePageState(); ConsumerState<ConnectDevicePage> createState() => _ConnectDevicePageState();
} }
class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
with TickerProviderStateMixin { int _retryScanCounter = 0;
// TickerProviderStateMixin is no longer needed as animations are self-contained or handled by StreamBuilder bool _initialScanTriggered = false;
int _retryScanCounter = 0; // Used to force animation reset bool _showOnlyAbawoDevices = true;
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
void _startScanIfNeeded(BluetoothController controller) { void _startScanIfNeeded(BluetoothController controller) {
// Use WidgetsBinding to schedule the scan start after the build phase
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Start scan only if it hasn't been triggered yet and the widget is mounted
if (!_initialScanTriggered && mounted) { if (!_initialScanTriggered && mounted) {
controller.startScan(timeout: _scanDuration); controller.startScan(timeout: _scanDuration);
if (mounted) { setState(() {
setState(() { _initialScanTriggered = true;
_initialScanTriggered = true; });
});
}
} }
}); });
} }
@override void _retryScan(BluetoothController controller) {
void initState() { if (!mounted) {
super.initState(); return;
super.initState(); }
// No animation controllers needed here anymore
setState(() {
_initialScanTriggered = true;
_retryScanCounter++;
});
controller.startScan(timeout: _scanDuration);
} }
@override Future<void> _connectDevice(
void dispose() { BluetoothController controller,
// Dispose controllers if they existed (they don't anymore) DiscoveredDevice device,
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -67,40 +142,77 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/devices'), 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( body: Column(
// Use Column instead of Center(Column(...))
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.all(16.0), // Add padding around the title padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Text( child: Container(
'Available Devices', padding: const EdgeInsets.all(18),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), 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( Expanded(
// Allow the device list to take available space
child: Consumer( child: Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final btAsyncValue = ref.watch(bluetoothProvider); final btAsyncValue = ref.watch(bluetoothProvider);
@ -112,144 +224,86 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
.toSet(); .toSet();
return btAsyncValue.when( return btAsyncValue.when(
loading: () => loading: () => const Center(child: CircularProgressIndicator()),
const Center(child: CircularProgressIndicator()), error: (err, stack) => Padding(
error: (err, stack) => Center(child: Text('Error: $err')), padding: const EdgeInsets.symmetric(horizontal: 20),
child: _ScanMessageCard(
title: 'Bluetooth unavailable',
message: '$err',
),
),
data: (controller) { data: (controller) {
// Trigger the initial scan if needed
_startScanIfNeeded(controller); _startScanIfNeeded(controller);
// StreamBuilder for Scan Results (Device List)
return StreamBuilder<List<DiscoveredDevice>>( return StreamBuilder<List<DiscoveredDevice>>(
stream: controller.scanResultsStream, stream: controller.scanResultsStream,
initialData: const [], initialData: const [],
builder: (context, snapshot) { builder: (context, snapshot) {
final results = snapshot.data ?? []; final results = snapshot.data ?? [];
// Filter results based on the toggle state
final filteredResults = _showOnlyAbawoDevices final filteredResults = _showOnlyAbawoDevices
? results ? results
.where((device) => .where((device) =>
device.serviceUuids.any(isAbawoDeviceGuid)) device.serviceUuids.any(isAbawoDeviceGuid))
.toList() .toList(growable: false)
: results; : results;
if (!_initialScanTriggered && filteredResults.isEmpty) { if (!_initialScanTriggered && filteredResults.isEmpty) {
// Show a message or placeholder before the first scan starts or if no devices found initially return const Padding(
return const Center( padding: EdgeInsets.symmetric(horizontal: 20),
child: Text( child: _ScanMessageCard(
'Scanning for devices...')); // Or CircularProgressIndicator() title: 'Looking for devices',
message:
'The scan has started. Nearby hardware will appear here as soon as it is discovered.',
),
);
} }
if (filteredResults.isEmpty && _initialScanTriggered) { if (filteredResults.isEmpty && _initialScanTriggered) {
// Show 'No devices found' only after the initial scan was triggered return Padding(
return const Center(child: Text('No devices found.')); 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.separated(
return ListView.builder( padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
itemCount: filteredResults.length, itemCount: filteredResults.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final device = filteredResults[index]; final device = filteredResults[index];
final isAlreadyConnected = final isAlreadyConnected =
connectedDeviceAddresses.contains(device.id); connectedDeviceAddresses.contains(device.id);
final abawoDevice = final tone = _ScanResultTone.resolve(
// device.serviceUuids.any(isAbawoDeviceGuid); isAlreadyConnected: isAlreadyConnected,
isAbawoDeviceIdent(device.manufacturerData); isAbawoDevice:
final connectable = device.serviceUuids isAbawoDeviceIdent(device.manufacturerData),
.any(isConnectableAbawoDeviceGuid); isConnectable: device.serviceUuids
final deviceName = device.name.isEmpty .any(isConnectableAbawoDeviceGuid),
? 'Unknown Device' );
: device.name;
return InkWell( return InkWell(
onTap: () async { onTap: () => _connectDevice(
if (isAlreadyConnected) { controller,
ScaffoldMessenger.of(context).showSnackBar( device,
const SnackBar( isAlreadyConnected,
content: Text( ),
'This device is already connected in the app.'), borderRadius: BorderRadius.circular(22),
), child: _DiscoveredDeviceCard(
); deviceName: device.name.isEmpty
return; ? 'Unknown Device'
} : device.name,
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,
deviceId: device.id, deviceId: device.id,
type: deviceTypeFromUuids(device.serviceUuids), deviceType:
deviceTypeFromUuids(device.serviceUuids),
rssi: device.rssi,
tone: tone,
), ),
); );
}, },
@ -261,97 +315,323 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
}, },
), ),
), ),
// Bottom section: Scanning Animation and Retry Button (visible only when scanning)
Consumer( Consumer(
// Use Consumer to get the controller for the retry button action builder: (context, ref, child) {
builder: (context, ref, child) { final btController = ref.watch(bluetoothProvider).asData?.value;
final btController = ref
.watch(bluetoothProvider)
.asData
?.value; // Get controller safely
return StreamBuilder<bool>( return StreamBuilder<bool>(
stream: btController?.isScanningStream ?? Stream<bool>.empty(), stream: btController?.isScanningStream ?? Stream<bool>.empty(),
initialData: false, initialData: false,
builder: (context, snapshot) { builder: (context, snapshot) {
final isScanning = snapshot.data ?? false; 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 Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: ElevatedButton( child: _ScanFooterCard(
onPressed: btController != null isScanning: isScanning,
? () { retryKey: _retryScanCounter,
// Retry scan ONLY when NOT currently scanning onPressed: isScanning || btController == null
if (mounted) { ? null
setState(() { : () => _retryScan(btController),
_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'),
), ),
); );
} },
);
// 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);
}

View File

@ -53,13 +53,17 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final btAsync = ref.watch(bluetoothProvider); 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( return Dialog(
clipBehavior: Clip.antiAlias, 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( child: SizedBox(
width: 520, width: dialogWidth,
height: 520, height: dialogHeight,
child: btAsync.when( child: btAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Bluetooth error: $err')), error: (err, _) => Center(child: Text('Bluetooth error: $err')),
@ -67,54 +71,146 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
_controller ??= controller; _controller ??= controller;
return Column( return Column(
children: [ children: [
_buildHeader(context), _DialogHeader(
const Divider(height: 1), showAll: _showAll,
onChanged: (value) {
setState(() {
_showAll = value;
});
},
onRescan: _startScan,
),
Expanded( Expanded(
child: StreamBuilder<List<DiscoveredDevice>>( child: StreamBuilder<List<DiscoveredDevice>>(
stream: controller.scanResultsStream, stream: controller.scanResultsStream,
initialData: controller.scanResults, initialData: controller.scanResults,
builder: (context, snapshot) { builder: (context, snapshot) {
final devices = final devices = _filteredDevices(snapshot.data ?? const []);
_filteredDevices(snapshot.data ?? const []);
if (devices.isEmpty) { if (devices.isEmpty) {
return const Center( return const Padding(
child: Text('No matching devices nearby.'), 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( return ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
itemCount: devices.length, itemCount: devices.length,
separatorBuilder: (_, __) => const Divider(height: 1), separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final device = devices[index]; final device = devices[index];
return ListTile( final isFtms =
contentPadding: const EdgeInsets.symmetric( device.serviceUuids.contains(Uuid.parse(ftmsServiceUuid));
horizontal: 16, return Material(
vertical: 8, 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<BikeScanDialog> {
); );
} }
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<DiscoveredDevice> _filteredDevices(List<DiscoveredDevice> devices) { List<DiscoveredDevice> _filteredDevices(List<DiscoveredDevice> devices) {
final ftmsUuid = Uuid.parse(ftmsServiceUuid); final ftmsUuid = Uuid.parse(ftmsServiceUuid);
return devices.where((device) { return devices.where((device) {
@ -182,6 +239,86 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
} }
} }
class _DialogHeader extends StatelessWidget {
const _DialogHeader({
required this.showAll,
required this.onChanged,
required this.onRescan,
});
final bool showAll;
final ValueChanged<bool> 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 { class _RssiBadge extends StatelessWidget {
const _RssiBadge({required this.rssi}); const _RssiBadge({required this.rssi});
@ -190,22 +327,22 @@ class _RssiBadge extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = rssi > -65 final color = rssi > -65
? Colors.green ? const Color(0xFF40C979)
: rssi > -80 : rssi > -80
? Colors.orange ? const Color(0xFFFFB649)
: Colors.red; : Theme.of(context).colorScheme.error;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.15), color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(999),
), ),
child: Text( child: Text(
'$rssi dBm', '$rssi dBm',
style: TextStyle( style: TextStyle(
color: color, color: color,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
fontSize: 12, fontSize: 12,
), ),
), ),