From ddaed084dc1de2fe025866678103f656f7e5f54d Mon Sep 17 00:00:00 2001 From: Yandrik Date: Thu, 23 Apr 2026 22:33:20 +0200 Subject: [PATCH] feat(ui): overhaul device details layout --- lib/pages/device_details_page.dart | 616 ++++++++++++++++++++++------- 1 file changed, 473 insertions(+), 143 deletions(-) diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index 0852450..13a81df 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -713,9 +713,11 @@ class _DeviceDetailsPageState extends ConsumerState { @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 { }, 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 { 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 { }, ), 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 { ), ), ), + ] else ...[ + const _DisconnectedDetailCard(), ], ], ), @@ -827,8 +831,14 @@ class _DeviceDetailsPageState extends ConsumerState { 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, + ), + ), + ], + ), + ); + } +}