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: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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user