feat: pairing ui
This commit is contained in:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user