1061 lines
32 KiB
Dart
1061 lines
32 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
|
import 'package:abawo_bt_app/controller/shifter_device_telemetry.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/firmware_file_selection.dart';
|
|
import 'package:abawo_bt_app/model/shifter_types.dart';
|
|
import 'package:abawo_bt_app/pages/bootloader_recovery_update_page.dart';
|
|
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
|
show DiscoveredDevice, ScanMode, Uuid;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
class DevicesTabPage extends ConsumerStatefulWidget {
|
|
const DevicesTabPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<DevicesTabPage> createState() => _DevicesTabPageState();
|
|
}
|
|
|
|
class _DevicesTabPageState extends ConsumerState<DevicesTabPage> {
|
|
static const Duration _bootloaderScanTimeout = Duration(seconds: 10);
|
|
|
|
StreamSubscription<List<DiscoveredDevice>>? _scanSubscription;
|
|
DiscoveredDevice? _dfuDevice;
|
|
bool _isBootloaderScanStarting = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_startBootloaderBackgroundScan());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
final bluetooth = ref.read(bluetoothProvider).valueOrNull;
|
|
unawaited(_scanSubscription?.cancel());
|
|
unawaited(_stopBootloaderScan(bluetooth));
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _startBootloaderBackgroundScan() async {
|
|
if (_isBootloaderScanStarting || _scanSubscription != null) {
|
|
return;
|
|
}
|
|
_isBootloaderScanStarting = true;
|
|
_clearBootloaderDevice();
|
|
|
|
try {
|
|
final bluetooth = await ref.read(bluetoothProvider.future);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
final scanResult = await bluetooth.startScan(
|
|
timeout: _bootloaderScanTimeout,
|
|
scanMode: ScanMode.lowLatency,
|
|
);
|
|
if (scanResult.isErr()) {
|
|
return;
|
|
}
|
|
|
|
_updateBootloaderDevice(bluetooth.scanResults);
|
|
_scanSubscription = bluetooth.scanResultsStream.listen(
|
|
_updateBootloaderDevice,
|
|
);
|
|
} finally {
|
|
_isBootloaderScanStarting = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _stopBootloaderScan([BluetoothController? bluetooth]) async {
|
|
await _scanSubscription?.cancel();
|
|
_scanSubscription = null;
|
|
|
|
await bluetooth?.stopScan();
|
|
}
|
|
|
|
void _updateBootloaderDevice(List<DiscoveredDevice> devices) {
|
|
final dfuDevice = devices.cast<DiscoveredDevice?>().firstWhere(
|
|
(device) => device != null && _isBootloaderAdvertisement(device),
|
|
orElse: () => null,
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (dfuDevice == null) {
|
|
_clearBootloaderDevice();
|
|
return;
|
|
}
|
|
|
|
if (dfuDevice.id == _dfuDevice?.id) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_dfuDevice = dfuDevice;
|
|
});
|
|
}
|
|
|
|
void _clearBootloaderDevice() {
|
|
if (!mounted || _dfuDevice == null) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_dfuDevice = null;
|
|
});
|
|
}
|
|
|
|
bool _isBootloaderAdvertisement(DiscoveredDevice device) {
|
|
final name = device.name.trim();
|
|
if (name == 'US-DFU' || name == 'UniversalShifters DFU') {
|
|
return true;
|
|
}
|
|
return name.toLowerCase().contains('dfu') &&
|
|
device.serviceUuids.any(
|
|
(uuid) =>
|
|
uuid.expanded ==
|
|
Uuid.parse(universalShifterControlServiceUuid).expanded,
|
|
);
|
|
}
|
|
|
|
Future<void> _openBootloaderRecovery() async {
|
|
final device = _dfuDevice;
|
|
if (device == null) {
|
|
return;
|
|
}
|
|
|
|
final firmware =
|
|
await Navigator.of(context).push<BootloaderDfuPreparedFirmware>(
|
|
MaterialPageRoute(
|
|
fullscreenDialog: true,
|
|
builder: (_) => _BootloaderRecoverySetupPage(device: device),
|
|
),
|
|
);
|
|
if (!mounted || firmware == null) {
|
|
return;
|
|
}
|
|
|
|
await _stopBootloaderScan();
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
_clearBootloaderDevice();
|
|
context.push(
|
|
'/bootloader_recovery_update',
|
|
extra: BootloaderRecoveryUpdateArgs(
|
|
bootloaderDeviceId: device.id,
|
|
firmware: firmware,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final devicesAsync = ref.watch(nConnectedDevicesProvider);
|
|
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
|
final dfuDevice = _dfuDevice;
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Devices',
|
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Manage connected hardware and jump back into setup.',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.68),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton.filledTonal(
|
|
tooltip: 'Connect a device',
|
|
onPressed: () => context.push('/connect_device'),
|
|
icon: const Icon(Icons.add),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
if (dfuDevice != null) ...[
|
|
_BootloaderRecoveryCard(
|
|
device: dfuDevice,
|
|
onStartRecovery: _openBootloaderRecovery,
|
|
),
|
|
const SizedBox(height: 20),
|
|
],
|
|
devicesAsync.when(
|
|
loading: () => const _LoadingCard(),
|
|
error: (error, _) => _MessageCard(
|
|
title: 'Could not load devices',
|
|
message: error.toString(),
|
|
),
|
|
data: (devices) => _ActiveDeviceCard(
|
|
devices: devices,
|
|
connectionData: connectionData,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'My Devices',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: () => context.push('/connect_device'),
|
|
icon: const Icon(Icons.add_circle_outline),
|
|
label: const Text('Add Device'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
const _SavedDevicesList(),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BootloaderRecoveryCard extends StatelessWidget {
|
|
const _BootloaderRecoveryCard({
|
|
required this.device,
|
|
required this.onStartRecovery,
|
|
});
|
|
|
|
final DiscoveredDevice device;
|
|
final VoidCallback onStartRecovery;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Card(
|
|
color: colorScheme.errorContainer.withValues(alpha: 0.45),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.system_update_alt, color: colorScheme.error),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'US-DFU Device Detected',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
const Text(
|
|
'US-DFU (Universal Shifters Firmware Update) device detected. Maybe a previous update failed?',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
device.name.isEmpty ? device.id : '${device.name} - ${device.id}',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
FilledButton.icon(
|
|
onPressed: onStartRecovery,
|
|
icon: const Icon(Icons.build_circle_outlined),
|
|
label: const Text('Start Recovery'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BootloaderRecoverySetupPage extends ConsumerStatefulWidget {
|
|
const _BootloaderRecoverySetupPage({required this.device});
|
|
|
|
final DiscoveredDevice device;
|
|
|
|
@override
|
|
ConsumerState<_BootloaderRecoverySetupPage> createState() =>
|
|
_BootloaderRecoverySetupPageState();
|
|
}
|
|
|
|
class _BootloaderRecoverySetupPageState
|
|
extends ConsumerState<_BootloaderRecoverySetupPage> {
|
|
final FirmwareFileSelectionService _firmwareFileSelectionService =
|
|
FirmwareFileSelectionService(filePicker: LocalFirmwareFilePicker());
|
|
|
|
BootloaderDfuPreparedFirmware? _selectedFirmware;
|
|
bool _isSelectingFirmware = false;
|
|
String? _message;
|
|
|
|
Future<void> _selectFirmwareFile() async {
|
|
if (_isSelectingFirmware) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isSelectingFirmware = true;
|
|
_message = null;
|
|
});
|
|
|
|
final suppressionCount = ref.read(
|
|
backgroundBluetoothDisconnectSuppressionCountProvider.notifier,
|
|
);
|
|
suppressionCount.state += 1;
|
|
|
|
final FirmwareFileSelectionResult result;
|
|
try {
|
|
result =
|
|
await _firmwareFileSelectionService.selectAndPrepareBootloaderDfu();
|
|
} finally {
|
|
suppressionCount.state =
|
|
suppressionCount.state <= 0 ? 0 : suppressionCount.state - 1;
|
|
}
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isSelectingFirmware = false;
|
|
if (result.isSuccess) {
|
|
_selectedFirmware = result.firmware;
|
|
_message =
|
|
'Validated ${result.firmware!.fileName}. Ready to start recovery.';
|
|
} else if (!result.isCanceled) {
|
|
_message = result.failure?.message;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _startRecovery() {
|
|
final firmware = _selectedFirmware;
|
|
if (firmware == null) {
|
|
setState(() {
|
|
_message = 'Select a firmware .bin file before starting recovery.';
|
|
});
|
|
return;
|
|
}
|
|
|
|
Navigator.of(context).pop(firmware);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
final selectedFirmware = _selectedFirmware;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('US-DFU Recovery'),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(20),
|
|
children: [
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.system_update_alt_rounded,
|
|
color: colorScheme.primary),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
'Recover Firmware Update',
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
'Select a raw app image for the detected US-DFU bootloader. Starting recovery opens the firmware update screen.',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
widget.device.name.isEmpty
|
|
? widget.device.id
|
|
: '${widget.device.name} - ${widget.device.id}',
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
selectedFirmware == null
|
|
? 'Selected file: none'
|
|
: 'Selected file: ${selectedFirmware.fileName}',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (selectedFirmware != null) ...[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Size: ${selectedFirmware.fileBytes.length} bytes | Session: ${selectedFirmware.metadata.sessionId} | CRC32: 0x${selectedFirmware.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
],
|
|
const SizedBox(height: 14),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed:
|
|
_isSelectingFirmware ? null : _selectFirmwareFile,
|
|
icon: _isSelectingFirmware
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child:
|
|
CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.upload_file),
|
|
label: const Text('Select Firmware'),
|
|
),
|
|
FilledButton.icon(
|
|
onPressed:
|
|
selectedFirmware == null ? null : _startRecovery,
|
|
icon: const Icon(Icons.system_update_alt),
|
|
label: const Text('Start Update'),
|
|
),
|
|
],
|
|
),
|
|
if (_message != null && _message!.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
Text(_message!),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SavedDevicesList extends ConsumerStatefulWidget {
|
|
const _SavedDevicesList();
|
|
|
|
@override
|
|
ConsumerState<_SavedDevicesList> createState() => _SavedDevicesListState();
|
|
}
|
|
|
|
class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> {
|
|
String? _connectingDeviceId;
|
|
|
|
Future<void> _removeDevice(ConnectedDevice device) async {
|
|
final shouldRemove = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Remove device?'),
|
|
content:
|
|
Text('Do you want to remove ${device.deviceName} from the app?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
child: const Text('Remove'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (shouldRemove != true || !mounted) {
|
|
return;
|
|
}
|
|
|
|
final result = await ref
|
|
.read(nConnectedDevicesProvider.notifier)
|
|
.deleteConnectedDevice(device.id);
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (result.isErr()) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to remove device: ${result.unwrapErr()}'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (_connectingDeviceId == device.deviceAddress) {
|
|
setState(() {
|
|
_connectingDeviceId = null;
|
|
});
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('${device.deviceName} removed from the app.')),
|
|
);
|
|
}
|
|
|
|
Future<void> _openDevice(ConnectedDevice device) async {
|
|
if (_connectingDeviceId != null) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_connectingDeviceId = device.deviceAddress;
|
|
});
|
|
|
|
try {
|
|
final controller = await ref.read(bluetoothProvider.future);
|
|
final result = await controller.connectById(
|
|
device.deviceAddress,
|
|
timeout: const Duration(seconds: 10),
|
|
);
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (result.isOk()) {
|
|
await ref
|
|
.read(nConnectedDevicesProvider.notifier)
|
|
.updateConnectedDeviceLastConnected(device.id);
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
context.push('/device/${device.deviceAddress}');
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Connection failed. Is the device turned on and in range?',
|
|
),
|
|
duration: Duration(seconds: 3),
|
|
),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Error: $error')),
|
|
);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_connectingDeviceId = null;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final devicesAsync = ref.watch(nConnectedDevicesProvider);
|
|
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
|
final connectedDeviceId = connectionData?.$2;
|
|
final connectionState = connectionData?.$1;
|
|
|
|
return devicesAsync.when(
|
|
loading: () => const _LoadingCard(),
|
|
error: (error, _) => _MessageCard(
|
|
title: 'Could not load saved devices',
|
|
message: error.toString(),
|
|
),
|
|
data: (devices) {
|
|
if (devices.isEmpty) {
|
|
return _MessageCard(
|
|
title: 'No devices yet',
|
|
message: 'Add your first shifter to start configuring it.',
|
|
actionLabel: 'Connect Device',
|
|
onAction: () => context.push('/connect_device'),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
for (final device in devices) ...[
|
|
_SavedDeviceTile(
|
|
device: device,
|
|
isConnecting: device.deviceAddress == _connectingDeviceId,
|
|
isConnected: connectedDeviceId == device.deviceAddress &&
|
|
connectionState == ConnectionStatus.connected,
|
|
onTap: () => _openDevice(device),
|
|
onRemove: () => _removeDevice(device),
|
|
),
|
|
if (device != devices.last) const SizedBox(height: 12),
|
|
],
|
|
const SizedBox(height: 12),
|
|
OutlinedButton.icon(
|
|
onPressed: () => context.push('/connect_device'),
|
|
icon: const Icon(Icons.bluetooth_searching),
|
|
label: const Text('Connect Device'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ActiveDeviceCard extends ConsumerWidget {
|
|
const _ActiveDeviceCard({
|
|
required this.devices,
|
|
required this.connectionData,
|
|
});
|
|
|
|
final List<ConnectedDevice> devices;
|
|
final (ConnectionStatus, String?)? connectionData;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final shifterDevices = devices
|
|
.where(
|
|
(device) =>
|
|
deviceTypeFromString(device.deviceType) ==
|
|
DeviceType.universalShifters,
|
|
)
|
|
.toList()
|
|
..sort((a, b) {
|
|
final aLastConnected = a.lastConnectedAt ?? a.createdAt;
|
|
final bLastConnected = b.lastConnectedAt ?? b.createdAt;
|
|
return bLastConnected.compareTo(aLastConnected);
|
|
});
|
|
|
|
if (shifterDevices.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final connectedId = connectionData?.$2;
|
|
final primaryDevice = connectedId == null
|
|
? shifterDevices.first
|
|
: shifterDevices.firstWhere(
|
|
(device) => device.deviceAddress == connectedId,
|
|
orElse: () => shifterDevices.first,
|
|
);
|
|
final isConnected = connectedId == primaryDevice.deviceAddress &&
|
|
connectionData?.$1 == ConnectionStatus.connected;
|
|
|
|
final telemetry = ref.watch(
|
|
shifterDeviceTelemetryCacheProvider.select(
|
|
(cache) => cache[primaryDevice.deviceAddress],
|
|
),
|
|
);
|
|
final batteryLabel = telemetry?.batteryLabel ?? '--';
|
|
const signalLabel = 'Ready';
|
|
final firmwareLabel = telemetry?.firmwareLabel ?? '--';
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(18),
|
|
child: Image.asset(
|
|
'assets/images/shifter-wireframe.png',
|
|
width: 96,
|
|
height: 72,
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
primaryDevice.deviceName,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_StatusChip(
|
|
label: isConnected ? 'Connected' : 'Saved device',
|
|
color: isConnected
|
|
? const Color(0xFF40C979)
|
|
: Theme.of(context).colorScheme.primary,
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
primaryDevice.deviceAddress,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.62),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _MetricTile(
|
|
label: 'Battery',
|
|
value: batteryLabel,
|
|
icon: Icons.battery_charging_full_rounded,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
const Expanded(
|
|
child: _MetricTile(
|
|
label: 'Signal',
|
|
value: signalLabel,
|
|
icon: Icons.signal_cellular_alt,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _MetricTile(
|
|
label: 'Firmware',
|
|
value: firmwareLabel,
|
|
icon: Icons.memory_rounded,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SavedDeviceTile extends StatelessWidget {
|
|
const _SavedDeviceTile({
|
|
required this.device,
|
|
required this.isConnecting,
|
|
required this.isConnected,
|
|
required this.onTap,
|
|
required this.onRemove,
|
|
});
|
|
|
|
final ConnectedDevice device;
|
|
final bool isConnecting;
|
|
final bool isConnected;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onRemove;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Material(
|
|
color: colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(22),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(22),
|
|
onTap: isConnecting ? null : onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(22),
|
|
border: Border.all(
|
|
color: isConnected
|
|
? colorScheme.primary.withValues(alpha: 0.65)
|
|
: colorScheme.outlineVariant.withValues(alpha: 0.55),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: isConnected
|
|
? colorScheme.primary.withValues(alpha: 0.14)
|
|
: colorScheme.surfaceContainerHighest
|
|
.withValues(alpha: 0.7),
|
|
),
|
|
child: Icon(
|
|
deviceTypeFromString(device.deviceType) ==
|
|
DeviceType.universalShifters
|
|
? Icons.bluetooth_rounded
|
|
: Icons.memory_rounded,
|
|
color:
|
|
isConnected ? colorScheme.primary : colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
device.deviceName,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
isConnected ? 'Connected' : 'Saved device',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: isConnected
|
|
? const Color(0xFF40C979)
|
|
: colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
device.deviceAddress,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color:
|
|
colorScheme.onSurface.withValues(alpha: 0.62),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (isConnecting)
|
|
const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2.4),
|
|
)
|
|
else
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Remove device',
|
|
onPressed: onRemove,
|
|
icon: const Icon(Icons.delete_outline),
|
|
),
|
|
Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.55),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MetricTile extends StatelessWidget {
|
|
const _MetricTile({
|
|
required this.label,
|
|
required this.value,
|
|
required this.icon,
|
|
});
|
|
|
|
final String label;
|
|
final String value;
|
|
final IconData icon;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 18, color: colorScheme.primary),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurface.withValues(alpha: 0.62),
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
value,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageCard extends StatelessWidget {
|
|
const _MessageCard({
|
|
required this.title,
|
|
required this.message,
|
|
this.actionLabel,
|
|
this.onAction,
|
|
});
|
|
|
|
final String title;
|
|
final String message;
|
|
final String? actionLabel;
|
|
final VoidCallback? onAction;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
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),
|
|
),
|
|
),
|
|
if (actionLabel != null && onAction != null) ...[
|
|
const SizedBox(height: 14),
|
|
FilledButton.icon(
|
|
onPressed: onAction,
|
|
icon: const Icon(Icons.add_circle_outline),
|
|
label: Text(actionLabel!),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LoadingCard extends StatelessWidget {
|
|
const _LoadingCard();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const Card(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(28),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusChip extends StatelessWidget {
|
|
const _StatusChip({
|
|
required this.label,
|
|
required this.color,
|
|
});
|
|
|
|
final String label;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: color.withValues(alpha: 0.24)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.check_circle_outline, size: 14, color: color),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: color,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|