From ac93c01cea59d04491c7fcbc7e9bca271d23d045 Mon Sep 17 00:00:00 2001 From: Yandrik Date: Tue, 28 Apr 2026 20:22:15 +0200 Subject: [PATCH] feat: pairing ui --- lib/pages/device_details_page.dart | 178 ++++++++++++++++++++++++++--- 1 file changed, 164 insertions(+), 14 deletions(-) diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index 284507f..726e39f 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -59,15 +59,16 @@ class _DeviceDetailsPageState extends ConsumerState { bool _isExitingPage = false; bool _hasRequestedDisconnect = false; - bool _hasShownPairingRecoveryDialog = false; bool _isAssignTrainerDialogOpen = false; bool _isManualReconnectRunning = false; + bool _isPairingCheckRunning = false; ProviderSubscription>? _connectionStatusSubscription; ShifterService? _shifterService; StreamSubscription? _statusSubscription; CentralStatus? _latestStatus; + String? _pairingError; final List<_StatusHistoryEntry> _statusHistory = []; bool _isGearRatiosLoading = false; @@ -208,6 +209,9 @@ class _DeviceDetailsPageState extends ConsumerState { } return; } + if (_isPairingCheckRunning) { + return; + } final asyncBluetooth = ref.read(bluetoothProvider); final BluetoothController bluetooth; if (asyncBluetooth.hasValue) { @@ -223,6 +227,11 @@ class _DeviceDetailsPageState extends ConsumerState { buttonDeviceId: widget.deviceAddress, ); + setState(() { + _isPairingCheckRunning = true; + _pairingError = null; + }); + var initialStatusResult = await service.readStatus(); for (final delay in _initialStatusRetryDelays) { if (initialStatusResult.isOk() || !mounted) { @@ -247,8 +256,13 @@ class _DeviceDetailsPageState extends ConsumerState { } if (initialStatusResult.isErr()) { + final error = initialStatusResult.unwrapErr(); await service.dispose(); - await _showPairingRecoveryDialog(); + setState(() { + _isPairingCheckRunning = false; + _pairingError = error.toString(); + _latestStatus = null; + }); return; } @@ -264,20 +278,13 @@ class _DeviceDetailsPageState extends ConsumerState { service.startStatusNotifications(); setState(() { _shifterService = service; + _isPairingCheckRunning = false; + _pairingError = null; }); unawaited(_loadGearRatios()); unawaited(_loadDeviceTelemetry()); } - Future _showPairingRecoveryDialog() async { - if (!mounted || _hasShownPairingRecoveryDialog) { - return; - } - - _hasShownPairingRecoveryDialog = true; - await showBluetoothPairingRecoveryDialog(context); - } - void _recordStatus(CentralStatus status) { setState(() { _latestStatus = status; @@ -305,6 +312,7 @@ class _DeviceDetailsPageState extends ConsumerState { await _disposeFirmwareUpdateService(); await _shifterService?.dispose(); _shifterService = null; + _isPairingCheckRunning = false; _isDeviceTelemetryLoading = false; _hasLoadedDeviceTelemetry = false; } @@ -686,6 +694,25 @@ class _DeviceDetailsPageState extends ConsumerState { } } + Future _retryPairing() async { + if (_isPairingCheckRunning || _isFirmwareUpdateBusy) { + return; + } + + await _startStatusStreamingIfNeeded(); + } + + Future _openPairingSettings() async { + final opened = await openBluetoothSettings(); + if (!mounted || opened) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open Bluetooth settings.')), + ); + } + Future _exitPage() async { if (_isFirmwareUpdateBusy) { ScaffoldMessenger.of(context).showSnackBar( @@ -821,9 +848,11 @@ class _DeviceDetailsPageState extends ConsumerState { : ConnectionStatus.disconnected; final isCurrentConnected = currentConnectionStatus == ConnectionStatus.connected; + final hasDeviceAccess = + isCurrentConnected && _shifterService != null && _latestStatus != null; final canSelectFirmware = - isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy; - final canStartFirmware = isCurrentConnected && + hasDeviceAccess && !_isSelectingFirmware && !_isFirmwareUpdateBusy; + final canStartFirmware = hasDeviceAccess && !_isSelectingFirmware && !_isFirmwareUpdateBusy && _selectedFirmware != null; @@ -856,7 +885,7 @@ class _DeviceDetailsPageState extends ConsumerState { status: _latestStatus, ), const SizedBox(height: 20), - if (isCurrentConnected) ...[ + if (hasDeviceAccess) ...[ const SizedBox(height: 16), _StatusBanner( status: _latestStatus, @@ -974,6 +1003,13 @@ class _DeviceDetailsPageState extends ConsumerState { onStartUpdate: _startFirmwareUpdate, ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence), ), + ] else if (isCurrentConnected) ...[ + _PairingRequiredCard( + isChecking: _isPairingCheckRunning, + errorText: _pairingError, + onRetry: _isFirmwareUpdateBusy ? null : _retryPairing, + onOpenBluetoothSettings: _openPairingSettings, + ), ] else ...[ _DisconnectedDetailCard( isReconnecting: _isManualReconnectRunning, @@ -1539,6 +1575,120 @@ class _TrainerConnectionCard extends StatelessWidget { } } +class _PairingRequiredCard extends StatelessWidget { + const _PairingRequiredCard({ + required this.isChecking, + required this.errorText, + required this.onRetry, + required this.onOpenBluetoothSettings, + }); + + final bool isChecking; + final String? errorText; + final VoidCallback? onRetry; + final VoidCallback onOpenBluetoothSettings; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final trimmedError = errorText?.trim(); + + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.primary.withValues(alpha: 0.12), + ), + child: Icon( + Icons.bluetooth_searching_rounded, + color: colorScheme.primary, + size: 28, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isChecking ? 'Checking pairing...' : 'Pair this device', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + 'Device controls need Bluetooth pairing before they can be used. Follow any system pairing prompts, keep the button nearby, then retry.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.68), + ), + ), + ], + ), + ), + ], + ), + if (trimmedError != null && trimmedError.isNotEmpty) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.54), + borderRadius: BorderRadius.circular(18), + ), + child: Text( + 'Last attempt failed: $trimmedError', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: isChecking ? null : onRetry, + icon: isChecking + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh_rounded), + label: + Text(isChecking ? 'Checking Pairing...' : 'Retry Pairing'), + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: isChecking ? null : onOpenBluetoothSettings, + icon: const Icon(Icons.settings_bluetooth_rounded), + label: const Text('Open Bluetooth Settings'), + ), + ), + ], + ), + ), + ); + } +} + class _DisconnectedDetailCard extends StatelessWidget { const _DisconnectedDetailCard({ required this.isReconnecting,