feat(ui): overhaul device details layout

This commit is contained in:
2026-04-23 22:33:20 +02:00
parent 87193c3ae9
commit ddaed084dc

View File

@ -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,
),
),
],
),
);
}
}