From 43d2bd4f953116e89b7ffaf92d53102bb95743e8 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Mon, 2 Mar 2026 20:11:49 +0800 Subject: [PATCH] Fixed horizontal scrollbar, limit per page by 10 rows on desktop Fixed duplicate entries in activity logs --- lib/providers/tasks_provider.dart | 173 +++++++++++-- lib/screens/tasks/task_detail_screen.dart | 34 ++- lib/widgets/tasq_adaptive_list.dart | 302 +++++++++++++++++----- 3 files changed, 421 insertions(+), 88 deletions(-) diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 1eefc7f2..d15a5f77 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'dart:convert'; @@ -19,6 +18,82 @@ import 'stream_recovery.dart'; import 'realtime_controller.dart'; import '../utils/app_time.dart'; +String _stableJson(dynamic value) { + if (value is Map) { + final keys = value.keys.map((k) => k.toString()).toList()..sort(); + final entries = {}; + for (final key in keys) { + entries[key] = _stableJson(value[key]); + } + return jsonEncode(entries); + } + if (value is List) { + return jsonEncode(value.map(_stableJson).toList(growable: false)); + } + return jsonEncode(value); +} + +String _activityFingerprint(Map row) { + final taskId = row['task_id']?.toString() ?? ''; + final actorId = row['actor_id']?.toString() ?? ''; + final actionType = row['action_type']?.toString() ?? ''; + final meta = row['meta']; + return '$taskId|$actorId|$actionType|${_stableJson(meta)}'; +} + +/// Build a UI-display fingerprint for a log entry so that entries that would +/// render identically are collapsed to a single row. +/// +/// For auto-save fields (filled_*), the actual text value changes every millisecond +/// as the user types, creating hundreds of entries. By ignoring the text value and +/// grouping strictly by "Task + Actor + Action + Visual Minute", all auto-save +/// logs within the exact same minute collapse into a single display row. +String _uiFingerprint(TaskActivityLog log) { + const oncePerTask = {'created', 'started', 'completed'}; + if (oncePerTask.contains(log.actionType)) { + return '${log.taskId}|${log.actionType}'; + } + + // The UI displays time as "MMM dd, yyyy hh:mm AA". Grouping by year, month, + // day, hour, and minute ensures we collapse duplicates that look identical on screen. + final visualMinute = + '${log.createdAt.year}-${log.createdAt.month}-${log.createdAt.day}-${log.createdAt.hour}-${log.createdAt.minute}'; + + if (log.actionType.startsWith('filled_')) { + // DO NOT include the meta/value here! If the user types "A", "AB", "ABC" in the + // same minute, including the value would make them mathematically "unique" and + // bypass the deduplication. Grouping by time+action solves the auto-save spam. + return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$visualMinute'; + } + + // For other actions, we also include the normalized meta to be safe. + final metaStr = _normaliseMeta(log.meta); + return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$metaStr|$visualMinute'; +} + +List _dedupeActivityLogs(List logs) { + if (logs.length < 2) return logs; + // Sort newest first so we always keep the most recent copy of a duplicate. + final sorted = [...logs]..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final seen = {}; + final deduped = []; + for (final log in sorted) { + final fp = _uiFingerprint(log); + if (seen.add(fp)) { + deduped.add(log); + } + } + return deduped; +} + +/// Normalise a meta value for comparison: treat null and empty-map +/// identically, and sort map keys for stability. +String _normaliseMeta(dynamic meta) { + if (meta == null) return ''; + final s = _stableJson(meta); + return (s == 'null' || s == '{}') ? '' : s; +} + // Helper to insert activity log rows while sanitizing nulls and // avoiding exceptions from malformed payloads. Accepts either a Map // or a List. @@ -38,7 +113,18 @@ Future _insertActivityRows(dynamic client, dynamic rows) async { .whereType>() .toList(); if (sanitized.isEmpty) return; - await client.from('task_activity_logs').insert(sanitized); + + final seen = {}; + final dedupedBatch = >[]; + for (final row in sanitized) { + final fingerprint = _activityFingerprint(row); + if (seen.add(fingerprint)) { + dedupedBatch.add(row); + } + } + + if (dedupedBatch.isEmpty) return; + await client.from('task_activity_logs').insert(dedupedBatch); } else if (rows is Map) { final m = Map.from(rows); m.removeWhere((k, v) => v == null); @@ -487,7 +573,7 @@ final taskActivityLogsProvider = ); ref.onDispose(wrapper.dispose); - return wrapper.stream.map((result) => result.data); + return wrapper.stream.map((result) => _dedupeActivityLogs(result.data)); }); final taskAssignmentsControllerProvider = Provider(( @@ -952,31 +1038,74 @@ class TasksController { required String status, String? reason, }) async { + Map? taskRow; + try { + final row = await _client + .from('tasks') + .select('status, request_type, request_category, action_taken') + .eq('id', taskId) + .maybeSingle(); + if (row is! Map) { + throw Exception('Task not found'); + } + taskRow = row; + } catch (e) { + rethrow; + } + + final currentStatus = (taskRow['status'] as String?)?.trim() ?? ''; + if (currentStatus == 'closed' || + currentStatus == 'cancelled' || + currentStatus == 'completed') { + if (currentStatus != status) { + throw Exception( + 'Status cannot be changed after a task is $currentStatus.', + ); + } + return; + } + if (status == 'cancelled') { if (reason == null || reason.trim().isEmpty) { throw Exception('Cancellation requires a reason.'); } } - if (status == 'completed') { - // fetch current metadata to validate several required fields - try { - final row = await _client - .from('tasks') - // include all columns that must be non-null/empty before completing - .select( - // signatories are not needed for validation; action_taken is still - // required so we include it alongside the type/category fields. - 'request_type, request_category, action_taken', - ) - .eq('id', taskId) - .maybeSingle(); - if (row is! Map) { - throw Exception('Task not found'); - } - final rt = row['request_type']; - final rc = row['request_category']; - final action = row['action_taken']; + if (status == 'in_progress') { + final assignmentRows = await _client + .from('task_assignments') + .select('user_id') + .eq('task_id', taskId); + final assignedUserIds = (assignmentRows as List) + .map((row) => row['user_id']?.toString()) + .whereType() + .where((id) => id.isNotEmpty) + .toSet() + .toList(); + if (assignedUserIds.isEmpty) { + throw Exception( + 'Assign at least one IT Staff before starting this task.', + ); + } + + final itStaffRows = await _client + .from('profiles') + .select('id') + .inFilter('id', assignedUserIds) + .eq('role', 'it_staff'); + final hasItStaff = (itStaffRows as List).isNotEmpty; + if (!hasItStaff) { + throw Exception( + 'Assign at least one IT Staff before starting this task.', + ); + } + } + + if (status == 'completed') { + try { + final rt = taskRow['request_type']; + final rc = taskRow['request_category']; + final action = taskRow['action_taken']; final missing = []; if (rt == null || (rt is String && rt.trim().isEmpty)) { diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 006155dc..4e55b5ea 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -226,6 +226,16 @@ class _TaskDetailScreenState extends ConsumerState ); final showAssign = canAssign && task.status != 'completed'; final assignments = assignmentsAsync.valueOrNull ?? []; + final profileById = { + for (final profile in profilesAsync.valueOrNull ?? []) + profile.id: profile, + }; + final hasAssignedItStaff = assignments.any((assignment) { + if (assignment.taskId != task.id) { + return false; + } + return profileById[assignment.userId]?.role == 'it_staff'; + }); final canUpdateStatus = _canUpdateStatus( profileAsync.valueOrNull, assignments, @@ -396,7 +406,12 @@ class _TaskDetailScreenState extends ConsumerState runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ - _buildStatusChip(context, task, canUpdateStatus), + _buildStatusChip( + context, + task, + canUpdateStatus, + hasAssignedItStaff, + ), _MetaBadge(label: 'Office', value: officeName), _MetaBadge( label: 'Task #', @@ -3472,16 +3487,29 @@ class _TaskDetailScreenState extends ConsumerState BuildContext context, Task task, bool canUpdateStatus, + bool hasAssignedItStaff, ) { final chip = StatusPill( label: task.status.toUpperCase(), isEmphasized: task.status != 'queued', ); - if (!canUpdateStatus) { + final isTerminal = + task.status == 'completed' || + task.status == 'cancelled' || + task.status == 'closed'; + + if (!canUpdateStatus || isTerminal) { return chip; } + final statusOptions = _statusOptions.where((status) { + if (status == 'in_progress' && !hasAssignedItStaff) { + return false; + } + return true; + }).toList(); + return PopupMenuButton( onSelected: (value) async { // If cancelling, require a reason — show dialog with spinner. @@ -3592,7 +3620,7 @@ class _TaskDetailScreenState extends ConsumerState } } }, - itemBuilder: (context) => _statusOptions + itemBuilder: (context) => statusOptions .map( (status) => PopupMenuItem( value: status, diff --git a/lib/widgets/tasq_adaptive_list.dart b/lib/widgets/tasq_adaptive_list.dart index 7076eff5..b63bf227 100644 --- a/lib/widgets/tasq_adaptive_list.dart +++ b/lib/widgets/tasq_adaptive_list.dart @@ -330,16 +330,6 @@ class TasQAdaptiveList extends StatelessWidget { } Widget _buildDesktop(BuildContext context, BoxConstraints constraints) { - final dataSource = _TasQTableSource( - context: context, - items: items, - columns: columns, - rowActions: rowActions, - onRowTap: onRowTap, - offset: pageOffset, - totalCount: totalCount, - ); - // Use progressively smaller fractions of the viewport on larger screens // so that content doesn't stretch too widely, but also consume as much // width as possible when the display is more modest. @@ -352,70 +342,256 @@ class TasQAdaptiveList extends StatelessWidget { contentFactor = 0.75; // ultra-wide monitors } final contentWidth = constraints.maxWidth * contentFactor; - final tableWidth = math.max( - contentWidth, - (columns.length + (rowActions == null ? 0 : 1)) * 140.0, - ); - final effectiveRowsPerPage = math.min( - rowsPerPage, - math.max(1, totalCount ?? items.length), - ); - // wrap horizontal scroll with a visible scrollbar on desktop. the - // ScrollController is shared so the scrollbar has something to observe. - final horizontalController = ScrollController(); - final tableWidget = Scrollbar( - controller: horizontalController, - thumbVisibility: true, - trackVisibility: true, - child: SingleChildScrollView( - controller: horizontalController, - scrollDirection: Axis.horizontal, - child: SizedBox( - width: tableWidth, - child: PaginatedDataTable( - header: tableHeader, - rowsPerPage: effectiveRowsPerPage, - columnSpacing: 20, - horizontalMargin: 16, - showCheckboxColumn: false, - headingRowColor: WidgetStateProperty.resolveWith( - (states) => Theme.of(context).colorScheme.surfaceContainer, - ), - columns: [ - for (final column in columns) - DataColumn(label: Text(column.header)), - if (rowActions != null) const DataColumn(label: Text('Actions')), + // 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. + // • horizontalMargin=16 → 32 px total side padding + // • columnSpacing=20 between each adjacent pair + // • 200 px minimum per data column, 140 px for the actions column + const double colMinW = 200.0; + const double actMinW = 140.0; + const double hMarginTotal = 16.0 * 2; + const double colSpacing = 20.0; + final actionsColumnCount = rowActions == null ? 0 : 1; + final totalCols = columns.length + actionsColumnCount; + final minColumnsWidth = + hMarginTotal + + (columns.length * colMinW) + + (actionsColumnCount * actMinW) + + (math.max(0, totalCols - 1) * colSpacing); + final tableWidth = math.max(contentWidth, minColumnsWidth); + + final summaryWidget = summaryDashboard == null + ? null + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: contentWidth, child: summaryDashboard!), + const SizedBox(height: 12), ], - source: dataSource, - onPageChanged: onPageChanged, - ), - ), - ), + ); + final filterWidget = filterHeader == null + ? null + : Column( + mainAxisSize: MainAxisSize.min, + children: [filterHeader!, const SizedBox(height: 12)], + ); + + return _DesktopTableView( + items: items, + columns: columns, + rowActions: rowActions, + onRowTap: onRowTap, + maxRowsPerPage: rowsPerPage, + totalCount: totalCount, + pageOffset: pageOffset, + tableHeader: tableHeader, + onPageChanged: onPageChanged, + contentWidth: contentWidth, + tableWidth: tableWidth, + summaryWidget: summaryWidget, + filterWidget: filterWidget, + hasBoundedHeight: constraints.hasBoundedHeight, + ); + } +} + +/// Stateful desktop table view that properly handles: +/// - Auto-computed rows per page in bounded-height contexts (headers stay +/// visible because the table always fits without vertical overflow). +/// - Horizontal scrollbar only when table width exceeds viewport. +/// - Clean [ScrollController] lifecycle. +class _DesktopTableView extends StatefulWidget { + const _DesktopTableView({ + super.key, + required this.items, + required this.columns, + required this.rowActions, + required this.onRowTap, + required this.maxRowsPerPage, + required this.totalCount, + required this.pageOffset, + required this.tableHeader, + required this.onPageChanged, + required this.contentWidth, + required this.tableWidth, + required this.summaryWidget, + required this.filterWidget, + required this.hasBoundedHeight, + }); + + final List items; + final List> columns; + final TasQRowActions? rowActions; + final TasQRowTap? onRowTap; + final int maxRowsPerPage; + final int? totalCount; + final int pageOffset; + final Widget? tableHeader; + final void Function(int)? onPageChanged; + final double contentWidth; + final double tableWidth; + final Widget? summaryWidget; + final Widget? filterWidget; + final bool hasBoundedHeight; + + @override + State<_DesktopTableView> createState() => _DesktopTableViewState(); +} + +class _DesktopTableViewState extends State<_DesktopTableView> { + final ScrollController _horizontalController = ScrollController(); + + @override + void dispose() { + _horizontalController.dispose(); + super.dispose(); + } + + /// Compute rows per page AND the per-row height from the exact pixel budget + /// available for the table section, so the rendered table fills the space + /// without leaving empty space below the pagination footer. + /// + /// Returns a `(rowsPerPage, rowHeight)` record. [rowHeight] is ≥ 48 px + /// (Flutter's `dataRowMinHeight` default) so rows never collapse. + (int, double) _computeRowLayout(double availableHeight) { + // PaginatedDataTable internal chrome heights (from Flutter source): + // Heading row – 56 px + // Footer row – 56 px + // Optional card header – 64 px (only when tableHeader != null) + // Card border/shadow – ~4 px + const double colHeaderH = 56.0; + const double footerH = 56.0; + const double defaultRowH = 48.0; + const double cardPad = 4.0; + final double headerH = widget.tableHeader != null ? 64.0 : 0.0; + + final overhead = colHeaderH + footerH + headerH + cardPad; + final availableForRows = availableHeight - overhead; + if (availableForRows <= 0) return (1, defaultRowH); + + // How many rows fit at minimum height? + final maxFitting = math.max(1, (availableForRows / defaultRowH).floor()); + // Never show blank rows — cap to the number of items we actually have. + final itemCount = math.max(1, widget.items.length); + final rows = math.min( + maxFitting, + math.min(widget.maxRowsPerPage, itemCount), ); - final summarySection = summaryDashboard == null - ? null - : [ - SizedBox(width: contentWidth, child: summaryDashboard!), - const SizedBox(height: 12), - ]; - final filterSection = filterHeader == null - ? null - : [filterHeader!, const SizedBox(height: 12)]; + // 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); + return (rows, rowHeight); + } - return SingleChildScrollView( - primary: true, - child: Center( + Widget _buildTable( + BuildContext context, + int rowsPerPage, { + double? rowHeight, + }) { + final dataSource = _TasQTableSource( + context: context, + items: widget.items, + columns: widget.columns, + rowActions: widget.rowActions, + onRowTap: widget.onRowTap, + offset: widget.pageOffset, + totalCount: widget.totalCount, + ); + + final table = PaginatedDataTable( + header: widget.tableHeader, + rowsPerPage: rowsPerPage, + columnSpacing: 20, + horizontalMargin: 16, + showCheckboxColumn: false, + // When a rowHeight is provided (bounded layout), expand rows to fill + // the exact available height so there is no empty space after paging. + dataRowMinHeight: rowHeight ?? 48.0, + dataRowMaxHeight: rowHeight ?? 48.0, + headingRowColor: WidgetStateProperty.resolveWith( + (states) => Theme.of(context).colorScheme.surfaceContainer, + ), + columns: [ + for (final column in widget.columns) + DataColumn(label: Text(column.header)), + if (widget.rowActions != null) const DataColumn(label: Text('Actions')), + ], + source: dataSource, + onPageChanged: widget.onPageChanged, + ); + + // Only wrap in a horizontal scroll + scrollbar when columns genuinely + // exceed the available width. + if (widget.tableWidth > widget.contentWidth) { + return Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + trackVisibility: true, + child: SingleChildScrollView( + controller: _horizontalController, + primary: false, + scrollDirection: Axis.horizontal, + child: SizedBox(width: widget.tableWidth, child: table), + ), + ); + } + + return table; + } + + @override + Widget build(BuildContext context) { + if (widget.hasBoundedHeight) { + // Bounded: use Expanded around a LayoutBuilder so we know the *exact* + // pixel budget for the table, then auto-compute rowsPerPage so the + // table always fits vertically (→ headers never scroll away). + return Center( child: SizedBox( key: const Key('adaptive_list_content'), - width: contentWidth, + width: widget.contentWidth, child: Column( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, - children: [...?summarySection, ...?filterSection, tableWidget], + children: [ + if (widget.summaryWidget != null) widget.summaryWidget!, + if (widget.filterWidget != null) widget.filterWidget!, + Expanded( + child: LayoutBuilder( + builder: (context, tableConstraints) { + final (rows, rowH) = _computeRowLayout( + tableConstraints.maxHeight, + ); + return _buildTable(context, rows, rowHeight: rowH); + }, + ), + ), + ], ), ), + ); + } + + // Unbounded: use the requested rowsPerPage (capped to current page's + // items.length to avoid blank-row padding at the bottom). + final defaultRows = math.min( + widget.maxRowsPerPage, + math.max(1, widget.items.length), + ); + return Center( + child: SizedBox( + key: const Key('adaptive_list_content'), + width: widget.contentWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.summaryWidget != null) widget.summaryWidget!, + if (widget.filterWidget != null) widget.filterWidget!, + _buildTable(context, defaultRows), + ], + ), ), ); }