638 lines
21 KiB
Dart
638 lines
21 KiB
Dart
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';
|
|
|
|
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 = 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;
|
|
}
|
|
}
|
|
|
|
@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:
|
|
isAbawoDeviceIdent(device.manufacturerData),
|
|
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);
|
|
}
|