diff --git a/.vscode/settings.json b/.vscode/settings.json index 9dfae4ee..f7d85f79 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "deno.enable": true, - "git.ignoreLimitWarning": true + "git.ignoreLimitWarning": true, + "cmake.ignoreCMakeListsMissing": true } \ No newline at end of file diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart index 8e42b773..3abaff49 100644 --- a/lib/providers/notifications_provider.dart +++ b/lib/providers/notifications_provider.dart @@ -317,6 +317,21 @@ class NotificationsController { } } + /// Mark all unread notifications for the current user as read. + Future markAllRead() async { + final userId = _client.auth.currentUser?.id; + if (userId == null) return; + try { + await _client + .from('notifications') + .update({'read_at': AppTime.nowUtc().toIso8601String()}) + .eq('user_id', userId) + .filter('read_at', 'is', null); + } catch (e) { + if (!isOfflineSaveError(e)) rethrow; + } + } + Future registerFcmToken(String token) async { final userId = _client.auth.currentUser?.id; if (userId == null) return; diff --git a/lib/screens/notifications/notifications_screen.dart b/lib/screens/notifications/notifications_screen.dart index ad436851..6b48c0fc 100644 --- a/lib/screens/notifications/notifications_screen.dart +++ b/lib/screens/notifications/notifications_screen.dart @@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:permission_handler/permission_handler.dart'; +import '../../models/notification_item.dart'; import '../../services/notification_service.dart'; - import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; @@ -37,8 +37,105 @@ class _NotificationsScreenState extends ConsumerState { Future _checkChannel() async { final muted = await NotificationService.isHighPriorityChannelMuted(); if (!mounted) return; - if (muted) { - setState(() => _showBanner = true); + if (muted) setState(() => _showBanner = true); + } + + String _relativeTime(DateTime dt) { + final diff = DateTime.now().difference(dt.toLocal()); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays == 1) return 'Yesterday'; + return '${diff.inDays}d ago'; + } + + String _dateGroup(DateTime dt) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final local = dt.toLocal(); + final d = DateTime(local.year, local.month, local.day); + if (d == today) return 'Today'; + if (d == yesterday) return 'Yesterday'; + return 'Earlier'; + } + + Color _iconContainerColor(BuildContext context, String type) { + final cs = Theme.of(context).colorScheme; + return switch (type) { + 'mention' || 'announcement' || 'announcement_comment' => + cs.secondaryContainer, + 'assignment' || 'created' || 'isr_assigned' => cs.primaryContainer, + _ => cs.tertiaryContainer, + }; + } + + Color _iconForegroundColor(BuildContext context, String type) { + final cs = Theme.of(context).colorScheme; + return switch (type) { + 'mention' || 'announcement' || 'announcement_comment' => + cs.onSecondaryContainer, + 'assignment' || 'created' || 'isr_assigned' => cs.onPrimaryContainer, + _ => cs.onTertiaryContainer, + }; + } + + String _notificationTitle(String type, String actorName) { + return switch (type) { + 'assignment' => '$actorName assigned you', + 'created' => '$actorName created a new item', + 'swap_request' => '$actorName requested a shift swap', + 'swap_update' => '$actorName updated a swap request', + 'announcement' => '$actorName posted an announcement', + 'announcement_comment' => '$actorName commented on an announcement', + 'it_job_reminder' => 'IT Job submission reminder', + 'isr_approved' => 'IT Service Request approved', + 'isr_status_changed' => 'IT Service Request status updated', + 'isr_assigned' => '$actorName assigned you an IT Service Request', + _ => '$actorName mentioned you', + }; + } + + IconData _notificationIcon(String type) { + return switch (type) { + 'assignment' => Icons.assignment_ind_outlined, + 'created' => Icons.campaign_outlined, + 'swap_request' => Icons.swap_horiz, + 'swap_update' => Icons.update, + 'announcement' => Icons.campaign, + 'announcement_comment' => Icons.comment_outlined, + 'it_job_reminder' => Icons.print, + 'isr_approved' => Icons.check_circle_outline, + 'isr_status_changed' => Icons.sync_alt_outlined, + 'isr_assigned' => Icons.engineering_outlined, + _ => Icons.alternate_email, + }; + } + + Future _handleTap(BuildContext context, NotificationItem item) async { + final ticketId = item.ticketId; + final taskId = item.taskId; + final isrId = item.itServiceRequestId; + + if (ticketId != null) { + await ref + .read(notificationsControllerProvider) + .markReadForTicket(ticketId); + } else if (taskId != null) { + await ref.read(notificationsControllerProvider).markReadForTask(taskId); + } else if (item.isUnread) { + await ref.read(notificationsControllerProvider).markRead(item.id); + } + + if (!context.mounted) return; + if (item.announcementId != null) { + context.go('/announcements'); + } else if (taskId != null) { + context.go('/tasks/$taskId'); + } else if (ticketId != null) { + context.go('/tickets/$ticketId'); + } else if (isrId != null) { + context.go('/it-service-requests/$isrId'); } } @@ -50,23 +147,35 @@ class _NotificationsScreenState extends ConsumerState { final tasksAsync = ref.watch(tasksProvider); final profileById = { - for (final profile in profilesAsync.valueOrNull ?? []) - profile.id: profile, + for (final p in profilesAsync.valueOrNull ?? []) p.id: p, }; final ticketById = { - for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket, + for (final t in ticketsAsync.valueOrNull ?? []) t.id: t, }; final taskById = { - for (final task in tasksAsync.valueOrNull ?? []) task.id: task, + for (final t in tasksAsync.valueOrNull ?? []) t.id: t, }; + final hasUnread = + notificationsAsync.valueOrNull?.any((n) => n.isUnread) ?? false; + return ResponsiveBody( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const AppPageHeader( + AppPageHeader( title: 'Notifications', subtitle: 'Updates and mentions across tasks and tickets', + actions: hasUnread + ? [ + IconButton( + icon: const Icon(Icons.done_all_outlined), + tooltip: 'Mark all as read', + onPressed: () => + ref.read(notificationsControllerProvider).markAllRead(), + ), + ] + : null, ), if (_showBanner && !_dismissed) Padding( @@ -98,94 +207,65 @@ class _NotificationsScreenState extends ConsumerState { "You'll see updates here when something needs your attention.", ); } - return ListView.separated( - padding: const EdgeInsets.only(bottom: 24), - itemCount: items.length, - separatorBuilder: (context, index) => - const SizedBox(height: 12), - itemBuilder: (context, index) { - final item = items[index]; - final actorName = item.actorId == null - ? 'System' - : (profileById[item.actorId]?.fullName ?? + + // Build a flat list interleaving section-header strings and items. + // Parallel animIndexes tracks the per-item animation delay index + // (-1 for headers which don't animate independently). + final entries = []; + final animIndexes = []; + int ni = 0; + String? lastGroup; + for (final item in items) { + final group = _dateGroup(item.createdAt); + if (group != lastGroup) { + entries.add(group); + animIndexes.add(-1); + lastGroup = group; + } + entries.add(item); + animIndexes.add(ni++); + } + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(notificationsProvider), + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 24), + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + if (entry is String) { + return _SectionHeader(label: entry); + } + final item = entry as NotificationItem; + final actorName = item.actorId == null + ? 'System' + : (profileById[item.actorId]?.fullName ?? item.actorId!); - final ticketSubject = item.ticketId == null - ? 'Ticket' - : (ticketById[item.ticketId]?.subject ?? - item.ticketId!); - final taskTitle = item.taskId == null - ? 'Task' - : (taskById[item.taskId]?.title ?? item.taskId!); - final subtitle = item.announcementId != null - ? 'Announcement' - : item.taskId != null - ? taskTitle - : ticketSubject; - - final title = _notificationTitle(item.type, actorName); - final icon = _notificationIcon(item.type); - - Future handleTap() async { - final ticketId = item.ticketId; - final taskId = item.taskId; - if (ticketId != null) { - await ref - .read(notificationsControllerProvider) - .markReadForTicket(ticketId); - } else if (taskId != null) { - await ref - .read(notificationsControllerProvider) - .markReadForTask(taskId); - } else if (item.isUnread) { - await ref - .read(notificationsControllerProvider) - .markRead(item.id); - } - if (!context.mounted) return; - if (item.announcementId != null) { - context.go('/announcements'); - } else if (taskId != null) { - context.go('/tasks/$taskId'); - } else if (ticketId != null) { - context.go('/tickets/$ticketId'); - } - } - - return M3FadeSlideIn( - delay: Duration( - milliseconds: index.clamp(0, 6) * 50, - ), - child: M3PressScale( - onTap: handleTap, - child: Card( - shape: AppSurfaces.of(context).compactShape, - child: ListTile( - leading: Icon(icon), - title: Text(title), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(subtitle), - const SizedBox(height: 4), - if (item.ticketId != null) - MonoText('Ticket ${item.ticketId}') - else if (item.taskId != null) - MonoText('Task ${item.taskId}'), - ], - ), - trailing: item.isUnread - ? Icon( - Icons.circle, - size: 10, - color: - Theme.of(context).colorScheme.error, - ) - : null, + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _NotificationCard( + item: item, + animDelay: Duration( + milliseconds: animIndexes[index].clamp(0, 6) * 50, ), + title: _notificationTitle(item.type, actorName), + icon: _notificationIcon(item.type), + iconContainerColor: + _iconContainerColor(context, item.type), + iconForegroundColor: + _iconForegroundColor(context, item.type), + taskTitle: item.taskId == null + ? null + : taskById[item.taskId]?.title, + ticketSubject: item.ticketId == null + ? null + : ticketById[item.ticketId]?.subject, + relativeTime: _relativeTime(item.createdAt), + onTap: () => _handleTap(context, item), ), - ), - ); - }, + ); + }, + ), ); }, loading: () => const Center(child: CircularProgressIndicator()), @@ -199,48 +279,160 @@ class _NotificationsScreenState extends ConsumerState { ), ); } +} - String _notificationTitle(String type, String actorName) { - switch (type) { - case 'assignment': - return '$actorName assigned you'; - case 'created': - return '$actorName created a new item'; - case 'swap_request': - return '$actorName requested a shift swap'; - case 'swap_update': - return '$actorName updated a swap request'; - case 'announcement': - return '$actorName posted an announcement'; - case 'announcement_comment': - return '$actorName commented on an announcement'; - case 'it_job_reminder': - return 'IT Job submission reminder'; - case 'mention': - default: - return '$actorName mentioned you'; - } - } +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.label}); - IconData _notificationIcon(String type) { - switch (type) { - case 'assignment': - return Icons.assignment_ind_outlined; - case 'created': - return Icons.campaign_outlined; - case 'swap_request': - return Icons.swap_horiz; - case 'swap_update': - return Icons.update; - case 'announcement': - return Icons.campaign; - case 'announcement_comment': - return Icons.comment_outlined; - case 'it_job_reminder': - return Icons.print; - case 'mention': - default: - return Icons.alternate_email; - } + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + } +} + +class _NotificationCard extends StatelessWidget { + const _NotificationCard({ + required this.item, + required this.animDelay, + required this.title, + required this.icon, + required this.iconContainerColor, + required this.iconForegroundColor, + required this.taskTitle, + required this.ticketSubject, + required this.relativeTime, + required this.onTap, + }); + + final NotificationItem item; + final Duration animDelay; + final String title; + final IconData icon; + final Color iconContainerColor; + final Color iconForegroundColor; + final String? taskTitle; + final String? ticketSubject; + final String relativeTime; + final VoidCallback onTap; + + String get _subtitle { + if (item.announcementId != null) return 'Announcement'; + if (item.taskId != null) return taskTitle ?? 'Task'; + if (item.itServiceRequestId != null) return 'IT Service Request'; + return ticketSubject ?? 'Ticket'; + } + + // Shows a short monospace reference only when no human-readable label exists. + String? get _refLabel { + if (item.itServiceRequestId != null) { + return 'ISR #${item.itServiceRequestId!.substring(0, 8)}'; + } + if (item.ticketId != null && ticketSubject == null) { + return 'Ticket #${item.ticketId!.substring(0, 8)}'; + } + if (item.taskId != null && taskTitle == null) { + return 'Task #${item.taskId!.substring(0, 8)}'; + } + return null; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + final cardColor = + item.isUnread ? cs.primaryContainer.withValues(alpha: 0.28) : null; + + return M3FadeSlideIn( + delay: animDelay, + child: M3PressScale( + onTap: onTap, + child: Card( + color: cardColor, + shape: AppSurfaces.of(context).compactShape, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Tonal icon container keyed by notification category + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconContainerColor, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 20, color: iconForegroundColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: tt.bodyMedium?.copyWith( + fontWeight: item.isUnread + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + const SizedBox(height: 2), + Text( + _subtitle, + style: tt.bodySmall + ?.copyWith(color: cs.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (_refLabel != null) ...[ + const SizedBox(height: 2), + MonoText(_refLabel!), + ], + ], + ), + ), + const SizedBox(width: 8), + // Timestamp + unread indicator + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + relativeTime, + style: + tt.labelSmall?.copyWith(color: cs.onSurfaceVariant), + ), + if (item.isUnread) ...[ + const SizedBox(height: 6), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), + ), + ], + ], + ), + ], + ), + ), + ), + ), + ); } } diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index bb7d87ac..b25652a7 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -514,6 +514,7 @@ class _TasksListScreenState extends ConsumerState ), TasQColumn( header: 'Status', + hideOnMedium: true, cellBuilder: (context, task) => Row( mainAxisSize: MainAxisSize.min, children: [ @@ -533,6 +534,7 @@ class _TasksListScreenState extends ConsumerState TasQColumn( header: 'Timestamp', technical: true, + hideOnMedium: true, cellBuilder: (context, task) => Text(_formatTimestamp(task.createdAt)), ), @@ -550,7 +552,9 @@ class _TasksListScreenState extends ConsumerState profileById, latestAssigneeByTaskId[task.id], ); - final subtitle = _buildSubtitle(officeName, task.status); + // Status is shown in the trailing badge; subtitle shows + // only the office name to avoid displaying it twice. + final subtitle = officeName; final hasMention = _hasTaskMention( notificationsAsync, task, @@ -588,9 +592,24 @@ class _TasksListScreenState extends ConsumerState const SizedBox(height: 2), Text('Assigned: $assigned'), const SizedBox(height: 4), - MonoText('ID ${task.taskNumber ?? task.id}'), - const SizedBox(height: 2), - Text(_formatTimestamp(task.createdAt)), + // Metadata: ID · timestamp in a single dimmed row + DefaultTextStyle.merge( + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + fontSize: 11, + ), + child: Row( + children: [ + MonoText( + task.taskNumber ?? task.id, + ), + const Text(' · '), + Text(_formatTimestamp(task.createdAt)), + ], + ), + ), ], ), trailing: Row( @@ -1194,10 +1213,7 @@ class _TasksListScreenState extends ConsumerState ); } - String _buildSubtitle(String officeName, String status) { - final statusLabel = status.toUpperCase(); - return '$officeName · $statusLabel'; - } + } List> _staffOptions(List? profiles) { diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart index d32acae3..207de466 100644 --- a/lib/screens/teams/teams_screen.dart +++ b/lib/screens/teams/teams_screen.dart @@ -195,26 +195,91 @@ class _TeamsScreenState extends ConsumerState { officeNamesList.sort( (a, b) => a.toLowerCase().compareTo(b.toLowerCase()), ); - final officeNames = officeNamesList.join(', '); final members = team.members(teamMembers); final memberNames = members .map((id) => profileById[id]?.fullName ?? id) .join(', '); - return ListTile( - title: Text(team.name), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Leader: ${leader?.fullName ?? team.leaderId}'), - Text('Offices: $officeNames'), - Text('Members: $memberNames'), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: actions, - ), + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + return M3PressScale( onTap: () => _showTeamDialog(context, team: team), + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 20, + backgroundColor: cs.primaryContainer, + child: Text( + team.name.isNotEmpty + ? team.name[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(team.name, style: tt.titleSmall), + const SizedBox(height: 2), + Text( + leader?.fullName ?? team.leaderId, + style: tt.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + if (officeNamesList.isNotEmpty) ...[ + const SizedBox(height: 6), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + for (final office in officeNamesList) + Chip( + label: Text( + office, + style: const TextStyle(fontSize: 11), + ), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ), + ], + ), + ], + if (memberNames.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + memberNames, + style: tt.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: actions, + ), + ], + ), + ), + ), ); }, rowActions: (team) => [ diff --git a/lib/services/notification_bridge.dart b/lib/services/notification_bridge.dart index 90ed2a53..a91b7d54 100644 --- a/lib/services/notification_bridge.dart +++ b/lib/services/notification_bridge.dart @@ -76,6 +76,21 @@ class _NotificationBridgeState extends ConsumerState } } + String _bannerLabel(String type) => switch (type) { + 'mention' => 'New mention', + 'assignment' => 'New assignment', + 'created' => 'New item created', + 'announcement' => 'New announcement', + 'announcement_comment' => 'New announcement comment', + 'swap_request' => 'New shift swap request', + 'swap_update' => 'Shift swap updated', + 'it_job_reminder' => 'IT Job reminder', + 'isr_approved' => 'IT Service Request approved', + 'isr_status_changed' => 'IT Service Request updated', + 'isr_assigned' => 'New IT Service Request assigned', + _ => 'New notification', + }; + void _showBanner(String type, NotificationItem item) { // Use a post-frame callback so that the ScaffoldMessenger from // MaterialApp is available in the element tree. @@ -84,7 +99,7 @@ class _NotificationBridgeState extends ConsumerState try { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('New $type received!'), + content: Text(_bannerLabel(type)), action: SnackBarAction( label: 'View', onPressed: () => diff --git a/lib/widgets/tasq_adaptive_list.dart b/lib/widgets/tasq_adaptive_list.dart index 2baf0465..c2f1351d 100644 --- a/lib/widgets/tasq_adaptive_list.dart +++ b/lib/widgets/tasq_adaptive_list.dart @@ -16,6 +16,7 @@ class TasQColumn { required this.header, required this.cellBuilder, this.technical = false, + this.hideOnMedium = false, }); /// The column header text. @@ -26,6 +27,11 @@ class TasQColumn { /// If true, applies monospace text style to the cell content. final bool technical; + + /// If true, this column is hidden on medium-width screens (< 1200px). + /// Use this for lower-priority columns (e.g. Timestamp, Status) that + /// would otherwise force a horizontal scrollbar on tablet viewports. + final bool hideOnMedium; } /// Builds a mobile tile for [TasQAdaptiveList]. @@ -351,6 +357,13 @@ class TasQAdaptiveList extends StatelessWidget { } final contentWidth = constraints.maxWidth * contentFactor; + // On medium screens (< 1200px) hide columns that are marked hideOnMedium, + // preventing the horizontal scrollbar from appearing on tablet viewports. + final isMediumScreen = constraints.maxWidth < 1200; + final visibleColumns = isMediumScreen + ? columns.where((c) => !c.hideOnMedium).toList() + : columns; + // Table width: mirrors PaginatedDataTable's own internal layout so the // horizontal scrollbar appears exactly when columns would otherwise be // squeezed below their minimum comfortable width. @@ -362,10 +375,10 @@ class TasQAdaptiveList extends StatelessWidget { const double hMarginTotal = 16.0 * 2; const double colSpacing = 20.0; final actionsColumnCount = rowActions == null ? 0 : 1; - final totalCols = columns.length + actionsColumnCount; + final totalCols = visibleColumns.length + actionsColumnCount; final minColumnsWidth = hMarginTotal + - (columns.length * colMinW) + + (visibleColumns.length * colMinW) + (actionsColumnCount * actMinW) + (math.max(0, totalCols - 1) * colSpacing); final tableWidth = math.max(contentWidth, minColumnsWidth); @@ -388,7 +401,7 @@ class TasQAdaptiveList extends StatelessWidget { return _DesktopTableView( items: items, - columns: columns, + columns: visibleColumns, rowActions: rowActions, onRowTap: onRowTap, maxRowsPerPage: rowsPerPage, @@ -472,6 +485,11 @@ class _DesktopTableViewState extends State<_DesktopTableView> { const double colHeaderH = 56.0; const double footerH = 56.0; const double defaultRowH = 48.0; + // Comfortable height used when the table is sparse (fewer items than the + // viewport can hold). Prevents 2 rows from each stretching to ~290px. + const double comfortableRowH = 52.0; + // Cap applied even when the table is full, so rows never become unwieldy. + const double maxRowH = 72.0; const double cardPad = 8.0; final double headerH = widget.tableHeader != null ? 64.0 : 0.0; @@ -488,9 +506,18 @@ class _DesktopTableViewState extends State<_DesktopTableView> { math.min(widget.maxRowsPerPage, itemCount), ); - // Expand each row to fill the leftover space so the table reaches - // exactly the bottom of the Expanded widget — no empty space. - final rowHeight = math.max(defaultRowH, availableForRows / rows); + // Only expand rows to fill leftover space when the table is "full" + // (items reach the viewport limit). Sparse tables use a comfortable fixed + // height instead — dividing 580px by 2 items would yield ~290px each. + // + // For full tables we must use exactFit (availableForRows / rows) so that + // rows × rowH == availableForRows exactly. Applying comfortableRowH as a + // lower-bound here caused overflow: e.g. 9 rows × 52px = 468px when only + // 444px was available → "BOTTOM OVERFLOWED BY 16 PIXELS". + final isTableFull = rows >= maxFitting; + final rowHeight = isTableFull + ? math.min(maxRowH, availableForRows / rows) + : comfortableRowH; return (rows, rowHeight); } diff --git a/test/layout_smoke_test.dart b/test/layout_smoke_test.dart index deea2a30..29c1bd15 100644 --- a/test/layout_smoke_test.dart +++ b/test/layout_smoke_test.dart @@ -73,6 +73,9 @@ class FakeNotificationsController implements NotificationsController { @override Future markReadForTask(String taskId) async {} + @override + Future markAllRead() async {} + @override Future registerFcmToken(String token) async {}