Files
abawo-bt-app/lib/pages/devices_page.dart

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