Fixed horizontal scrollbar, limit per page by 10 rows on desktop

Fixed duplicate entries in activity logs
This commit is contained in:
Marc Rejohn Castillano 2026-03-02 20:11:49 +08:00
parent b9722106ff
commit 43d2bd4f95
3 changed files with 421 additions and 88 deletions

View File

@ -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 = <String, dynamic>{};
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<String, dynamic> 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<TaskActivityLog> _dedupeActivityLogs(List<TaskActivityLog> 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 = <String>{};
final deduped = <TaskActivityLog>[];
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<Map>.
@ -38,7 +113,18 @@ Future<void> _insertActivityRows(dynamic client, dynamic rows) async {
.whereType<Map<String, dynamic>>()
.toList();
if (sanitized.isEmpty) return;
await client.from('task_activity_logs').insert(sanitized);
final seen = <String>{};
final dedupedBatch = <Map<String, dynamic>>[];
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<String, dynamic>.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<TaskAssignmentsController>((
@ -952,31 +1038,74 @@ class TasksController {
required String status,
String? reason,
}) async {
Map<String, dynamic>? taskRow;
try {
final row = await _client
.from('tasks')
.select('status, request_type, request_category, action_taken')
.eq('id', taskId)
.maybeSingle();
if (row is! Map<String, dynamic>) {
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<String, dynamic>) {
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<String>()
.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 = <String>[];
if (rt == null || (rt is String && rt.trim().isEmpty)) {

View File

@ -226,6 +226,16 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
);
final showAssign = canAssign && task.status != 'completed';
final assignments = assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
final profileById = {
for (final profile in profilesAsync.valueOrNull ?? <Profile>[])
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<TaskDetailScreen>
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<TaskDetailScreen>
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<String>(
onSelected: (value) async {
// If cancelling, require a reason show dialog with spinner.
@ -3592,7 +3620,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
}
}
},
itemBuilder: (context) => _statusOptions
itemBuilder: (context) => statusOptions
.map(
(status) => PopupMenuItem(
value: status,

View File

@ -330,16 +330,6 @@ class TasQAdaptiveList<T> extends StatelessWidget {
}
Widget _buildDesktop(BuildContext context, BoxConstraints constraints) {
final dataSource = _TasQTableSource<T>(
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<T> 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<T>(
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<T> 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<T> items;
final List<TasQColumn<T>> columns;
final TasQRowActions<T>? rowActions;
final TasQRowTap<T>? 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<T>> createState() => _DesktopTableViewState<T>();
}
class _DesktopTableViewState<T> extends State<_DesktopTableView<T>> {
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
: <Widget>[
SizedBox(width: contentWidth, child: summaryDashboard!),
const SizedBox(height: 12),
];
final filterSection = filterHeader == null
? null
: <Widget>[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<T>(
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),
],
),
),
);
}