feat(ui): restyle device scanning flows
This commit is contained in:
@ -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<ConnectDevicePage> createState() => _ConnectDevicePageState();
|
||||
}
|
||||
|
||||
class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
|
||||
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<ConnectDevicePage> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
super.initState();
|
||||
// No animation controllers needed here anymore
|
||||
void _retryScan(BluetoothController controller) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Dispose controllers if they existed (they don't anymore)
|
||||
super.dispose();
|
||||
setState(() {
|
||||
_initialScanTriggered = true;
|
||||
_retryScanCounter++;
|
||||
});
|
||||
controller.startScan(timeout: _scanDuration);
|
||||
}
|
||||
|
||||
Future<void> _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<ConnectDevicePage>
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/devices'),
|
||||
),
|
||||
actions: [
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12.0),
|
||||
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: [
|
||||
const Text('abawo only'), // Label for the switch
|
||||
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) {
|
||||
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),
|
||||
),
|
||||
),
|
||||
// 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<ConnectDevicePage>
|
||||
.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<List<DiscoveredDevice>>(
|
||||
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.'),
|
||||
onTap: () => _connectDevice(
|
||||
controller,
|
||||
device,
|
||||
isAlreadyConnected,
|
||||
),
|
||||
);
|
||||
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,
|
||||
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<ConnectDevicePage>
|
||||
},
|
||||
),
|
||||
),
|
||||
// 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
|
||||
final btController = ref.watch(bluetoothProvider).asData?.value;
|
||||
|
||||
return StreamBuilder<bool>(
|
||||
stream: btController?.isScanningStream ?? Stream<bool>.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 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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
child: _ScanFooterCard(
|
||||
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
|
||||
],
|
||||
retryKey: _retryScanCounter,
|
||||
onPressed: isScanning || btController == null
|
||||
? null
|
||||
: () => _retryScan(btController),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
], // 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);
|
||||
}
|
||||
|
||||
@ -53,13 +53,17 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
@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,104 +71,157 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
_controller ??= controller;
|
||||
return Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<DiscoveredDevice>>(
|
||||
stream: controller.scanResultsStream,
|
||||
initialData: controller.scanResults,
|
||||
builder: (context, snapshot) {
|
||||
final devices =
|
||||
_filteredDevices(snapshot.data ?? const []);
|
||||
if (devices.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No matching devices nearby.'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: devices.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
_DialogHeader(
|
||||
showAll: _showAll,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showAll = value;
|
||||
});
|
||||
},
|
||||
onRescan: _startScan,
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<DiscoveredDevice>>(
|
||||
stream: controller.scanResultsStream,
|
||||
initialData: controller.scanResults,
|
||||
builder: (context, snapshot) {
|
||||
final devices = _filteredDevices(snapshot.data ?? const []);
|
||||
if (devices.isEmpty) {
|
||||
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 SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Rescan',
|
||||
onPressed: _startScan,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user