1648 lines
51 KiB
Dart
1648 lines
51 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
|
import 'package:abawo_bt_app/model/shifter_types.dart';
|
|
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
|
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
|
import 'package:abawo_bt_app/service/shifter_service.dart';
|
|
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
|
|
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
|
|
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
|
show DiscoveredDevice;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:nb_utils/nb_utils.dart';
|
|
|
|
import '../controller/bluetooth.dart';
|
|
import '../database/database.dart';
|
|
|
|
class DeviceDetailsPage extends ConsumerStatefulWidget {
|
|
const DeviceDetailsPage({
|
|
required this.deviceAddress,
|
|
super.key,
|
|
});
|
|
|
|
final String deviceAddress;
|
|
|
|
@override
|
|
ConsumerState<DeviceDetailsPage> createState() => _DeviceDetailsPageState();
|
|
}
|
|
|
|
class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|
static const List<double> _keAntRatios = [
|
|
0.35,
|
|
0.40,
|
|
0.47,
|
|
0.54,
|
|
0.61,
|
|
0.69,
|
|
0.82,
|
|
0.95,
|
|
1.13,
|
|
1.29,
|
|
1.50,
|
|
1.71,
|
|
1.89,
|
|
2.12,
|
|
2.40,
|
|
2.77,
|
|
3.27,
|
|
];
|
|
|
|
bool _isExitingPage = false;
|
|
bool _hasRequestedDisconnect = false;
|
|
bool _hasShownPairingRecoveryDialog = false;
|
|
bool _isAssignTrainerDialogOpen = false;
|
|
bool _isManualReconnectRunning = false;
|
|
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
|
|
_connectionStatusSubscription;
|
|
|
|
ShifterService? _shifterService;
|
|
StreamSubscription<CentralStatus>? _statusSubscription;
|
|
CentralStatus? _latestStatus;
|
|
final List<_StatusHistoryEntry> _statusHistory = [];
|
|
|
|
bool _isGearRatiosLoading = false;
|
|
bool _hasLoadedGearRatios = false;
|
|
String? _gearRatiosError;
|
|
List<double> _gearRatios = const [];
|
|
int _defaultGearIndex = 0;
|
|
|
|
late final FirmwareFileSelectionService _firmwareFileSelectionService;
|
|
FirmwareUpdateService? _firmwareUpdateService;
|
|
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
|
|
DfuV1PreparedFirmware? _selectedFirmware;
|
|
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
|
|
state: DfuUpdateState.idle,
|
|
totalBytes: 0,
|
|
sentBytes: 0,
|
|
lastAckedSequence: 0xFF,
|
|
sessionId: 0,
|
|
flags: DfuUpdateFlags(),
|
|
);
|
|
bool _isSelectingFirmware = false;
|
|
bool _isStartingFirmwareUpdate = false;
|
|
String? _firmwareUserMessage;
|
|
|
|
bool get _isFirmwareUpdateBusy {
|
|
if (_isStartingFirmwareUpdate) {
|
|
return true;
|
|
}
|
|
switch (_dfuProgress.state) {
|
|
case DfuUpdateState.starting:
|
|
case DfuUpdateState.waitingForAck:
|
|
case DfuUpdateState.transferring:
|
|
case DfuUpdateState.finishing:
|
|
return true;
|
|
case DfuUpdateState.idle:
|
|
case DfuUpdateState.completed:
|
|
case DfuUpdateState.aborted:
|
|
case DfuUpdateState.failed:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_firmwareFileSelectionService = FirmwareFileSelectionService(
|
|
filePicker: LocalFirmwareFilePicker(),
|
|
);
|
|
_connectionStatusSubscription =
|
|
ref.listenManual<AsyncValue<(ConnectionStatus, String?)>>(
|
|
connectionStatusProvider,
|
|
(_, next) {
|
|
final data = next.valueOrNull;
|
|
if (data == null) {
|
|
return;
|
|
}
|
|
_onConnectionStatusChanged(data);
|
|
},
|
|
fireImmediately: true,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
unawaited(_disconnectOnClose());
|
|
_connectionStatusSubscription?.close();
|
|
_statusSubscription?.cancel();
|
|
_shifterService?.dispose();
|
|
_firmwareProgressSubscription?.cancel();
|
|
unawaited(_firmwareUpdateService?.dispose() ?? Future<void>.value());
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _disconnectOnClose() async {
|
|
if (_isFirmwareUpdateBusy) {
|
|
return;
|
|
}
|
|
|
|
if (_hasRequestedDisconnect) {
|
|
return;
|
|
}
|
|
|
|
_hasRequestedDisconnect = true;
|
|
_isExitingPage = true;
|
|
|
|
await _disposeFirmwareUpdateService();
|
|
|
|
final bluetooth = ref.read(bluetoothProvider).value;
|
|
await bluetooth?.disconnect();
|
|
await _stopStatusStreaming();
|
|
}
|
|
|
|
void _onConnectionStatusChanged((ConnectionStatus, String?) data) {
|
|
if (!mounted || _isExitingPage) {
|
|
return;
|
|
}
|
|
|
|
final (status, connectedDeviceId) = data;
|
|
final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
|
|
|
|
if (isCurrentDevice && status == ConnectionStatus.connected) {
|
|
_startStatusStreamingIfNeeded();
|
|
return;
|
|
}
|
|
|
|
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
|
|
!_isFirmwareUpdateBusy) {
|
|
_stopStatusStreaming();
|
|
}
|
|
}
|
|
|
|
Future<void> _startStatusStreamingIfNeeded() async {
|
|
bool isCurrentDeviceConnected(BluetoothController bluetooth) {
|
|
final connectionState = bluetooth.currentConnectionState;
|
|
return connectionState.$1 == ConnectionStatus.connected &&
|
|
connectionState.$2 == widget.deviceAddress;
|
|
}
|
|
|
|
if (_shifterService != null) {
|
|
final bluetooth = ref.read(bluetoothProvider).value;
|
|
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
|
|
return;
|
|
}
|
|
_statusSubscription ??= _shifterService!.statusStream.listen((status) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
_recordStatus(status);
|
|
});
|
|
_shifterService!.startStatusNotifications();
|
|
if (!_hasLoadedGearRatios && !_isGearRatiosLoading) {
|
|
unawaited(_loadGearRatios());
|
|
}
|
|
return;
|
|
}
|
|
final asyncBluetooth = ref.read(bluetoothProvider);
|
|
final BluetoothController bluetooth;
|
|
if (asyncBluetooth.hasValue) {
|
|
bluetooth = asyncBluetooth.requireValue;
|
|
} else {
|
|
bluetooth = await ref.read(bluetoothProvider.future);
|
|
}
|
|
if (!isCurrentDeviceConnected(bluetooth)) {
|
|
return;
|
|
}
|
|
final service = ShifterService(
|
|
bluetooth: bluetooth,
|
|
buttonDeviceId: widget.deviceAddress,
|
|
);
|
|
|
|
final initialStatusResult = await service.readStatus();
|
|
if (!mounted) {
|
|
await service.dispose();
|
|
return;
|
|
}
|
|
|
|
if (initialStatusResult.isErr()) {
|
|
await service.dispose();
|
|
await _showPairingRecoveryDialog();
|
|
return;
|
|
}
|
|
|
|
_recordStatus(initialStatusResult.unwrap());
|
|
|
|
_statusSubscription = service.statusStream.listen((status) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
_recordStatus(status);
|
|
});
|
|
|
|
service.startStatusNotifications();
|
|
setState(() {
|
|
_shifterService = service;
|
|
});
|
|
unawaited(_loadGearRatios());
|
|
}
|
|
|
|
Future<void> _showPairingRecoveryDialog() async {
|
|
if (!mounted || _hasShownPairingRecoveryDialog) {
|
|
return;
|
|
}
|
|
|
|
_hasShownPairingRecoveryDialog = true;
|
|
await showBluetoothPairingRecoveryDialog(context);
|
|
}
|
|
|
|
void _recordStatus(CentralStatus status) {
|
|
setState(() {
|
|
_latestStatus = status;
|
|
_statusHistory.insert(
|
|
0,
|
|
_StatusHistoryEntry(
|
|
timestamp: DateTime.now(),
|
|
status: status,
|
|
),
|
|
);
|
|
if (_statusHistory.length > 100) {
|
|
_statusHistory.removeRange(100, _statusHistory.length);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _stopStatusStreaming() async {
|
|
await _statusSubscription?.cancel();
|
|
_statusSubscription = null;
|
|
|
|
if (_isFirmwareUpdateBusy) {
|
|
return;
|
|
}
|
|
|
|
await _disposeFirmwareUpdateService();
|
|
await _shifterService?.dispose();
|
|
_shifterService = null;
|
|
}
|
|
|
|
Future<void> _disposeFirmwareUpdateService() async {
|
|
await _firmwareProgressSubscription?.cancel();
|
|
_firmwareProgressSubscription = null;
|
|
await _firmwareUpdateService?.dispose();
|
|
_firmwareUpdateService = null;
|
|
}
|
|
|
|
Future<void> _loadGearRatios() async {
|
|
final shifter = _shifterService;
|
|
if (shifter == null || _isGearRatiosLoading || _isFirmwareUpdateBusy) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isGearRatiosLoading = true;
|
|
_gearRatiosError = null;
|
|
});
|
|
|
|
final result = await shifter.readGearRatios();
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (result.isErr()) {
|
|
setState(() {
|
|
_gearRatiosError = 'Failed to read gear ratios: ${result.unwrapErr()}';
|
|
_isGearRatiosLoading = false;
|
|
_hasLoadedGearRatios = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
final data = result.unwrap();
|
|
_gearRatios = data.ratios;
|
|
_defaultGearIndex = data.defaultGearIndex;
|
|
_isGearRatiosLoading = false;
|
|
_hasLoadedGearRatios = true;
|
|
_gearRatiosError = null;
|
|
});
|
|
}
|
|
|
|
Future<String?> _saveGearRatios(
|
|
List<double> ratios, int defaultGearIndex) async {
|
|
if (_isFirmwareUpdateBusy) {
|
|
return 'Gear ratio changes are disabled during firmware update.';
|
|
}
|
|
|
|
final shifter = _shifterService;
|
|
if (shifter == null) {
|
|
return 'Status channel is not ready yet.';
|
|
}
|
|
|
|
final result = await shifter.writeGearRatios(
|
|
GearRatiosData(
|
|
ratios: ratios,
|
|
defaultGearIndex: defaultGearIndex,
|
|
),
|
|
);
|
|
if (result.isErr()) {
|
|
return 'Could not save gear ratios: ${result.unwrapErr()}';
|
|
}
|
|
|
|
if (!mounted) {
|
|
return null;
|
|
}
|
|
|
|
setState(() {
|
|
_gearRatios = List<double>.from(ratios);
|
|
_defaultGearIndex = ratios.isEmpty
|
|
? 0
|
|
: defaultGearIndex.clamp(0, ratios.length - 1).toInt();
|
|
_hasLoadedGearRatios = true;
|
|
_gearRatiosError = null;
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<void> _connectButtonToBike() async {
|
|
if (_isAssignTrainerDialogOpen) {
|
|
return;
|
|
}
|
|
|
|
if (_isFirmwareUpdateBusy) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Connect to bike is disabled during firmware updates.'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
_isAssignTrainerDialogOpen = true;
|
|
final DiscoveredDevice? selectedBike;
|
|
try {
|
|
selectedBike = await BikeScanDialog.show(
|
|
context,
|
|
excludedDeviceId: widget.deviceAddress,
|
|
);
|
|
} finally {
|
|
_isAssignTrainerDialogOpen = false;
|
|
}
|
|
if (selectedBike == null || !mounted) {
|
|
return;
|
|
}
|
|
|
|
await _startStatusStreamingIfNeeded();
|
|
final shifter = _shifterService;
|
|
if (shifter == null) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Status channel is not ready yet.')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final result = await shifter.connectButtonToBike(selectedBike.id);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (result.isErr()) {
|
|
final err = result.unwrapErr();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Connect request failed: $err')),
|
|
);
|
|
toast('Connect request failed.');
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Sent connect request for ${selectedBike.id}.')),
|
|
);
|
|
}
|
|
|
|
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
|
|
final shifter = _shifterService;
|
|
if (shifter == null) {
|
|
return null;
|
|
}
|
|
if (_firmwareUpdateService != null) {
|
|
return _firmwareUpdateService;
|
|
}
|
|
|
|
final asyncBluetooth = ref.read(bluetoothProvider);
|
|
final bluetooth = asyncBluetooth.valueOrNull;
|
|
if (bluetooth == null) {
|
|
return null;
|
|
}
|
|
|
|
final service = FirmwareUpdateService(
|
|
transport: ShifterFirmwareUpdateTransport(
|
|
shifterService: shifter,
|
|
bluetoothController: bluetooth,
|
|
buttonDeviceId: widget.deviceAddress,
|
|
),
|
|
);
|
|
|
|
_firmwareProgressSubscription = service.progressStream.listen((progress) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_dfuProgress = progress;
|
|
if (progress.state == DfuUpdateState.failed &&
|
|
progress.errorMessage != null) {
|
|
_firmwareUserMessage = progress.errorMessage;
|
|
}
|
|
if (progress.state == DfuUpdateState.completed) {
|
|
_firmwareUserMessage =
|
|
'Firmware update completed. The button rebooted and reconnected.';
|
|
}
|
|
if (progress.state == DfuUpdateState.aborted) {
|
|
_firmwareUserMessage = 'Firmware update canceled.';
|
|
}
|
|
});
|
|
});
|
|
|
|
_firmwareUpdateService = service;
|
|
return service;
|
|
}
|
|
|
|
Future<void> _selectFirmwareFile() async {
|
|
if (_isFirmwareUpdateBusy || _isSelectingFirmware) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isSelectingFirmware = true;
|
|
_firmwareUserMessage = null;
|
|
});
|
|
|
|
final result = await _firmwareFileSelectionService.selectAndPrepareDfuV1();
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isSelectingFirmware = false;
|
|
if (result.isSuccess) {
|
|
_selectedFirmware = result.firmware;
|
|
_firmwareUserMessage =
|
|
'Selected ${result.firmware!.fileName}. Ready to start update.';
|
|
} else if (!result.isCanceled) {
|
|
_firmwareUserMessage = result.failure?.message;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _startFirmwareUpdate() async {
|
|
if (_isFirmwareUpdateBusy || _isSelectingFirmware) {
|
|
return;
|
|
}
|
|
|
|
final firmware = _selectedFirmware;
|
|
if (firmware == null) {
|
|
setState(() {
|
|
_firmwareUserMessage =
|
|
'Select a firmware .bin file before starting the update.';
|
|
});
|
|
return;
|
|
}
|
|
|
|
await _startStatusStreamingIfNeeded();
|
|
final updater = await _ensureFirmwareUpdateService();
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
if (updater == null) {
|
|
setState(() {
|
|
_firmwareUserMessage =
|
|
'Firmware updater is not ready. Ensure the button is connected.';
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isStartingFirmwareUpdate = true;
|
|
_firmwareUserMessage =
|
|
'Starting update. Keep this screen open and stay near the button.';
|
|
});
|
|
|
|
final result = await updater.startUpdate(
|
|
imageBytes: firmware.fileBytes,
|
|
sessionId: firmware.metadata.sessionId,
|
|
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
|
|
);
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isStartingFirmwareUpdate = false;
|
|
if (result.isErr()) {
|
|
_firmwareUserMessage = result.unwrapErr().toString();
|
|
}
|
|
});
|
|
}
|
|
|
|
String _dfuPhaseText(DfuUpdateState state) {
|
|
switch (state) {
|
|
case DfuUpdateState.idle:
|
|
return 'Idle';
|
|
case DfuUpdateState.starting:
|
|
return 'Sending START command';
|
|
case DfuUpdateState.waitingForAck:
|
|
return 'Waiting for ACK from button';
|
|
case DfuUpdateState.transferring:
|
|
return 'Transferring firmware frames';
|
|
case DfuUpdateState.finishing:
|
|
return 'Finalizing update and waiting for reboot/reconnect';
|
|
case DfuUpdateState.completed:
|
|
return 'Update completed';
|
|
case DfuUpdateState.aborted:
|
|
return 'Update canceled';
|
|
case DfuUpdateState.failed:
|
|
return 'Update failed';
|
|
}
|
|
}
|
|
|
|
String _formatBytes(int bytes) {
|
|
if (bytes < 1024) {
|
|
return '$bytes B';
|
|
}
|
|
if (bytes < 1024 * 1024) {
|
|
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
}
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
|
}
|
|
|
|
String _hexByte(int value) {
|
|
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
|
|
}
|
|
|
|
Future<void> _manualReconnect() async {
|
|
if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isManualReconnectRunning = true;
|
|
});
|
|
|
|
try {
|
|
final bluetooth = await ref.read(bluetoothProvider.future);
|
|
final result = await bluetooth.connectById(
|
|
widget.deviceAddress,
|
|
timeout: const Duration(seconds: 10),
|
|
);
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
if (result.isErr()) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'Reconnect failed. Is the device turned on and in range?',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Reconnect failed: $error')),
|
|
);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isManualReconnectRunning = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _exitPage() async {
|
|
if (_isFirmwareUpdateBusy) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Firmware update is running. Keep this screen open until it completes.'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await _disconnectOnClose();
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
context.go('/devices');
|
|
}
|
|
|
|
void _showStatusHistory() {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
builder: (context) {
|
|
return SizedBox(
|
|
height: MediaQuery.of(context).size.height * 0.72,
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
const Expanded(
|
|
child: Text(
|
|
'Status Console',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: _statusHistory.isEmpty
|
|
? const Center(child: Text('No status updates yet.'))
|
|
: ListView.builder(
|
|
itemCount: _statusHistory.length,
|
|
itemBuilder: (context, index) {
|
|
final item = _statusHistory[index];
|
|
final errorCode = _effectiveErrorCode(item.status);
|
|
return ListTile(
|
|
dense: true,
|
|
visualDensity: VisualDensity.compact,
|
|
title: Text(
|
|
item.status.statusLine,
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
subtitle: Text(
|
|
_formatTimestamp(item.timestamp),
|
|
style: const TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
trailing: errorCode == null
|
|
? null
|
|
: IconButton(
|
|
tooltip: 'Explain error',
|
|
onPressed: () {
|
|
_showErrorInfoDialog(errorCode);
|
|
},
|
|
icon: const Icon(Icons.info_outline),
|
|
),
|
|
onTap: errorCode == null
|
|
? null
|
|
: () {
|
|
_showErrorInfoDialog(errorCode);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
String _formatTimestamp(DateTime time) {
|
|
final h = time.hour.toString().padLeft(2, '0');
|
|
final m = time.minute.toString().padLeft(2, '0');
|
|
final s = time.second.toString().padLeft(2, '0');
|
|
return '$h:$m:$s';
|
|
}
|
|
|
|
int? _effectiveErrorCode(CentralStatus status) {
|
|
if (status.trainer.state == TrainerConnectionState.error) {
|
|
return status.trainer.errorCode ?? status.lastFailure;
|
|
}
|
|
return status.lastFailure;
|
|
}
|
|
|
|
void _showErrorInfoDialog(int errorCode) {
|
|
final info = shifterErrorInfo(errorCode);
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
icon: const Icon(Icons.info_outline),
|
|
title: Text('Error ${info.code}: ${info.title}'),
|
|
content: Text(info.details),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
|
final currentConnectionStatus =
|
|
connectionData != null && connectionData.$2 == widget.deviceAddress
|
|
? connectionData.$1
|
|
: ConnectionStatus.disconnected;
|
|
final isCurrentConnected =
|
|
currentConnectionStatus == ConnectionStatus.connected;
|
|
final canSelectFirmware =
|
|
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
|
final canStartFirmware = isCurrentConnected &&
|
|
!_isSelectingFirmware &&
|
|
!_isFirmwareUpdateBusy &&
|
|
_selectedFirmware != null;
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (bool didPop, bool? result) {
|
|
_exitPage();
|
|
},
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Device'),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: _exitPage,
|
|
),
|
|
),
|
|
body: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildDeviceOverviewCard(
|
|
context,
|
|
ref,
|
|
widget.deviceAddress,
|
|
connectionStatus: currentConnectionStatus,
|
|
status: _latestStatus,
|
|
),
|
|
const SizedBox(height: 20),
|
|
if (isCurrentConnected) ...[
|
|
const SizedBox(height: 16),
|
|
_StatusBanner(
|
|
status: _latestStatus,
|
|
onTap: _showStatusHistory,
|
|
onErrorInfoTap: _latestStatus == null
|
|
? null
|
|
: () {
|
|
final code = _effectiveErrorCode(_latestStatus!);
|
|
if (code != null) {
|
|
_showErrorInfoDialog(code);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
_TrainerConnectionCard(
|
|
status: _latestStatus,
|
|
onAssign:
|
|
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
|
onShowStatusConsole: _showStatusHistory,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Opacity(
|
|
opacity: _isFirmwareUpdateBusy ? 0.6 : 1,
|
|
child: AbsorbPointer(
|
|
absorbing: _isFirmwareUpdateBusy,
|
|
child: GearRatioEditorCard(
|
|
ratios: _gearRatios,
|
|
defaultGearIndex: _defaultGearIndex,
|
|
isLoading: _isGearRatiosLoading,
|
|
errorText: _gearRatiosError,
|
|
onRetry:
|
|
_isFirmwareUpdateBusy ? null : _loadGearRatios,
|
|
onSave: _saveGearRatios,
|
|
presets: const [
|
|
GearRatioPreset(
|
|
name: 'Road',
|
|
description:
|
|
'Balanced 12-speed road gearing for steady cadence steps.',
|
|
ratios: [
|
|
0.50,
|
|
0.58,
|
|
0.67,
|
|
0.76,
|
|
0.86,
|
|
0.97,
|
|
1.09,
|
|
1.22,
|
|
1.36,
|
|
1.51,
|
|
1.67,
|
|
1.84,
|
|
],
|
|
),
|
|
GearRatioPreset(
|
|
name: 'Gravel',
|
|
description:
|
|
'Slightly lower gearing with smooth jumps for mixed terrain rides.',
|
|
ratios: [
|
|
0.46,
|
|
0.54,
|
|
0.62,
|
|
0.70,
|
|
0.79,
|
|
0.89,
|
|
1.00,
|
|
1.12,
|
|
1.25,
|
|
1.40,
|
|
1.57,
|
|
1.76,
|
|
],
|
|
),
|
|
GearRatioPreset(
|
|
name: 'MTB',
|
|
description:
|
|
'Lower climbing gears with wider top-end spacing for steep trails.',
|
|
ratios: [
|
|
0.42,
|
|
0.49,
|
|
0.57,
|
|
0.66,
|
|
0.76,
|
|
0.87,
|
|
1.00,
|
|
1.15,
|
|
1.32,
|
|
1.52,
|
|
1.75,
|
|
2.02,
|
|
],
|
|
),
|
|
GearRatioPreset(
|
|
name: 'KeAnt Classic',
|
|
description:
|
|
'17-step baseline from KeAnt cross app gearing.',
|
|
ratios: _keAntRatios,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_FirmwareUpdateCard(
|
|
selectedFirmware: _selectedFirmware,
|
|
progress: _dfuProgress,
|
|
isSelecting: _isSelectingFirmware,
|
|
isStarting: _isStartingFirmwareUpdate,
|
|
canSelect: canSelectFirmware,
|
|
canStart: canStartFirmware,
|
|
phaseText: _dfuPhaseText(_dfuProgress.state),
|
|
statusText: _firmwareUserMessage,
|
|
formattedProgressBytes:
|
|
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
|
onSelectFirmware: _selectFirmwareFile,
|
|
onStartUpdate: _startFirmwareUpdate,
|
|
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
|
),
|
|
] else ...[
|
|
_DisconnectedDetailCard(
|
|
isReconnecting: _isManualReconnectRunning,
|
|
onReconnect: _manualReconnect,
|
|
onBackToDevices: _exitPage,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusHistoryEntry {
|
|
const _StatusHistoryEntry({
|
|
required this.timestamp,
|
|
required this.status,
|
|
});
|
|
|
|
final DateTime timestamp;
|
|
final CentralStatus status;
|
|
}
|
|
|
|
class _FirmwareUpdateCard extends StatelessWidget {
|
|
const _FirmwareUpdateCard({
|
|
required this.selectedFirmware,
|
|
required this.progress,
|
|
required this.isSelecting,
|
|
required this.isStarting,
|
|
required this.canSelect,
|
|
required this.canStart,
|
|
required this.phaseText,
|
|
required this.statusText,
|
|
required this.formattedProgressBytes,
|
|
required this.ackSequenceHex,
|
|
required this.onSelectFirmware,
|
|
required this.onStartUpdate,
|
|
});
|
|
|
|
final DfuV1PreparedFirmware? selectedFirmware;
|
|
final DfuUpdateProgress progress;
|
|
final bool isSelecting;
|
|
final bool isStarting;
|
|
final bool canSelect;
|
|
final bool canStart;
|
|
final String phaseText;
|
|
final String? statusText;
|
|
final String formattedProgressBytes;
|
|
final String ackSequenceHex;
|
|
final Future<void> Function() onSelectFirmware;
|
|
final Future<void> Function() onStartUpdate;
|
|
|
|
bool get _showProgress {
|
|
return progress.totalBytes > 0 ||
|
|
progress.sentBytes > 0 ||
|
|
progress.state != DfuUpdateState.idle;
|
|
}
|
|
|
|
bool get _showRebootExpectation {
|
|
return progress.state == DfuUpdateState.finishing ||
|
|
progress.state == DfuUpdateState.completed;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.system_update_alt_rounded,
|
|
color: colorScheme.primary),
|
|
const SizedBox(width: 10),
|
|
const Text(
|
|
'Firmware Update',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Select a firmware image, review the transfer state, and start the update when ready.',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: canSelect ? onSelectFirmware : null,
|
|
icon: isSelecting
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.upload_file),
|
|
label: const Text('Select Firmware'),
|
|
),
|
|
FilledButton.icon(
|
|
onPressed: canStart ? onStartUpdate : null,
|
|
icon: isStarting
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.system_update_alt),
|
|
label: const Text('Start Update'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
|
borderRadius: BorderRadius.circular(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: 4),
|
|
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),
|
|
Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
|
|
if (_showProgress) ...[
|
|
const SizedBox(height: 10),
|
|
LinearProgressIndicator(
|
|
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
|
|
minHeight: 10,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
],
|
|
if (_showRebootExpectation) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
if (statusText != null && statusText!.trim().isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
statusText!,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: progress.state == DfuUpdateState.failed
|
|
? colorScheme.error
|
|
: theme.textTheme.bodySmall?.color,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusBanner extends StatelessWidget {
|
|
const _StatusBanner({
|
|
required this.status,
|
|
required this.onTap,
|
|
this.onErrorInfoTap,
|
|
});
|
|
|
|
final CentralStatus? status;
|
|
final VoidCallback onTap;
|
|
final VoidCallback? onErrorInfoTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final color = _resolveColor(colorScheme);
|
|
final text = status?.statusLine ?? 'Waiting for status updates...';
|
|
|
|
return Material(
|
|
color: colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(22),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(22),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(18),
|
|
border: Border.all(color: color.withValues(alpha: 0.32)),
|
|
color: color.withValues(alpha: 0.08),
|
|
),
|
|
padding: const EdgeInsets.all(14),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.memory_rounded, color: color),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Live Status',
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
text,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (onErrorInfoTap != null &&
|
|
status != null &&
|
|
(status!.trainer.state == TrainerConnectionState.error ||
|
|
status!.lastFailure != null))
|
|
IconButton(
|
|
tooltip: 'Explain error',
|
|
onPressed: onErrorInfoTap,
|
|
icon: Icon(Icons.info_outline, color: color),
|
|
),
|
|
Icon(Icons.chevron_right, color: color),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _resolveColor(ColorScheme scheme) {
|
|
final current = status;
|
|
if (current == null) {
|
|
return scheme.primary;
|
|
}
|
|
if (current.trainer.state == TrainerConnectionState.error) {
|
|
return scheme.error;
|
|
}
|
|
if (current.trainer.state == TrainerConnectionState.ftmsReady) {
|
|
return Colors.green;
|
|
}
|
|
if (current.trainer.state == TrainerConnectionState.connecting ||
|
|
current.trainer.state == TrainerConnectionState.pairing ||
|
|
current.trainer.state == TrainerConnectionState.discoveringFtms) {
|
|
return Colors.orange;
|
|
}
|
|
return scheme.primary;
|
|
}
|
|
}
|
|
|
|
Widget _buildDeviceOverviewCard(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
String deviceAddress, {
|
|
required ConnectionStatus connectionStatus,
|
|
required CentralStatus? status,
|
|
}) {
|
|
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
|
|
|
|
return asyncSavedDevices.when(
|
|
data: (devices) {
|
|
ConnectedDevice? currentDeviceData;
|
|
try {
|
|
currentDeviceData = devices.firstWhere(
|
|
(d) => d.deviceAddress == deviceAddress,
|
|
);
|
|
} catch (_) {
|
|
currentDeviceData = null;
|
|
}
|
|
|
|
if (currentDeviceData == null) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Text('Device details not found for $deviceAddress.'),
|
|
),
|
|
);
|
|
}
|
|
|
|
// TODO(yannik): Replace these overview placeholder metrics with actual
|
|
// battery, signal, and firmware values once the device exposes them.
|
|
return _DeviceOverviewCard(
|
|
device: currentDeviceData,
|
|
connectionStatus: connectionStatus,
|
|
status: status,
|
|
);
|
|
},
|
|
loading: () => const Card(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(18),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
),
|
|
error: (error, stackTrace) => Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Text('Error loading device info: $error'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _DeviceOverviewCard extends StatelessWidget {
|
|
const _DeviceOverviewCard({
|
|
required this.device,
|
|
required this.connectionStatus,
|
|
required this.status,
|
|
});
|
|
|
|
final ConnectedDevice device;
|
|
final ConnectionStatus connectionStatus;
|
|
final CentralStatus? status;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final trainerAddress = status?.connectedTrainerAddr == null
|
|
? 'No trainer assigned yet'
|
|
: formatMacAddressFromLittleEndian(status!.connectedTrainerAddr!);
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: Image.asset(
|
|
'assets/images/shifter-wireframe.png',
|
|
width: 104,
|
|
height: 78,
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
device.deviceName,
|
|
style:
|
|
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_DetailStatusChip(status: connectionStatus),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
trainerAddress,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color:
|
|
colorScheme.onSurface.withValues(alpha: 0.68),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
Row(
|
|
children: const [
|
|
Expanded(
|
|
child: _OverviewMetricTile(
|
|
label: 'Battery',
|
|
value: '--',
|
|
icon: Icons.battery_charging_full_rounded,
|
|
),
|
|
),
|
|
SizedBox(width: 10),
|
|
Expanded(
|
|
child: _OverviewMetricTile(
|
|
label: 'Signal',
|
|
value: 'Ready',
|
|
icon: Icons.signal_cellular_alt_rounded,
|
|
),
|
|
),
|
|
SizedBox(width: 10),
|
|
Expanded(
|
|
child: _OverviewMetricTile(
|
|
label: 'Firmware',
|
|
value: '--',
|
|
icon: Icons.memory_rounded,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
device.deviceAddress,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurface.withValues(alpha: 0.62),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TrainerConnectionCard extends StatelessWidget {
|
|
const _TrainerConnectionCard({
|
|
required this.status,
|
|
required this.onAssign,
|
|
required this.onShowStatusConsole,
|
|
});
|
|
|
|
final CentralStatus? status;
|
|
final VoidCallback? onAssign;
|
|
final VoidCallback onShowStatusConsole;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final trainerText = status?.connectedTrainerAddr == null
|
|
? 'No trainer assigned yet.'
|
|
: 'Assigned: ${formatMacAddressFromLittleEndian(status!.connectedTrainerAddr!)}';
|
|
final readinessText = status?.trainer.label ?? 'Waiting for trainer status';
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 46,
|
|
height: 46,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: colorScheme.primary.withValues(alpha: 0.12),
|
|
),
|
|
child: Icon(Icons.pedal_bike_rounded,
|
|
color: colorScheme.primary),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Trainer Assignment',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
trainerText,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color:
|
|
colorScheme.onSurface.withValues(alpha: 0.68),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.bluetooth_connected_rounded,
|
|
color: colorScheme.primary),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
readinessText,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton.icon(
|
|
onPressed: onAssign,
|
|
icon: const Icon(Icons.link_rounded),
|
|
label: const Text('Assign Trainer'),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: onShowStatusConsole,
|
|
icon: const Icon(Icons.subject_rounded),
|
|
label: const Text('Status Console'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DisconnectedDetailCard extends StatelessWidget {
|
|
const _DisconnectedDetailCard({
|
|
required this.isReconnecting,
|
|
required this.onReconnect,
|
|
required this.onBackToDevices,
|
|
});
|
|
|
|
final bool isReconnecting;
|
|
final VoidCallback onReconnect;
|
|
final VoidCallback onBackToDevices;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 46,
|
|
height: 46,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: colorScheme.error.withValues(alpha: 0.12),
|
|
),
|
|
child: Icon(
|
|
Icons.bluetooth_disabled_rounded,
|
|
color: colorScheme.error,
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Text(
|
|
'No connection',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
'This device is not currently connected. Turn it on and keep it nearby, then reconnect when you are ready.',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton.icon(
|
|
onPressed: isReconnecting ? null : onReconnect,
|
|
icon: isReconnecting
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.bluetooth_connected_rounded),
|
|
label:
|
|
Text(isReconnecting ? 'Reconnecting...' : 'Reconnect'),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: isReconnecting ? null : onBackToDevices,
|
|
icon: const Icon(Icons.arrow_back_rounded),
|
|
label: const Text('Devices'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DetailStatusChip extends StatelessWidget {
|
|
const _DetailStatusChip({required this.status});
|
|
|
|
final ConnectionStatus status;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (label, color) = switch (status) {
|
|
ConnectionStatus.connected => ('Connected', const Color(0xFF40C979)),
|
|
ConnectionStatus.connecting => ('Connecting', const Color(0xFFFFB649)),
|
|
ConnectionStatus.disconnecting => (
|
|
'Disconnecting',
|
|
const Color(0xFFFFB649)
|
|
),
|
|
ConnectionStatus.disconnected => (
|
|
'Disconnected',
|
|
Theme.of(context).colorScheme.primary
|
|
),
|
|
};
|
|
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OverviewMetricTile extends StatelessWidget {
|
|
const _OverviewMetricTile({
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|