Fixed horizontal scrollbar, limit per page by 10 rows on desktop
Fixed duplicate entries in activity logs
This commit is contained in:
parent
b9722106ff
commit
43d2bd4f95
|
|
@ -1,6 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
|
@ -19,6 +18,82 @@ import 'stream_recovery.dart';
|
||||||
import 'realtime_controller.dart';
|
import 'realtime_controller.dart';
|
||||||
import '../utils/app_time.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
|
// Helper to insert activity log rows while sanitizing nulls and
|
||||||
// avoiding exceptions from malformed payloads. Accepts either a Map
|
// avoiding exceptions from malformed payloads. Accepts either a Map
|
||||||
// or a List<Map>.
|
// or a List<Map>.
|
||||||
|
|
@ -38,7 +113,18 @@ Future<void> _insertActivityRows(dynamic client, dynamic rows) async {
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
.toList();
|
.toList();
|
||||||
if (sanitized.isEmpty) return;
|
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) {
|
} else if (rows is Map) {
|
||||||
final m = Map<String, dynamic>.from(rows);
|
final m = Map<String, dynamic>.from(rows);
|
||||||
m.removeWhere((k, v) => v == null);
|
m.removeWhere((k, v) => v == null);
|
||||||
|
|
@ -487,7 +573,7 @@ final taskActivityLogsProvider =
|
||||||
);
|
);
|
||||||
|
|
||||||
ref.onDispose(wrapper.dispose);
|
ref.onDispose(wrapper.dispose);
|
||||||
return wrapper.stream.map((result) => result.data);
|
return wrapper.stream.map((result) => _dedupeActivityLogs(result.data));
|
||||||
});
|
});
|
||||||
|
|
||||||
final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
|
final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
|
||||||
|
|
@ -952,31 +1038,74 @@ class TasksController {
|
||||||
required String status,
|
required String status,
|
||||||
String? reason,
|
String? reason,
|
||||||
}) async {
|
}) 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 (status == 'cancelled') {
|
||||||
if (reason == null || reason.trim().isEmpty) {
|
if (reason == null || reason.trim().isEmpty) {
|
||||||
throw Exception('Cancellation requires a reason.');
|
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'];
|
if (status == 'in_progress') {
|
||||||
final rc = row['request_category'];
|
final assignmentRows = await _client
|
||||||
final action = row['action_taken'];
|
.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>[];
|
final missing = <String>[];
|
||||||
if (rt == null || (rt is String && rt.trim().isEmpty)) {
|
if (rt == null || (rt is String && rt.trim().isEmpty)) {
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,16 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
);
|
);
|
||||||
final showAssign = canAssign && task.status != 'completed';
|
final showAssign = canAssign && task.status != 'completed';
|
||||||
final assignments = assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
|
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(
|
final canUpdateStatus = _canUpdateStatus(
|
||||||
profileAsync.valueOrNull,
|
profileAsync.valueOrNull,
|
||||||
assignments,
|
assignments,
|
||||||
|
|
@ -396,7 +406,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildStatusChip(context, task, canUpdateStatus),
|
_buildStatusChip(
|
||||||
|
context,
|
||||||
|
task,
|
||||||
|
canUpdateStatus,
|
||||||
|
hasAssignedItStaff,
|
||||||
|
),
|
||||||
_MetaBadge(label: 'Office', value: officeName),
|
_MetaBadge(label: 'Office', value: officeName),
|
||||||
_MetaBadge(
|
_MetaBadge(
|
||||||
label: 'Task #',
|
label: 'Task #',
|
||||||
|
|
@ -3472,16 +3487,29 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Task task,
|
Task task,
|
||||||
bool canUpdateStatus,
|
bool canUpdateStatus,
|
||||||
|
bool hasAssignedItStaff,
|
||||||
) {
|
) {
|
||||||
final chip = StatusPill(
|
final chip = StatusPill(
|
||||||
label: task.status.toUpperCase(),
|
label: task.status.toUpperCase(),
|
||||||
isEmphasized: task.status != 'queued',
|
isEmphasized: task.status != 'queued',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!canUpdateStatus) {
|
final isTerminal =
|
||||||
|
task.status == 'completed' ||
|
||||||
|
task.status == 'cancelled' ||
|
||||||
|
task.status == 'closed';
|
||||||
|
|
||||||
|
if (!canUpdateStatus || isTerminal) {
|
||||||
return chip;
|
return chip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final statusOptions = _statusOptions.where((status) {
|
||||||
|
if (status == 'in_progress' && !hasAssignedItStaff) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
// If cancelling, require a reason — show dialog with spinner.
|
// 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(
|
.map(
|
||||||
(status) => PopupMenuItem(
|
(status) => PopupMenuItem(
|
||||||
value: status,
|
value: status,
|
||||||
|
|
|
||||||
|
|
@ -330,16 +330,6 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDesktop(BuildContext context, BoxConstraints constraints) {
|
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
|
// Use progressively smaller fractions of the viewport on larger screens
|
||||||
// so that content doesn't stretch too widely, but also consume as much
|
// so that content doesn't stretch too widely, but also consume as much
|
||||||
// width as possible when the display is more modest.
|
// width as possible when the display is more modest.
|
||||||
|
|
@ -352,70 +342,256 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
contentFactor = 0.75; // ultra-wide monitors
|
contentFactor = 0.75; // ultra-wide monitors
|
||||||
}
|
}
|
||||||
final contentWidth = constraints.maxWidth * contentFactor;
|
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
|
// Table width: mirrors PaginatedDataTable's own internal layout so the
|
||||||
// ScrollController is shared so the scrollbar has something to observe.
|
// horizontal scrollbar appears exactly when columns would otherwise be
|
||||||
final horizontalController = ScrollController();
|
// squeezed below their minimum comfortable width.
|
||||||
final tableWidget = Scrollbar(
|
// • horizontalMargin=16 → 32 px total side padding
|
||||||
controller: horizontalController,
|
// • columnSpacing=20 between each adjacent pair
|
||||||
thumbVisibility: true,
|
// • 200 px minimum per data column, 140 px for the actions column
|
||||||
trackVisibility: true,
|
const double colMinW = 200.0;
|
||||||
child: SingleChildScrollView(
|
const double actMinW = 140.0;
|
||||||
controller: horizontalController,
|
const double hMarginTotal = 16.0 * 2;
|
||||||
scrollDirection: Axis.horizontal,
|
const double colSpacing = 20.0;
|
||||||
child: SizedBox(
|
final actionsColumnCount = rowActions == null ? 0 : 1;
|
||||||
width: tableWidth,
|
final totalCols = columns.length + actionsColumnCount;
|
||||||
child: PaginatedDataTable(
|
final minColumnsWidth =
|
||||||
header: tableHeader,
|
hMarginTotal +
|
||||||
rowsPerPage: effectiveRowsPerPage,
|
(columns.length * colMinW) +
|
||||||
columnSpacing: 20,
|
(actionsColumnCount * actMinW) +
|
||||||
horizontalMargin: 16,
|
(math.max(0, totalCols - 1) * colSpacing);
|
||||||
showCheckboxColumn: false,
|
final tableWidth = math.max(contentWidth, minColumnsWidth);
|
||||||
headingRowColor: WidgetStateProperty.resolveWith(
|
|
||||||
(states) => Theme.of(context).colorScheme.surfaceContainer,
|
final summaryWidget = summaryDashboard == null
|
||||||
),
|
? null
|
||||||
columns: [
|
: Column(
|
||||||
for (final column in columns)
|
mainAxisSize: MainAxisSize.min,
|
||||||
DataColumn(label: Text(column.header)),
|
children: [
|
||||||
if (rowActions != null) const DataColumn(label: Text('Actions')),
|
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
|
// Expand each row to fill the leftover space so the table reaches
|
||||||
? null
|
// exactly the bottom of the Expanded widget — no empty space.
|
||||||
: <Widget>[
|
final rowHeight = math.max(defaultRowH, availableForRows / rows);
|
||||||
SizedBox(width: contentWidth, child: summaryDashboard!),
|
return (rows, rowHeight);
|
||||||
const SizedBox(height: 12),
|
}
|
||||||
];
|
|
||||||
final filterSection = filterHeader == null
|
|
||||||
? null
|
|
||||||
: <Widget>[filterHeader!, const SizedBox(height: 12)];
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
Widget _buildTable(
|
||||||
primary: true,
|
BuildContext context,
|
||||||
child: Center(
|
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(
|
child: SizedBox(
|
||||||
key: const Key('adaptive_list_content'),
|
key: const Key('adaptive_list_content'),
|
||||||
width: contentWidth,
|
width: widget.contentWidth,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user