import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../model/device.dart'; import '../model/device_type.dart'; import '../theme/app_colors.dart'; import '../utils/friendly_date_formatter.dart'; import '../widgets/status_badge.dart'; import 'device_actions.dart'; import 'device_list_sort.dart'; import '../theme/dimens.dart'; import '../utils/placeholders.dart'; import '../routes.dart'; // Column layout shared between the header and data rows so cells line up. const double _iconWidth = 40; const double _trailingWidth = 48; const Duration _onlineThreshold = Duration(minutes: 10); class _ColumnSpec { final DeviceSortColumn column; final int flex; const _ColumnSpec(this.column, this.flex); } const List<_ColumnSpec> _columnSpecs = [ _ColumnSpec(DeviceSortColumn.name, 3), _ColumnSpec(DeviceSortColumn.owner, 2), _ColumnSpec(DeviceSortColumn.macAddress, 3), _ColumnSpec(DeviceSortColumn.ipAddress, 2), _ColumnSpec(DeviceSortColumn.lastSeen, 3), _ColumnSpec(DeviceSortColumn.vendor, 2), ]; String _displayName(Device device) { if (device.name != null && device.name!.isNotEmpty) return device.name!; if (device.isRegistered && device.owner.isNotEmpty) { final type = device.deviceType == DeviceType.unknown ? 'Device' : device.deviceType.label; return "${device.owner}'s $type"; } return Placeholders.emptyValue; } class DeviceListHeaderDelegate extends SliverPersistentHeaderDelegate { final DeviceSortColumn sortColumn; final bool ascending; final void Function(DeviceSortColumn column) onTap; DeviceListHeaderDelegate({ required this.sortColumn, required this.ascending, required this.onTap, }); @override double get minExtent => 48; @override double get maxExtent => 48; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent, ) { return Material( color: Theme.of(context).colorScheme.surface, child: Column( children: [ Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( children: [ _IconHeaderCell( active: sortColumn, ascending: ascending, onTap: onTap, ), for (final spec in _columnSpecs) _HeaderCell( flex: spec.flex, column: spec.column, active: sortColumn, ascending: ascending, onTap: onTap, ), const SizedBox(width: _trailingWidth), ], ), ), ), const Divider(height: 1), ], ), ); } @override bool shouldRebuild(covariant DeviceListHeaderDelegate oldDelegate) { return oldDelegate.sortColumn != sortColumn || oldDelegate.ascending != ascending; } } class _HeaderCell extends StatelessWidget { final int flex; final DeviceSortColumn column; final DeviceSortColumn active; final bool ascending; final void Function(DeviceSortColumn column) onTap; const _HeaderCell({ required this.flex, required this.column, required this.active, required this.ascending, required this.onTap, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final isActive = column == active; final color = isActive ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant; return Expanded( flex: flex, child: InkWell( onTap: () => onTap(column), child: Padding( padding: const EdgeInsets.symmetric( horizontal: Insets.sm, vertical: Insets.md, ), child: Row( children: [ Flexible( child: Text( column.label, style: theme.textTheme.labelLarge?.copyWith(color: color), overflow: TextOverflow.ellipsis, ), ), if (isActive) ...[ const SizedBox(width: Insets.xs), Icon( ascending ? Icons.arrow_upward : Icons.arrow_downward, size: 16, color: color, ), ], ], ), ), ), ); } } // The leading device-type column shows only an icon per row, so its header is a // compact icon button that sorts by device type rather than a labelled cell. class _IconHeaderCell extends StatelessWidget { final DeviceSortColumn active; final bool ascending; final void Function(DeviceSortColumn column) onTap; const _IconHeaderCell({ required this.active, required this.ascending, required this.onTap, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final isActive = active == DeviceSortColumn.deviceType; final color = isActive ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant; return SizedBox( width: _iconWidth, child: InkWell( onTap: () => onTap(DeviceSortColumn.deviceType), child: Tooltip( message: 'Sort by device type', child: Padding( padding: const EdgeInsets.symmetric(vertical: Insets.md), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.devices, size: 18, color: color), if (isActive) Icon( ascending ? Icons.arrow_upward : Icons.arrow_downward, size: 14, color: color, ), ], ), ), ), ), ); } } class DeviceRowWide extends StatelessWidget { final Device device; final FriendlyDateFormatter formatter; final VoidCallback onRefresh; const DeviceRowWide({ super.key, required this.device, required this.formatter, required this.onRefresh, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Material( color: device.isRegistered ? null : theme.colorScheme.secondaryContainer, child: InkWell( onTap: () => context.push(Routes.deviceDetail(device.macAddress)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( children: [ SizedBox( width: _iconWidth, child: Tooltip( message: device.deviceType == DeviceType.unknown ? 'Device type unknown' : device.deviceType.label, child: Icon(device.deviceType.icon), ), ), for (final spec in _columnSpecs) Expanded( flex: spec.flex, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 12, ), child: _cellContent(theme, spec.column, device, formatter), ), ), SizedBox( width: _trailingWidth, child: _DeviceActionsMenu(device: device, onRefresh: onRefresh), ), ], ), ), ), ); } } Widget _cellContent( ThemeData theme, DeviceSortColumn column, Device device, FriendlyDateFormatter formatter, ) { switch (column) { case DeviceSortColumn.deviceType: // The device type is shown via the leading icon column, never a text cell. return const SizedBox.shrink(); case DeviceSortColumn.name: return _OverflowTooltipText( text: _displayName(device), style: theme.textTheme.bodyMedium, ); case DeviceSortColumn.owner: if (!device.isRegistered) { return const Align( alignment: Alignment.centerLeft, child: StatusBadge( label: 'Not registered', color: BadgeColor.secondary, ), ); } return _OverflowTooltipText( text: device.owner.isEmpty ? Placeholders.emptyValue : device.owner, style: theme.textTheme.bodyMedium, ); case DeviceSortColumn.macAddress: return _OverflowTooltipText( text: device.macAddress, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), ); case DeviceSortColumn.ipAddress: return _OverflowTooltipText( text: device.ipv4Address, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), ); case DeviceSortColumn.lastSeen: return Row( children: [ _StatusDot(lastSeen: device.lastSeen), const SizedBox(width: 6), Flexible( child: _OverflowTooltipText( text: formatter.format(device.lastSeen), style: theme.textTheme.bodyMedium, ), ), ], ); case DeviceSortColumn.vendor: return _OverflowTooltipText( text: device.vendor.isEmpty ? Placeholders.emptyValue : device.vendor, style: theme.textTheme.bodyMedium, ); } } class _OverflowTooltipText extends StatelessWidget { final String text; final TextStyle? style; const _OverflowTooltipText({required this.text, this.style}); @override Widget build(BuildContext context) { final effectiveStyle = DefaultTextStyle.of(context).style.merge(style); return LayoutBuilder( builder: (context, constraints) { final painter = TextPainter( text: TextSpan(text: text, style: effectiveStyle), maxLines: 1, textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), )..layout(maxWidth: constraints.maxWidth); final child = Text(text, overflow: TextOverflow.ellipsis, style: style); return painter.didExceedMaxLines ? Tooltip(message: text, child: child) : child; }, ); } } class DeviceRowCompact extends StatelessWidget { final Device device; final FriendlyDateFormatter formatter; final VoidCallback onRefresh; const DeviceRowCompact({ super.key, required this.device, required this.formatter, required this.onRefresh, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Card( color: device.isRegistered ? null : theme.colorScheme.secondaryContainer, child: ListTile( onTap: () => context.push(Routes.deviceDetail(device.macAddress)), leading: Tooltip( message: device.deviceType == DeviceType.unknown ? 'Device type unknown' : device.deviceType.label, child: Icon(device.deviceType.icon), ), title: Row( children: [ Flexible(child: _OverflowTooltipText(text: _displayName(device))), const SizedBox(width: Insets.sm), if (device.isRegistered) const StatusBadge(label: 'Registered', color: BadgeColor.success) else const StatusBadge( label: 'Not registered', color: BadgeColor.secondary, ), ], ), subtitle: DefaultTextStyle.merge( style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _OverflowTooltipText( text: '${device.isRegistered ? (device.owner.isEmpty ? Placeholders.emptyValue : device.owner) : device.ipv4Address} ยท ${device.macAddress}', ), if (!device.isRegistered && device.vendor.isNotEmpty) _OverflowTooltipText(text: device.vendor), Row( children: [ _StatusDot(lastSeen: device.lastSeen), const SizedBox(width: 6), Flexible( child: _OverflowTooltipText( text: 'Last seen: ${formatter.format(device.lastSeen)}', ), ), ], ), ], ), ), isThreeLine: true, trailing: _DeviceActionsMenu(device: device, onRefresh: onRefresh), ), ); } } class _StatusDot extends StatelessWidget { final DateTime lastSeen; const _StatusDot({required this.lastSeen}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final isOnline = DateTime.now().difference(lastSeen) < _onlineThreshold; final color = isOnline ? theme.extension()!.success : theme.colorScheme.outline; return Tooltip( message: isOnline ? 'Online' : 'Appears offline', child: Container( width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), ); } } class _DeviceActionsMenu extends StatelessWidget { final Device device; final VoidCallback onRefresh; const _DeviceActionsMenu({required this.device, required this.onRefresh}); @override Widget build(BuildContext context) { return PopupMenuButton( icon: const Icon(Icons.more_vert), onSelected: (value) async { if (value == 'details') { context.push(Routes.deviceDetail(device.macAddress)); } else if (value == 'edit') { await showEditDeviceDialog(context, device, onRefresh); } else if (value == 'forget') { await confirmForgetDevice(context, device, onRefresh); } else if (value == 'register') { await showRegisterDeviceDialog(context, device, onRefresh); } }, itemBuilder: (context) => [ const PopupMenuItem(value: 'details', child: Text('View details')), if (device.isRegistered) const PopupMenuItem(value: 'edit', child: Text('Edit')), if (device.isRegistered) const PopupMenuItem(value: 'forget', child: Text('Forget')), if (!device.isRegistered) const PopupMenuItem(value: 'register', child: Text('Register')), ], ); } }