444 lines
17 KiB
Dart
444 lines
17 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
|
import 'package:abawo_bt_app/model/shifter_types.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
class BikeScanDialog extends ConsumerStatefulWidget {
|
|
const BikeScanDialog({
|
|
required this.excludedDeviceId,
|
|
super.key,
|
|
});
|
|
|
|
final String excludedDeviceId;
|
|
|
|
static Future<DiscoveredDevice?> show(
|
|
BuildContext context, {
|
|
required String excludedDeviceId,
|
|
}) {
|
|
return showDialog<DiscoveredDevice>(
|
|
context: context,
|
|
barrierDismissible: true,
|
|
builder: (_) => BikeScanDialog(excludedDeviceId: excludedDeviceId),
|
|
);
|
|
}
|
|
|
|
@override
|
|
ConsumerState<BikeScanDialog> createState() => _BikeScanDialogState();
|
|
}
|
|
|
|
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
|
bool _showAll = false;
|
|
bool _isStartingScan = true;
|
|
String? _scanError;
|
|
BluetoothController? _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_startScan());
|
|
}
|
|
|
|
Future<void> _startScan() async {
|
|
setState(() {
|
|
_isStartingScan = true;
|
|
_scanError = null;
|
|
});
|
|
|
|
try {
|
|
final controller = await ref.read(bluetoothProvider.future);
|
|
_controller = controller;
|
|
await controller.stopScan();
|
|
await controller.startScan();
|
|
} catch (error) {
|
|
_scanError = error.toString();
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isStartingScan = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller?.stopScan();
|
|
super.dispose();
|
|
}
|
|
|
|
@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,
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
|
child: SizedBox(
|
|
width: dialogWidth,
|
|
height: dialogHeight,
|
|
child: btAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (err, _) => Center(child: Text('Bluetooth error: $err')),
|
|
data: (controller) {
|
|
_controller ??= controller;
|
|
return Column(
|
|
children: [
|
|
_DialogHeader(
|
|
showAll: _showAll,
|
|
isScanning: _isStartingScan,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_showAll = value;
|
|
});
|
|
},
|
|
onRescan: _startScan,
|
|
),
|
|
Expanded(
|
|
child: _scanError != null
|
|
? _ScanMessage(
|
|
message: 'Could not start trainer scan: $_scanError',
|
|
action: TextButton.icon(
|
|
onPressed: _startScan,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Retry'),
|
|
),
|
|
)
|
|
: StreamBuilder<List<DiscoveredDevice>>(
|
|
stream: controller.scanResultsStream,
|
|
initialData: controller.scanResults,
|
|
builder: (context, snapshot) {
|
|
if (_isStartingScan &&
|
|
(snapshot.data == null ||
|
|
snapshot.data!.isEmpty)) {
|
|
return const Center(
|
|
child: CircularProgressIndicator());
|
|
}
|
|
|
|
final devices =
|
|
_filteredDevices(snapshot.data ?? const []);
|
|
if (devices.isEmpty) {
|
|
return const _ScanMessage(
|
|
message:
|
|
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
|
|
);
|
|
}
|
|
|
|
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 = _advertisesFtms(device);
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<DiscoveredDevice> _filteredDevices(List<DiscoveredDevice> devices) {
|
|
return devices.where((device) {
|
|
if (device.id == widget.excludedDeviceId) {
|
|
return false;
|
|
}
|
|
if (_showAll) {
|
|
return true;
|
|
}
|
|
return _advertisesFtms(device);
|
|
}).toList(growable: false);
|
|
}
|
|
|
|
bool _advertisesFtms(DiscoveredDevice device) {
|
|
return device.serviceUuids.any(isFtmsUuid) ||
|
|
device.serviceData.keys.any(isFtmsUuid);
|
|
}
|
|
}
|
|
|
|
class _DialogHeader extends StatelessWidget {
|
|
const _DialogHeader({
|
|
required this.showAll,
|
|
required this.isScanning,
|
|
required this.onChanged,
|
|
required this.onRescan,
|
|
});
|
|
|
|
final bool showAll;
|
|
final bool isScanning;
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 132,
|
|
child: OutlinedButton.icon(
|
|
style: OutlinedButton.styleFrom(
|
|
minimumSize: const Size(0, 48),
|
|
),
|
|
onPressed: isScanning ? null : onRescan,
|
|
icon: isScanning
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.refresh),
|
|
label: const Text('Rescan'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ScanMessage extends StatelessWidget {
|
|
const _ScanMessage({
|
|
required this.message,
|
|
this.action,
|
|
});
|
|
|
|
final String message;
|
|
final Widget? action;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
if (action != null) ...[
|
|
const SizedBox(height: 12),
|
|
SizedBox(width: 132, child: action!),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RssiBadge extends StatelessWidget {
|
|
const _RssiBadge({required this.rssi});
|
|
|
|
final int rssi;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = rssi > -65
|
|
? const Color(0xFF40C979)
|
|
: rssi > -80
|
|
? 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(999),
|
|
),
|
|
child: Text(
|
|
'$rssi dBm',
|
|
style: TextStyle(
|
|
color: color,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|