feat(ui): overhaul device details layout
This commit is contained in:
@ -713,9 +713,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
||||
final isCurrentConnected = connectionData != null &&
|
||||
connectionData.$1 == ConnectionStatus.connected &&
|
||||
connectionData.$2 == widget.deviceAddress;
|
||||
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 &&
|
||||
@ -731,7 +733,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Device Details'),
|
||||
title: const Text('Device'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _exitPage,
|
||||
@ -741,15 +743,25 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDeviceInfo(context, ref, widget.deviceAddress),
|
||||
const SizedBox(height: 16),
|
||||
_buildConnectionStatus(context, ref, widget.deviceAddress),
|
||||
const SizedBox(height: 16),
|
||||
_buildDeviceOverviewCard(
|
||||
context,
|
||||
ref,
|
||||
widget.deviceAddress,
|
||||
connectionStatus: currentConnectionStatus,
|
||||
status: _latestStatus,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (isCurrentConnected) ...[
|
||||
_TrainerConnectionCard(
|
||||
status: _latestStatus,
|
||||
onAssign: _isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
||||
onShowStatusConsole: _showStatusHistory,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_StatusBanner(
|
||||
status: _latestStatus,
|
||||
onTap: _showStatusHistory,
|
||||
@ -763,16 +775,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed:
|
||||
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
||||
icon: const Icon(Icons.link),
|
||||
label: const Text('Connect Button to Bike'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FirmwareUpdateCard(
|
||||
selectedFirmware: _selectedFirmware,
|
||||
progress: _dfuProgress,
|
||||
@ -814,6 +816,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const _DisconnectedDetailCard(),
|
||||
],
|
||||
],
|
||||
),
|
||||
@ -827,8 +831,14 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@ -916,23 +926,30 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Firmware Update',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
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: 10),
|
||||
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,
|
||||
@ -966,26 +983,42 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
selectedFirmware == null
|
||||
? 'Selected file: none'
|
||||
: 'Selected file: ${selectedFirmware!.fileName}',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
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),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
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: 8),
|
||||
const SizedBox(height: 10),
|
||||
LinearProgressIndicator(
|
||||
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
|
||||
minHeight: 10,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
@ -1011,11 +1044,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
color: progress.state == DfuUpdateState.failed
|
||||
? colorScheme.error
|
||||
: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1038,35 +1072,55 @@ class _StatusBanner extends StatelessWidget {
|
||||
final text = status?.statusLine ?? 'Waiting for status updates...';
|
||||
|
||||
return Material(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.memory, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
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),
|
||||
],
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1093,10 +1147,14 @@ class _StatusBanner extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDeviceInfo(
|
||||
Widget _buildDeviceOverviewCard(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String deviceAddress,
|
||||
{
|
||||
required ConnectionStatus connectionStatus,
|
||||
required CentralStatus? status,
|
||||
}
|
||||
) {
|
||||
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
|
||||
|
||||
@ -1112,79 +1170,351 @@ Widget _buildDeviceInfo(
|
||||
}
|
||||
|
||||
if (currentDeviceData == null) {
|
||||
return Center(
|
||||
child: Text('Device details not found for $deviceAddress.'),
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Text('Device details not found for $deviceAddress.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Name: ${currentDeviceData.deviceName}',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Address: ${currentDeviceData.deviceAddress}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Type: ${currentDeviceData.deviceType}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
// 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 Center(child: CircularProgressIndicator()),
|
||||
error: (error, stackTrace) =>
|
||||
Center(child: Text('Error loading device info: $error')),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectionStatus(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String deviceAddress,
|
||||
) {
|
||||
final asyncConnectionStatus = ref.watch(connectionStatusProvider);
|
||||
|
||||
return asyncConnectionStatus.when(
|
||||
data: (data) {
|
||||
final (status, connectedDeviceId) = data;
|
||||
String statusText;
|
||||
final isCurrentDeviceConnected =
|
||||
connectedDeviceId != null && connectedDeviceId == deviceAddress;
|
||||
|
||||
if (isCurrentDeviceConnected) {
|
||||
switch (status) {
|
||||
case ConnectionStatus.connected:
|
||||
statusText = 'Status: Connected';
|
||||
break;
|
||||
case ConnectionStatus.connecting:
|
||||
statusText = 'Status: Connecting...';
|
||||
break;
|
||||
case ConnectionStatus.disconnecting:
|
||||
statusText = 'Status: Disconnecting...';
|
||||
break;
|
||||
case ConnectionStatus.disconnected:
|
||||
statusText = 'Status: Disconnected';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
statusText = 'Status: Disconnected';
|
||||
}
|
||||
|
||||
return Text(statusText, style: Theme.of(context).textTheme.titleMedium);
|
||||
},
|
||||
loading: () => const Text(
|
||||
'Status: Unknown',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
loading: () => const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(18),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
error: (error, stackTrace) => Text(
|
||||
'Status: Error ($error)',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
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();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Text(
|
||||
'This device is not currently connected. Reopen it from Devices to reconnect and manage trainer pairing, firmware, and gear ratios.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user