feat: pairing ui

This commit is contained in:
2026-04-28 20:22:15 +02:00
parent e3eba0bfc1
commit ac93c01cea

View File

@ -59,15 +59,16 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
bool _isExitingPage = false;
bool _hasRequestedDisconnect = false;
bool _hasShownPairingRecoveryDialog = false;
bool _isAssignTrainerDialogOpen = false;
bool _isManualReconnectRunning = false;
bool _isPairingCheckRunning = false;
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
_connectionStatusSubscription;
ShifterService? _shifterService;
StreamSubscription<CentralStatus>? _statusSubscription;
CentralStatus? _latestStatus;
String? _pairingError;
final List<_StatusHistoryEntry> _statusHistory = [];
bool _isGearRatiosLoading = false;
@ -208,6 +209,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
return;
}
if (_isPairingCheckRunning) {
return;
}
final asyncBluetooth = ref.read(bluetoothProvider);
final BluetoothController bluetooth;
if (asyncBluetooth.hasValue) {
@ -223,6 +227,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
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<DeviceDetailsPage> {
}
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<DeviceDetailsPage> {
service.startStatusNotifications();
setState(() {
_shifterService = service;
_isPairingCheckRunning = false;
_pairingError = null;
});
unawaited(_loadGearRatios());
unawaited(_loadDeviceTelemetry());
}
Future<void> _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<DeviceDetailsPage> {
await _disposeFirmwareUpdateService();
await _shifterService?.dispose();
_shifterService = null;
_isPairingCheckRunning = false;
_isDeviceTelemetryLoading = false;
_hasLoadedDeviceTelemetry = false;
}
@ -686,6 +694,25 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
}
Future<void> _retryPairing() async {
if (_isPairingCheckRunning || _isFirmwareUpdateBusy) {
return;
}
await _startStatusStreamingIfNeeded();
}
Future<void> _openPairingSettings() async {
final opened = await openBluetoothSettings();
if (!mounted || opened) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open Bluetooth settings.')),
);
}
Future<void> _exitPage() async {
if (_isFirmwareUpdateBusy) {
ScaffoldMessenger.of(context).showSnackBar(
@ -821,9 +848,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
: 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<DeviceDetailsPage> {
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<DeviceDetailsPage> {
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,