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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connectionData = ref.watch(connectionStatusProvider).valueOrNull; final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
final isCurrentConnected = connectionData != null && final currentConnectionStatus = connectionData != null &&
connectionData.$1 == ConnectionStatus.connected && connectionData.$2 == widget.deviceAddress
connectionData.$2 == widget.deviceAddress; ? connectionData.$1
: ConnectionStatus.disconnected;
final isCurrentConnected = currentConnectionStatus == ConnectionStatus.connected;
final canSelectFirmware = final canSelectFirmware =
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy; isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
final canStartFirmware = isCurrentConnected && final canStartFirmware = isCurrentConnected &&
@ -731,7 +733,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}, },
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Device Details'), title: const Text('Device'),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: _exitPage, onPressed: _exitPage,
@ -741,15 +743,25 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildDeviceInfo(context, ref, widget.deviceAddress), _buildDeviceOverviewCard(
const SizedBox(height: 16), context,
_buildConnectionStatus(context, ref, widget.deviceAddress), ref,
const SizedBox(height: 16), widget.deviceAddress,
connectionStatus: currentConnectionStatus,
status: _latestStatus,
),
const SizedBox(height: 20),
if (isCurrentConnected) ...[ if (isCurrentConnected) ...[
_TrainerConnectionCard(
status: _latestStatus,
onAssign: _isFirmwareUpdateBusy ? null : _connectButtonToBike,
onShowStatusConsole: _showStatusHistory,
),
const SizedBox(height: 16),
_StatusBanner( _StatusBanner(
status: _latestStatus, status: _latestStatus,
onTap: _showStatusHistory, onTap: _showStatusHistory,
@ -763,16 +775,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}, },
), ),
const SizedBox(height: 16), 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( _FirmwareUpdateCard(
selectedFirmware: _selectedFirmware, selectedFirmware: _selectedFirmware,
progress: _dfuProgress, 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), margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).cardColor, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.55),
),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -916,23 +926,30 @@ class _FirmwareUpdateCard extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Container( return Card(
decoration: BoxDecoration( child: Padding(
borderRadius: BorderRadius.circular(16), padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
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),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Row(
'Firmware Update', children: [
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), 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( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
@ -966,26 +983,42 @@ class _FirmwareUpdateCard extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 14),
Text( Container(
selectedFirmware == null padding: const EdgeInsets.all(14),
? 'Selected file: none' decoration: BoxDecoration(
: 'Selected file: ${selectedFirmware!.fileName}', color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
style: theme.textTheme.bodyMedium, borderRadius: BorderRadius.circular(18),
),
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,
), ),
], child: Column(
const SizedBox(height: 10), 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), Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
if (_showProgress) ...[ if (_showProgress) ...[
const SizedBox(height: 8), const SizedBox(height: 10),
LinearProgressIndicator( LinearProgressIndicator(
value: progress.totalBytes > 0 ? progress.fractionComplete : 0, value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
minHeight: 10,
borderRadius: BorderRadius.circular(999),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
@ -1011,11 +1044,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
color: progress.state == DfuUpdateState.failed color: progress.state == DfuUpdateState.failed
? colorScheme.error ? colorScheme.error
: theme.textTheme.bodySmall?.color, : theme.textTheme.bodySmall?.color,
), ),
), ),
], ],
], ],
), ),
),
); );
} }
} }
@ -1038,35 +1072,55 @@ class _StatusBanner extends StatelessWidget {
final text = status?.statusLine ?? 'Waiting for status updates...'; final text = status?.statusLine ?? 'Waiting for status updates...';
return Material( return Material(
color: color.withValues(alpha: 0.16), color: colorScheme.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(22),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(22),
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(16),
child: Row( child: Container(
children: [ decoration: BoxDecoration(
Icon(Icons.memory, color: color), borderRadius: BorderRadius.circular(18),
const SizedBox(width: 10), border: Border.all(color: color.withValues(alpha: 0.32)),
Expanded( color: color.withValues(alpha: 0.08),
child: Text( ),
text, padding: const EdgeInsets.all(14),
maxLines: 2, child: Row(
overflow: TextOverflow.ellipsis, 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 &&
if (onErrorInfoTap != null && status != null &&
status != null && (status!.trainer.state == TrainerConnectionState.error ||
(status!.trainer.state == TrainerConnectionState.error || status!.lastFailure != null))
status!.lastFailure != null)) IconButton(
IconButton( tooltip: 'Explain error',
tooltip: 'Explain error', onPressed: onErrorInfoTap,
onPressed: onErrorInfoTap, icon: Icon(Icons.info_outline, color: color),
icon: Icon(Icons.info_outline, color: color), ),
), Icon(Icons.chevron_right, color: color),
Icon(Icons.chevron_right, color: color), ],
], ),
), ),
), ),
), ),
@ -1093,10 +1147,14 @@ class _StatusBanner extends StatelessWidget {
} }
} }
Widget _buildDeviceInfo( Widget _buildDeviceOverviewCard(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
String deviceAddress, String deviceAddress,
{
required ConnectionStatus connectionStatus,
required CentralStatus? status,
}
) { ) {
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider); final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
@ -1112,79 +1170,351 @@ Widget _buildDeviceInfo(
} }
if (currentDeviceData == null) { if (currentDeviceData == null) {
return Center( return Card(
child: Text('Device details not found for $deviceAddress.'), child: Padding(
padding: const EdgeInsets.all(18),
child: Text('Device details not found for $deviceAddress.'),
),
); );
} }
return Column( // TODO(yannik): Replace these overview placeholder metrics with actual
crossAxisAlignment: CrossAxisAlignment.start, // battery, signal, and firmware values once the device exposes them.
children: [ return _DeviceOverviewCard(
Text( device: currentDeviceData,
'Name: ${currentDeviceData.deviceName}', connectionStatus: connectionStatus,
style: Theme.of(context).textTheme.titleLarge, status: status,
),
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,
),
],
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Card(
error: (error, stackTrace) => child: Padding(
Center(child: Text('Error loading device info: $error')), padding: EdgeInsets.all(18),
); child: Center(child: CircularProgressIndicator()),
} ),
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),
), ),
error: (error, stackTrace) => Text( error: (error, stackTrace) => Card(
'Status: Error ($error)', child: Padding(
style: const TextStyle(color: Colors.red), 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,
),
),
],
),
);
}
}