feat: pairing ui
This commit is contained in:
@ -59,15 +59,16 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
|
|
||||||
bool _isExitingPage = false;
|
bool _isExitingPage = false;
|
||||||
bool _hasRequestedDisconnect = false;
|
bool _hasRequestedDisconnect = false;
|
||||||
bool _hasShownPairingRecoveryDialog = false;
|
|
||||||
bool _isAssignTrainerDialogOpen = false;
|
bool _isAssignTrainerDialogOpen = false;
|
||||||
bool _isManualReconnectRunning = false;
|
bool _isManualReconnectRunning = false;
|
||||||
|
bool _isPairingCheckRunning = false;
|
||||||
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
|
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
|
||||||
_connectionStatusSubscription;
|
_connectionStatusSubscription;
|
||||||
|
|
||||||
ShifterService? _shifterService;
|
ShifterService? _shifterService;
|
||||||
StreamSubscription<CentralStatus>? _statusSubscription;
|
StreamSubscription<CentralStatus>? _statusSubscription;
|
||||||
CentralStatus? _latestStatus;
|
CentralStatus? _latestStatus;
|
||||||
|
String? _pairingError;
|
||||||
final List<_StatusHistoryEntry> _statusHistory = [];
|
final List<_StatusHistoryEntry> _statusHistory = [];
|
||||||
|
|
||||||
bool _isGearRatiosLoading = false;
|
bool _isGearRatiosLoading = false;
|
||||||
@ -208,6 +209,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_isPairingCheckRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final asyncBluetooth = ref.read(bluetoothProvider);
|
final asyncBluetooth = ref.read(bluetoothProvider);
|
||||||
final BluetoothController bluetooth;
|
final BluetoothController bluetooth;
|
||||||
if (asyncBluetooth.hasValue) {
|
if (asyncBluetooth.hasValue) {
|
||||||
@ -223,6 +227,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
buttonDeviceId: widget.deviceAddress,
|
buttonDeviceId: widget.deviceAddress,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isPairingCheckRunning = true;
|
||||||
|
_pairingError = null;
|
||||||
|
});
|
||||||
|
|
||||||
var initialStatusResult = await service.readStatus();
|
var initialStatusResult = await service.readStatus();
|
||||||
for (final delay in _initialStatusRetryDelays) {
|
for (final delay in _initialStatusRetryDelays) {
|
||||||
if (initialStatusResult.isOk() || !mounted) {
|
if (initialStatusResult.isOk() || !mounted) {
|
||||||
@ -247,8 +256,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (initialStatusResult.isErr()) {
|
if (initialStatusResult.isErr()) {
|
||||||
|
final error = initialStatusResult.unwrapErr();
|
||||||
await service.dispose();
|
await service.dispose();
|
||||||
await _showPairingRecoveryDialog();
|
setState(() {
|
||||||
|
_isPairingCheckRunning = false;
|
||||||
|
_pairingError = error.toString();
|
||||||
|
_latestStatus = null;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -264,20 +278,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
service.startStatusNotifications();
|
service.startStatusNotifications();
|
||||||
setState(() {
|
setState(() {
|
||||||
_shifterService = service;
|
_shifterService = service;
|
||||||
|
_isPairingCheckRunning = false;
|
||||||
|
_pairingError = null;
|
||||||
});
|
});
|
||||||
unawaited(_loadGearRatios());
|
unawaited(_loadGearRatios());
|
||||||
unawaited(_loadDeviceTelemetry());
|
unawaited(_loadDeviceTelemetry());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showPairingRecoveryDialog() async {
|
|
||||||
if (!mounted || _hasShownPairingRecoveryDialog) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_hasShownPairingRecoveryDialog = true;
|
|
||||||
await showBluetoothPairingRecoveryDialog(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _recordStatus(CentralStatus status) {
|
void _recordStatus(CentralStatus status) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_latestStatus = status;
|
_latestStatus = status;
|
||||||
@ -305,6 +312,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
await _disposeFirmwareUpdateService();
|
await _disposeFirmwareUpdateService();
|
||||||
await _shifterService?.dispose();
|
await _shifterService?.dispose();
|
||||||
_shifterService = null;
|
_shifterService = null;
|
||||||
|
_isPairingCheckRunning = false;
|
||||||
_isDeviceTelemetryLoading = false;
|
_isDeviceTelemetryLoading = false;
|
||||||
_hasLoadedDeviceTelemetry = 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 {
|
Future<void> _exitPage() async {
|
||||||
if (_isFirmwareUpdateBusy) {
|
if (_isFirmwareUpdateBusy) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@ -821,9 +848,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
: ConnectionStatus.disconnected;
|
: ConnectionStatus.disconnected;
|
||||||
final isCurrentConnected =
|
final isCurrentConnected =
|
||||||
currentConnectionStatus == ConnectionStatus.connected;
|
currentConnectionStatus == ConnectionStatus.connected;
|
||||||
|
final hasDeviceAccess =
|
||||||
|
isCurrentConnected && _shifterService != null && _latestStatus != null;
|
||||||
final canSelectFirmware =
|
final canSelectFirmware =
|
||||||
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
hasDeviceAccess && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||||
final canStartFirmware = isCurrentConnected &&
|
final canStartFirmware = hasDeviceAccess &&
|
||||||
!_isSelectingFirmware &&
|
!_isSelectingFirmware &&
|
||||||
!_isFirmwareUpdateBusy &&
|
!_isFirmwareUpdateBusy &&
|
||||||
_selectedFirmware != null;
|
_selectedFirmware != null;
|
||||||
@ -856,7 +885,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
status: _latestStatus,
|
status: _latestStatus,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (isCurrentConnected) ...[
|
if (hasDeviceAccess) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_StatusBanner(
|
_StatusBanner(
|
||||||
status: _latestStatus,
|
status: _latestStatus,
|
||||||
@ -974,6 +1003,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
onStartUpdate: _startFirmwareUpdate,
|
onStartUpdate: _startFirmwareUpdate,
|
||||||
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
||||||
),
|
),
|
||||||
|
] else if (isCurrentConnected) ...[
|
||||||
|
_PairingRequiredCard(
|
||||||
|
isChecking: _isPairingCheckRunning,
|
||||||
|
errorText: _pairingError,
|
||||||
|
onRetry: _isFirmwareUpdateBusy ? null : _retryPairing,
|
||||||
|
onOpenBluetoothSettings: _openPairingSettings,
|
||||||
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
_DisconnectedDetailCard(
|
_DisconnectedDetailCard(
|
||||||
isReconnecting: _isManualReconnectRunning,
|
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 {
|
class _DisconnectedDetailCard extends StatelessWidget {
|
||||||
const _DisconnectedDetailCard({
|
const _DisconnectedDetailCard({
|
||||||
required this.isReconnecting,
|
required this.isReconnecting,
|
||||||
|
|||||||
Reference in New Issue
Block a user