flutter_table_layout 0.1.3
flutter_table_layout: ^0.1.3 copied to clipboard
Responsive data table for Flutter with frozen columns, sorting, filtering, grouping, editing, and Excel/PDF export for mobile, web, and desktop.
flutter_table_layout #
Adaptive data tables for Flutter. One widget gives you a dense data grid on desktop and web, and expandable cards on phones. It comes with search, date and custom filters, sorting, pagination, selection, summaries, Excel / Word / PDF export, printing, auto-generated CRUD forms, five themes, and full RTL (Arabic) support.
The grid also covers the "spreadsheet" features: frozen columns, sticky header with virtualized rows (100k+ rows), column resize and drag & drop reorder, a per-column filter row, multi-column sort, row grouping, inline cell editing, keyboard navigation, and server-side data sources.
[Desktop data grid]
[Mobile cards in Arabic (RTL)] [Mobile cards, dark cozy theme]
Contents #
- Features
- Installation
- Quick start
- Columns
- Search, filters and sorting
- Pagination
- Selection, row taps and expandable rows
- Summary row
- Controlling the table from outside
- Loading and empty states
- Layout mode (grid or cards)
- Advanced grid
- Export and printing
- Dynamic forms (CRUD)
- Hiding features and permissions
- Themes
- Localization and RTL
- Comparison with other packages
- API reference
- Using the logic without the UI
- Running the example and tests
- بالعربية
Features #
| 📱 Adaptive layout | Data grid above mobileBreakpoint (600 px by default). Below it, rows become expandable cards. Horizontal scrolling kicks in when the columns don't fit. |
| 🔎 Search | Debounced, case-insensitive search over visible and searchable columns. Dates also match yyyy-MM-dd. |
| 📅 Filters | A single "Period" button with quick periods, a range calendar and typed dates (bottom sheet on phones), a "Query" button mode, your own filter widgets (customFilters), and a "Clear filters" button. |
| ↕️ Sorting | Stable sort. Numbers, strings (case-insensitive), dates and booleans compare naturally, and null always sorts last. |
| 📄 Pagination | Numbered pages, a rows-per-page menu, "Showing 11–20 of 48". The current page is clamped automatically when data shrinks. |
| ☑️ Selection | Row checkboxes, a tri-state select all, onSelectionChanged, and a selection badge. Exports use only the selected rows when there are any. |
| 🧩 Expandable rows | expandedRowBuilder adds a details panel on desktop and mobile. |
| 🧮 Summary row | summaryBuilder receives every filtered row, across all pages. |
| 📤 Export | Excel .xlsx with typed cells, Word .doc, PDF (auto landscape, Arabic font), and system printing. Web downloads the file, mobile opens the share sheet, desktop saves to Downloads. |
| 📝 Dynamic forms | Generate add/edit dialogs from your columns, with validation and typed values. |
| 🎨 Themes | adaptive, light, dark, glassmorphic, gradient, cozy. It's a ThemeExtension, so you can register it once for the whole app. |
| 🌍 i18n and RTL | English and Arabic built in, every string can be overridden, and layout, alignment and icons mirror in RTL. |
| 🧊 Frozen columns | Pin columns at the start or end (pin: ColumnPin.start); users can pin from the columns menu. LTR and RTL. |
| 🚀 Large data | bodyHeight / fillHeight: sticky header and lazily built rows. Tested with 100,000 rows. |
| ↔️ Column layout | Resize by dragging the header edge (double-click resets), reorder by long-press + drag, "Reset columns". |
| 🧪 Column filters | A filter row under the header: text, =x, !=x, >10, <=5, 10..20, a, b, dates >=2026-01-01. |
| 🔢 Multi-sort | Shift + click adds secondary sort levels, shown as 1, 2, 3 in the header. |
| 🗂️ Grouping | groupByColumnId, collapsible groups, per-group aggregates via groupHeaderBuilder. |
| ✏️ Inline editing | Double-click or Enter to edit text, number, dropdown, date or boolean cells, with validation and per-cell permissions. |
| ⌨️ Keyboard | Arrows, Enter/F2, Tab, Space, Home/End, Page Up/Down, Ctrl+A, Escape. |
| ☁️ Server-side | AdaptiveTableDataSource: search, filters, sort and paging are sent to your API. Stale responses are ignored. |
| 🕹️ Controller | AdaptiveTableController to search, filter, sort, paginate and select from anywhere. |
Installation #
dependencies:
flutter_table_layout: ^0.1.0
flutter pub get
Requires Flutter ≥ 3.35 / Dart ≥ 3.9.
Platform notes
- macOS: printing and saving files need the sandbox entitlements
com.apple.security.printandcom.apple.security.files.downloads.read-write.- Android / iOS: exports open the native share sheet (
share_plus).- Web: exports download directly in the browser.
Quick start #
import 'package:flutter/material.dart';
import 'package:flutter_table_layout/flutter_table_layout.dart';
class Invoice {
final int id;
final String customer;
final double total;
final DateTime date;
final bool paid;
const Invoice(this.id, this.customer, this.total, this.date, this.paid);
}
class InvoicesPage extends StatelessWidget {
const InvoicesPage({super.key, required this.invoices});
final List<Invoice> invoices;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
child: AdaptiveTableLayout<Invoice>(
title: 'Invoices',
subtitle: 'All invoices of the current year',
items: invoices,
columns: [
AdaptiveTableColumn(id: 'id', title: '#', width: 70),
AdaptiveTableColumn(id: 'customer', title: 'Customer', flex: 2),
AdaptiveTableColumn(
id: 'total',
title: 'Total',
alignment: TableColumnAlignment.end,
valueFormatter: (v) => '\$${(v as double).toStringAsFixed(2)}',
),
AdaptiveTableColumn(id: 'date', title: 'Date', width: 120),
AdaptiveTableColumn(
id: 'paid',
title: 'Status',
width: 110,
cellBuilder: (context, inv) => Chip(
label: Text(inv.paid ? 'Paid' : 'Open'),
),
),
],
// One extractor per column id. Used for search, sort and export.
valueProviders: {
'id': (i) => i.id,
'customer': (i) => i.customer,
'total': (i) => i.total,
'date': (i) => i.date,
'paid': (i) => i.paid ? 'Paid' : 'Open',
},
dateProvider: (i) => i.date, // enables the From / To filter
),
),
);
}
}
💡
itemsmay be a new list or the same list changed in place. Either way the table refreshes on the next rebuild.
Columns #
AdaptiveTableColumn<T> describes one column:
| Property | Default | Description |
|---|---|---|
id |
required | Unique key. It must match the key in valueProviders. |
title |
required | Header text. Single words are never split mid-word, they shrink to fit instead. |
fieldName |
id |
Field name, used by the dynamic form type detection. |
width / flex |
null / 1 |
Fixed width, or a flex share of the remaining space. |
alignment |
start |
start, center or end. Direction-aware, so end is the left edge in RTL. |
cellBuilder |
null |
Custom cell widget. |
headerBuilder |
null |
Custom header widget. |
valueFormatter |
null |
Formats the raw value for text cells, mobile cards and PDF/Word exports. Excel keeps the raw typed value. |
isSortable |
true |
Tapping the header toggles ascending / descending. |
isVisible |
true |
Initial visibility. Users can still toggle it from the columns menu. |
isHideable |
true |
Whether users may hide the column. The last visible column can never be hidden. |
isSearchable |
true |
Include in global search. |
isExportable |
true |
Include in exports and printing. Set false for action columns. |
pin |
ColumnPin.none |
Freeze at the start or end (desktop grid). |
minWidth |
60 |
Smallest width when resizing or squeezing. |
isResizable |
true |
Allow resizing by dragging the header edge. |
isFilterable |
true |
Show a field in the per-column filter row. |
isEditable |
false |
Allow inline editing (with onCellEdited). |
editor |
inferred | CellEditor.text / number / dropdown(options) / date / boolean. |
cellValidator |
null |
Validates inline edits. Return an error message to keep the editor open. |
AdaptiveTableColumn<Currency>(
id: 'actions',
title: 'Actions',
width: 130,
isSortable: false,
isSearchable: false,
isExportable: false, // not written to Excel / PDF / Word
cellBuilder: (context, c) => Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => edit(c)),
IconButton(icon: const Icon(Icons.delete), onPressed: () => delete(c)),
],
),
),
[Columns menu (dark theme)]
Search, filters and sorting #
AdaptiveTableLayout<Transaction>(
// …
showSearch: true, // default
searchDebounce: const Duration(milliseconds: 250),
dateProvider: (t) => t.date, // shows the "Period" date filter
firstDate: DateTime(2020), lastDate: DateTime(2030),
onQueryPressed: () {}, // optional: apply dates only on "Query"
initialSortColumnId: 'date',
initialSortAscending: false,
// Custom filters: any widget, plus a matcher.
customFilterMatcher: (t, filters) =>
filters['currency'] == null || t.currency == filters['currency'],
customFilters: const [CurrencyFilter()],
)
A custom filter widget is built under the table, so it can talk to the table directly:
class CurrencyFilter extends StatelessWidget {
const CurrencyFilter({super.key});
@override
Widget build(BuildContext context) {
final cubit = context.watch<TableCubit<Transaction>>();
final current = cubit.tableState.customFilters['currency'] as String?;
return SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'all', label: Text('All')),
ButtonSegment(value: 'USD', label: Text('USD')),
ButtonSegment(value: 'SAR', label: Text('SAR')),
],
selected: {current ?? 'all'},
onSelectionChanged: (s) =>
cubit.setCustomFilter('currency', s.first == 'all' ? null : s.first),
);
}
}
Value providers whose key doesn't match any column are search-only keys. For example,
'tags': (t) => t.tags.join(' ')makes tags searchable without showing a column.
Date filter ("Period" button) #
[Date range panel] [Date range bottom sheet on a phone]
With a dateProvider, the filter bar shows a single Period button ("Period: This month ▾", ✕ to clear). It opens:
- Desktop / web: a panel anchored to the button with quick periods, a two-month range calendar (one month on narrower screens) and typed From / To fields (
yyyy-mm-dd). - Phones: the same as a bottom sheet with preset chips and one month.
Click a first day, then a last day; the days in between are highlighted. Apply filters the rows (or waits for your Query button when onQueryPressed is set), and Clear removes the filter.
AdaptiveTableLayout<Invoice>(
dateProvider: (i) => i.date,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
// Optional: choose / add quick periods (default: all, today, yesterday,
// last 7 days, last 30 days, this month, last month, this year).
datePresets: [
DateRangePreset.all,
DateRangePreset.thisMonth,
DateRangePreset.lastMonth,
DateRangePreset(
id: 'quarter',
label: (labels) => 'This quarter',
range: (now) {
final q = (now.month - 1) ~/ 3;
return DateTimeRange(
start: DateTime(now.year, q * 3 + 1),
end: DateTime(now.year, q * 3 + 4, 0),
);
},
),
],
// The previous look (two separate From / To buttons) is still available:
// dateFilterStyle: DateFilterStyle.separateFields,
)
The panel is also usable on its own: showTableDateRangePicker(context: …, start: …, end: …, theme: …, labels: …).
Pagination #
AdaptiveTableLayout<T>(
pageSizes: const [10, 25, 50, 100],
initialPageSize: 25,
showPagination: true, // false = render every row, no footer
)
Selection, row taps and expandable rows #
AdaptiveTableLayout<Transaction>(
showSelection: true,
onSelectionChanged: (rows) => setState(() => selected = rows),
onRowTap: (t) => openDetails(t), // when null, a tap expands the row
onRowLongPress: (t) => showMenu(t),
rowColorBuilder: (t) => t.isOverdue ? Colors.red.withValues(alpha: .06) : null,
expandedRowBuilder: (context, t) => TransactionDetails(t),
mobileTitleColumnId: 'details', // card title on phones
mobileSubtitleColumnId: 'amount', // card subtitle on phones
)
Summary row #
summaryBuilder receives all filtered rows, not only the current page:
summaryBuilder: (context, rows) {
final total = rows.fold<double>(0, (s, r) => s + r.amount);
return Text('Total: ${total.toStringAsFixed(2)}');
},
Controlling the table from outside #
final controller = AdaptiveTableController<Invoice>();
AdaptiveTableLayout<Invoice>(controller: controller, /* … */);
controller.search('acme');
controller.setDateRange(DateTime(2026, 1, 1), null);
controller.setCustomFilter('status', 'open');
controller.sortBy('total', ascending: false);
controller.goToPage(2);
controller.setColumnVisibility('notes', false);
controller.selectAll();
controller.setColumnFilter('total', '>100');
controller.addSort('date'); // secondary sort level
controller.groupBy('customer');
controller.setColumnPin('customer', ColumnPin.start);
controller.refresh(); // reload (server mode)
final rows = controller.filteredItems; // all pages, filtered and sorted
final picked = controller.selectedItems;
controller.resetFilters();
The controller is a ChangeNotifier, so ListenableBuilder(listenable: controller, …) rebuilds on every table change. Use onTableStateChanged to persist the search/sort/page state, or to drive server-side queries.
Loading and empty states #
AdaptiveTableLayout<T>(
isLoading: isFetching, // spinner, or a progress bar over existing rows
loadingWidget: const MyShimmer(),
emptyWidget: const MyEmptyState(), // otherwise "No data" / "No results"
)
If a value provider throws, the table shows an error message instead of crashing, and recovers on the next change.
Layout mode (grid or cards) #
AdaptiveTableLayout<T>(
layoutMode: TableLayoutMode.auto, // default: cards below mobileBreakpoint
// layoutMode: TableLayoutMode.table, // always the grid (scrolls horizontally on phones)
// layoutMode: TableLayoutMode.cards, // always cards, even on desktop
mobileBreakpoint: 700, // only used by `auto`
)
The toolbar and footer always adapt to the available width, whatever the mode.
Advanced grid #
[Frozen columns and per-column filters]
Everything below works on desktop and web. On phones the same table switches to cards, with grouping and virtualization still working.
Sticky header and virtualization (large data) #
Give the rows a bounded height and only the visible rows are built, while the header stays on top:
// A fixed rows area…
AdaptiveTableLayout<Employee>(
items: employees, // e.g. 100,000 rows
showPagination: false, // one long, lazily built list
bodyHeight: 520,
columns: columns,
valueProviders: providers,
)
// …or fill the parent (Expanded, SizedBox, a TabBarView page…)
Expanded(
child: AdaptiveTableLayout<Employee>(
fillHeight: true,
items: employees,
columns: columns,
valueProviders: providers,
),
)
fillHeight is ignored (with a debug message) when the parent height is unbounded, for example inside a SingleChildScrollView.
Frozen columns #
AdaptiveTableColumn(id: 'id', title: 'ID', width: 80, pin: ColumnPin.start),
AdaptiveTableColumn(id: 'name', title: 'Name', width: 200, pin: ColumnPin.start),
// … scrollable columns …
AdaptiveTableColumn(id: 'actions', title: 'Actions', width: 110, pin: ColumnPin.end),
- Pinned columns stay visible while the other columns scroll horizontally.
startandendfollow the text direction. - The selection and expand columns are always pinned.
- Users can pin or unpin any column from the 📌 button in the columns menu, or you can call
controller.setColumnPin('city', ColumnPin.start).
Resize and reorder columns #
- Resize: drag the right edge of a header. Double-click the edge to restore the declared width.
- Reorder: long-press a header, then drag it onto another header.
- Reset: the columns menu has a "Reset columns" item, or call
controller.resetColumnLayout().
AdaptiveTableLayout<T>(
allowColumnResize: true, // default
allowColumnReorder: true, // default
columns: [
AdaptiveTableColumn(id: 'notes', title: 'Notes', minWidth: 120),
AdaptiveTableColumn(id: 'actions', title: '', isResizable: false),
],
)
// From code:
controller.setColumnWidth('notes', 300);
controller.moveColumn('salary', beforeColumnId: 'name');
Per-column filter row #
AdaptiveTableLayout<T>(showColumnFilters: true, /* … */)
| Type in a column filter | Keeps rows where the value… |
|---|---|
acme |
contains "acme" (case-insensitive) |
=paid / !=paid |
equals / differs |
!test |
does not contain "test" |
>1000, >=1000, <50, <=50 |
compares as a number, date or text |
100..500 |
is within the range (inclusive) |
>=2026-01-01 |
is on or after a date |
USD, SAR |
matches any of the parts (OR) |
Column filters combine with each other and with the search box using AND. Set isFilterable: false to skip a column. From code: controller.setColumnFilter('salary', '>=3000'). The expressions are also sent to your server in TableStateModel.columnFilters.
Multi-column sort #
Click a header to sort, and Shift + click other headers to add secondary sort levels, shown as 1, 2… next to the arrow. From code:
controller.setSorts(const [ColumnSort('department'), ColumnSort('salary', ascending: false)]);
controller.addSort('name');
Row grouping #
[Grouping and inline editing] [Grouped cards on a phone (Arabic)]
AdaptiveTableLayout<Employee>(
groupByColumnId: 'department', // or controller.groupBy('city')
groupHeaderBuilder: (context, group) => Text(
'Avg: ${group.rows.map((e) => e.salary).reduce((a, b) => a + b) / group.rows.length}',
),
)
- Click a group header to collapse or expand it.
group.rowsholds every row of the group across all pages, so aggregates cover the whole group.- Rows are ordered by the group column first, then by the user's sort.
Inline cell editing #
AdaptiveTableLayout<Employee>(
items: employees,
columns: [
AdaptiveTableColumn(id: 'name', title: 'Name', isEditable: true,
cellValidator: (v) => (v as String).trim().isEmpty ? 'Required' : null),
AdaptiveTableColumn(id: 'salary', title: 'Salary', isEditable: true), // number editor (inferred)
AdaptiveTableColumn(id: 'dept', title: 'Department', isEditable: true,
editor: const CellEditor.dropdown(['Sales', 'IT', 'HR'])),
AdaptiveTableColumn(id: 'hiredOn', title: 'Hired', isEditable: true), // date picker (inferred)
AdaptiveTableColumn(id: 'active', title: 'Active', isEditable: true), // toggles on double-click
],
// Permissions: per row / per column.
canEditCell: (e, column) => user.canEdit && e.isActive,
// Save the change. Return false (or throw) to reject it.
onCellEdited: (e, column, value) async {
final ok = await api.update(e.id, {column: value});
if (ok) setState(() => employees = [for (final x in employees) x.id == e.id ? x.copyWith(column, value) : x]);
return ok;
},
)
| Action | Result |
|---|---|
| Double-click, Enter or F2 | Start editing |
| Enter | Save |
| Tab / Shift+Tab | Save and edit the next / previous cell |
| Escape | Cancel |
| Click outside | Save |
The editor is inferred from the value (num → number, DateTime → date picker, bool → toggle, otherwise text) unless you set editor. Numbers accept 1,250.5. Invalid values keep the editor open with an error.
Keyboard navigation #
Click a cell, then:
| Key | Action |
|---|---|
| ← → ↑ ↓ | Move between cells (mirrored in RTL) |
| Enter / F2 | Edit, or run onRowTap / expand the row |
| Space | Select the row |
| Home / End (+Ctrl) | First / last column (row) |
| Page Up / Page Down | Previous / next page |
| Ctrl/Cmd + A | Select all |
| Escape | Leave the cell |
Turn it off with enableKeyboardNavigation: false.
Server-side data #
[Server-side data source]
class _InvoicesState extends State<InvoicesPage> {
// Create the source once, not in build().
late final source = AdaptiveTableDataSource<Invoice>.fromCallback((q) async {
final res = await api.getInvoices(
page: q.currentPage,
size: q.pageSize,
search: q.searchQuery,
sort: [for (final s in q.sorts) '${s.columnId}:${s.ascending ? 'asc' : 'desc'}'],
filters: q.columnFilters, // {'total': '>100', 'status': '=paid'}
from: q.startDate, to: q.endDate,
);
return TableDataPage(items: res.items, totalCount: res.total);
});
@override
Widget build(BuildContext context) => AdaptiveTableLayout<Invoice>(
dataSource: source, // `items` is not needed
columns: columns,
valueProviders: providers,
showColumnFilters: true,
fillHeight: true,
);
}
- Every change to the search, filters, sort, page or page size calls
fetch. - A progress bar shows while a request is in flight, and out-of-order responses are ignored.
- On error, the table shows a Retry button (
controller.refresh()also reloads). - If the server has fewer pages than the current page, the table moves to the last page automatically.
- Selection and exports work on the rows of the current page. Use
onExportRequestedfor server-side exports. - Tip:
FilterItemsUseCaseis pure Dart, so your Dart backend can run exactly the same filtering and sorting.
Export and printing #
The toolbar has Excel, Word, PDF and Print actions. Exports:
- respect the current search, filters and sort order;
- export only the selected rows when some are selected (
exportSelectedWhenAny); - skip hidden columns and columns with
isExportable: false; - escape HTML in the Word output, and use safe file and sheet names, including Arabic titles;
- save the file where it belongs: a browser download on web, the share sheet on mobile, and
~/Downloadson desktop (with a snackbar showing the path).
AdaptiveTableLayout<T>(
exportOptions: TableExportOptions(
fileName: 'statement_2026',
formats: {ExportFormat.excel, ExportFormat.pdf},
pdfFont: myRegularFont, // pw.Font.ttf(await rootBundle.load('assets/Cairo-Regular.ttf'))
pdfBoldFont: myBoldFont,
pdfPageFormat: PdfPageFormat.a4.landscape,
// Handle the bytes yourself (upload, e-mail…) instead of saving:
onExport: (format, bytes, fileName) async => upload(bytes, fileName),
),
)
Use your own export or print (for example your company PDF template). The table gives you the rows it would export: the selected rows if any, otherwise all filtered rows, in the current sort order.
AdaptiveTableLayout<Invoice>(
onExportRequested: (format, rows) async {
if (format == ExportFormat.pdf) {
final bytes = await MyInvoicePdf.build(rows);
await saveAndShareFile(bytes: bytes, fileName: 'invoices.pdf',
mimeType: format.mimeType);
}
},
onPrintRequested: (rows) => MyPrinter.print(rows),
)
Arabic PDFs offline: by default the Cairo font is downloaded from Google Fonts once, then cached. Offline apps should bundle a font and pass
pdfFont.
The exporters also work on their own:
final bytes = await const ExcelExporter().generateExcel<Invoice>(
sheetName: 'Invoices',
columns: columns.map((c) => c.definition).toList(),
items: invoices,
valueProviders: providers,
);
await saveAndShareFile(bytes: bytes, fileName: 'invoices.xlsx',
mimeType: ExportFormat.excel.mimeType);
Dynamic forms (CRUD) #
[Dynamic form dialog]
Generate a form from your columns and get typed values back:
final values = await DynamicFormDialog.show(
context,
title: 'New transaction',
fields: DynamicFormField.detectFromColumns(
columns,
dropdownItems: {'currency': ['YER', 'SAR', 'USD']},
excludeIds: {'id', 'actions'},
initialValues: {'currency': 'USD'}, // e.g. when editing
fieldTypes: {'status': FieldType.text}, // override the detection
),
);
if (values != null) {
// values['amount'] is num?, values['date'] is DateTime, values['paid'] is bool…
}
Type detection works on whole words of fieldName / id (createdAt, created_at and Created At all split into created + at):
| Detected type | Matches | Widget | Value |
|---|---|---|---|
boolean |
starts with is/has/can/should/allow, or contains active, enabled, status, visible |
SwitchListTile |
bool |
date |
contains date, day, time, birthday, dob, or ends with at/on |
date picker | DateTime |
number |
contains id, amount, price, rate, total, qty, count, balance, discount, … |
numeric field | num? (null if left empty) |
dropdown |
column listed in dropdownItems |
DropdownButtonFormField |
String? |
text |
anything else | TextFormField |
String? |
Manual fields support validator (called for every type), hint, helperText, enabled, maxLines, firstDate and lastDate:
DynamicFormField(
id: 'email',
label: 'E-mail',
type: FieldType.text,
isRequired: true,
hint: 'name@company.com',
validator: (v) => (v as String?)?.contains('@') == true ? null : 'Invalid e-mail',
),
Hiding features and permissions #
Every part of the table can be turned off, and a button whose callback is null is not shown. So permissions are just values:
final canEdit = user.can('invoices.edit');
AdaptiveTableLayout<Invoice>(
showSearch: true,
showDateFilter: false, // hide the From / To pickers
showExport: user.can('invoices.export'),
showPrint: user.can('invoices.print'),
showSelection: canEdit,
showColumnsToggle: true,
showPagination: true,
showSummary: user.can('invoices.totals'),
showClearFilters: true,
onAddNewPressed: user.can('invoices.create') ? openMyAddDialog : null,
onRefreshPressed: reload,
exportOptions: const TableExportOptions(formats: {ExportFormat.excel}), // only Excel
columns: [
// …
if (canEdit)
AdaptiveTableColumn(
id: 'actions', title: 'Actions', width: 130,
isSortable: false, isSearchable: false, isExportable: false,
cellBuilder: (context, inv) => Row(mainAxisSize: MainAxisSize.min, children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => openMyEditDialog(inv)),
if (user.can('invoices.delete'))
IconButton(icon: const Icon(Icons.delete), onPressed: () => delete(inv)),
]),
),
],
onRowTap: canEdit ? openMyEditDialog : null,
)
- The filter bar disappears when search, the date filter and custom filters are all off.
- The toolbar disappears when there is no title and no action.
onAddNewPressedcan open any dialog or page, soDynamicFormDialogis optional.- Sensitive columns: include them only when allowed (
if (user.isAdmin) AdaptiveTableColumn(...)), or useisHideable: false/isExportable: false.
Themes #
| Glassmorphic | Gradient | Cozy |
|---|---|---|
| [glass] | [gradient] | [cozy] |
AdaptiveTableTheme.adaptive(context) // default: follows light/dark + colorScheme.primary
AdaptiveTableTheme.light(context, accentColor: Colors.teal)
AdaptiveTableTheme.dark(context)
AdaptiveTableTheme.glassmorphic(context, isDark: true) // place over an image/gradient
AdaptiveTableTheme.gradient(context, gradient: myGradient)
AdaptiveTableTheme.cozy(context)
// Tweak any preset:
AdaptiveTableTheme.light(context).copyWith(
borderRadius: BorderRadius.circular(4),
useAlternateRows: false,
accentColor: Colors.deepPurple,
)
Register a theme once for the whole app. It also animates between light and dark:
MaterialApp(
theme: ThemeData(extensions: [myTableTheme]),
)
Main theme properties: cardBackgroundColor, borderRadius, cardBorder, cardShadow, cardMargin, toolbarBackgroundColor, headerBackgroundColor, headerTextStyle, titleTextStyle, rowBackgroundColor, alternateRowBackgroundColor, useAlternateRows, rowTextStyle, rowPadding, rowHoverColor, selectedRowColor, dividerColor, footerBackgroundColor, footerTextStyle, summaryBackgroundColor, accentColor, onAccentColor, actionIconColor, toolbarIconColor, statusPositiveColor, statusNegativeColor, headerGradient, footerGradient, enableGlassmorphism, blurSigma.
Localization and RTL #
[Arabic RTL desktop]
- Labels come from
AdaptiveTableLabels.of(context): Arabic when the app locale isar, English otherwise. - Pass
labels: AdaptiveTableLabels.en.copyWith(search: 'Buscar…', …)(or a whole newAdaptiveTableLabels) for any other language. - The older
searchHint,dateFromLabel,dateToLabelandqueryButtonLabelparameters still work and override the labels. - In RTL, the layout, column alignments, pagination arrows and expand icons are mirrored, Excel sheets are right-to-left, and Word/PDF documents use
dir="rtl".
Comparison with other packages #
✅ built in · 🟡 partial / needs extra work · ❌ not available. The other packages' columns are based on their public documentation. Check their latest versions before deciding.
| Feature | flutter_table_layout | PlutoGrid / TrinaGrid | Syncfusion DataGrid | data_table_2 | PaginatedDataTable |
|---|---|---|---|---|---|
| License | MIT (free) | MIT | Commercial (free community license with conditions) | MIT | Flutter SDK |
| Automatic mobile cards layout | ✅ | ❌ | ❌ | ❌ | ❌ |
| Arabic / RTL with ready-made labels | ✅ | 🟡 | 🟡 | 🟡 | 🟡 |
| Global search box | ✅ | ✅ | 🟡 | ❌ | ❌ |
| Date-range filter | ✅ | 🟡 | 🟡 | ❌ | ❌ |
| Per-column filter row | ✅ | ✅ | ✅ | ❌ | ❌ |
| Multi-column sort | ✅ | ✅ | ✅ | ❌ | ❌ |
| Pagination | ✅ | ✅ | ✅ | ✅ | ✅ |
| Virtualized rows / sticky header | ✅ | ✅ | ✅ | ✅ | ❌ |
| Frozen columns (start / end) | ✅ | ✅ | ✅ | 🟡 (start) | ❌ |
| Resize / reorder columns | ✅ | ✅ | ✅ | ❌ | ❌ |
| Row grouping | ✅ | ✅ | ✅ | ❌ | ❌ |
| Inline cell editing | ✅ | ✅ | ✅ | ❌ | ❌ |
| Keyboard navigation | ✅ | ✅ | ✅ | ❌ | ❌ |
| Server-side data source | ✅ | ✅ | ✅ | ✅ | 🟡 |
| Excel + Word + PDF export, print | ✅ built in | 🟡 add-on | 🟡 add-on | ❌ | ❌ |
| Auto-generated add / edit forms | ✅ | ❌ | ❌ | ❌ | ❌ |
| Summary row + group aggregates | ✅ | ✅ | ✅ | ❌ | ❌ |
| Ready-made themes | ✅ 6 | ✅ | ✅ | 🟡 | 🟡 |
| External controller | ✅ | ✅ | ✅ | 🟡 | 🟡 |
Where this package is stronger: it's the only one that turns into cards on phones automatically, it ships Arabic out of the box, exports to Excel, Word and PDF and prints without add-ons, and generates CRUD forms from your columns. All of that sits in one MIT-licensed widget, with the same API on phone, tablet, desktop and web.
Where the others are still ahead: Excel-style clipboard copy/paste, multi-cell range selection, row drag & drop, stacked (multi-level) headers, tree data, and cell merging. These are not implemented here yet.
API reference #
AdaptiveTableLayout<T> #
| Parameter | Type | Default | Description |
|---|---|---|---|
items |
List<T> |
required | Rows. |
columns |
List<AdaptiveTableColumn<T>> |
required | Column schema. |
valueProviders |
Map<String, dynamic Function(T)> |
required | Values per column id (search / sort / export). |
dateProvider |
DateTime? Function(T)? |
null |
Enables the date filter. |
customFilterMatcher |
bool Function(T, Map<String, dynamic>)? |
null |
Applies customFilters values. |
customFilters |
List<Widget>? |
null |
Extra filter widgets. |
controller |
AdaptiveTableController<T>? |
null |
External control. |
title / subtitle / titleIcon |
null |
Toolbar header. | |
toolbarActions |
List<Widget>? |
null |
Extra toolbar buttons. |
onRefreshPressed / onAddNewPressed / onQueryPressed |
VoidCallback? |
null |
Toolbar callbacks. |
showSearch / showSelection / showExport / showPrint / showColumnsToggle / showPagination / showSummary / showClearFilters |
bool |
true |
Feature switches. |
showDateFilter |
bool? |
dateProvider != null |
Show the date pickers. |
pageSizes / initialPageSize |
List<int> / int? |
[5,10,20,50] / 10 |
Pagination. |
initialSortColumnId / initialSortAscending |
null / true |
Initial sort. | |
summaryBuilder |
Widget Function(BuildContext, List<T>)? |
null |
Summary row. |
expandedRowBuilder |
Widget Function(BuildContext, T)? |
null |
Row details panel. |
onRowTap / onRowLongPress |
ValueChanged<T>? |
null |
Row gestures. |
onSelectionChanged |
ValueChanged<List<T>>? |
null |
Selection updates. |
onTableStateChanged |
ValueChanged<TableStateModel>? |
null |
Search / filter / sort / page updates. |
rowColorBuilder |
Color? Function(T)? |
null |
Per-row background. |
layoutMode |
TableLayoutMode |
auto |
auto, table (always grid), cards (always cards). |
mobileBreakpoint / minDesktopWidth |
double |
600 / 800 |
Layout thresholds. |
onExportRequested / onPrintRequested |
callbacks | null |
Replace the built-in export / print with your own. |
dataSource |
AdaptiveTableDataSource<T>? |
null |
Server-side mode (items not needed). |
bodyHeight / fillHeight |
double? / bool |
null / false |
Sticky header + virtualized rows. |
showColumnFilters |
bool |
false |
Filter row under the header. |
allowColumnResize / allowColumnReorder |
bool |
true / true |
Column layout by the user. |
enableKeyboardNavigation |
bool |
true |
Keyboard control of the grid. |
groupByColumnId / groupHeaderBuilder |
null |
Row grouping and group aggregates. | |
onCellEdited / canEditCell |
callbacks | null |
Inline editing and per-cell permission. |
minRowHeight |
double |
0 |
Minimum desktop row height. |
mobileTitleColumnId / mobileSubtitleColumnId |
String? |
first / second column | Card title and subtitle. |
theme / labels |
AdaptiveTableTheme.of / AdaptiveTableLabels.of |
Styling and strings. | |
searchDebounce |
Duration |
250ms |
Search delay. |
firstDate / lastDate |
DateTime? |
1900 / 2200 | Date picker range. |
dateFilterStyle |
DateFilterStyle |
rangePicker |
"Period" button + panel, or the legacy separateFields. |
datePresets |
List<DateRangePreset>? |
defaults | Quick periods of the date panel ([] = none). |
exportOptions |
TableExportOptions |
defaults | Export configuration. |
isLoading / loadingWidget / emptyWidget |
false / null / null |
States. |
Using the logic without the UI #
The domain layer is plain Dart and can be used anywhere, for example on a server:
final result = const FilterItemsUseCase().apply<Invoice>(
items: invoices,
columns: const [ColumnDefinition(id: 'customer', title: 'Customer')],
state: const TableStateModel(searchQuery: 'acme', sortByColumnId: 'total',
sortAscending: false, currentPage: 1, pageSize: 20),
valueProviders: {'customer': (i) => i.customer, 'total': (i) => i.total},
);
print('${result.totalCount} rows, ${result.totalPages} pages');
TableCubit<T> holds the reactive state (via flutter_bloc) if you want to build your own UI.
Running the example and tests #
cd example && flutter run # android / ios / web / windows / macos / linux
flutter test # from the package root
The screenshots in this README are generated from the example app:
cd example
flutter test test/screenshots_test.dart --update-goldens \
--dart-define=SCREENSHOTS=true --dart-define=ARABIC_FONT_DIR=/path/to/cairo
Releasing (maintainers) #
New versions are published to pub.dev automatically by .github/workflows/publish.yml when a version tag is pushed:
- Bump
version:inpubspec.yaml(e.g.0.1.2) and add the entry toCHANGELOG.md. - Merge to
main. - Tag and push:
git tag v0.1.2 git push origin v0.1.2
The workflow checks that the tag matches pubspec.yaml, runs analyze and tests, then publishes with GitHub OIDC (no token needed). pub.dev must have "Publishing from GitHub Actions" enabled for this repository with the tag pattern v{{version}}.
بالعربية #
flutter_table_layout مكتبة جداول ذكية لـ Flutter. تعرض البيانات كجدول كامل على الكمبيوتر والويب، وتحوّلها تلقائياً إلى بطاقات قابلة للتوسيع على الجوال. تدعم العربية واتجاه RTL بالكامل.
أهم المزايا: (ومعها تثبيت الأعمدة، والتعديل المباشر، والتجميع، والفلترة لكل عمود، والبيانات من السيرفر)
- بحث فوري، وفلترة بالتاريخ (من / إلى)، وفلاتر مخصصة، وزر لمسح الفلاتر.
- فرز ثابت يفهم الأرقام والنصوص والتواريخ، وترقيم صفحات بأرقام وعدد صفوف قابل للتغيير.
- تحديد الصفوف مع استدعاء
onSelectionChanged، وصفوف قابلة للتوسيع لعرض التفاصيل، وصف ملخص للإجماليات. - تصدير إلى Excel وWord وPDF (بخط عربي) وطباعة مباشرة. عند تحديد صفوف يُصدَّر المحدد منها فقط، ولا تُصدَّر الأعمدة المخفية.
- نماذج إضافة وتعديل تُولَّد تلقائياً من الأعمدة مع التحقق من الإدخال.
- ستة أنماط تصميم (تلقائي، فاتح، داكن، زجاجي، متدرج، مريح) قابلة للتعديل بـ
copyWith. - نصوص عربية وإنجليزية جاهزة تُختار حسب لغة التطبيق، ويمكن تخصيص أي نص.
- متحكم
AdaptiveTableControllerللتحكم بالجدول من خارجه.
مثال سريع:
AdaptiveTableLayout<Transaction>(
title: 'كشف الحساب',
items: transactions,
columns: [
AdaptiveTableColumn(id: 'date', title: 'التاريخ', width: 120),
AdaptiveTableColumn(id: 'details', title: 'البيان', flex: 2),
AdaptiveTableColumn(id: 'amount', title: 'المبلغ',
alignment: TableColumnAlignment.end),
],
valueProviders: {
'date': (t) => t.date,
'details': (t) => t.details,
'amount': (t) => t.amount,
},
dateProvider: (t) => t.date,
summaryBuilder: (context, rows) =>
Text('الإجمالي: ${rows.fold<double>(0, (s, t) => s + t.amount)}'),
)
لتظهر النصوص بالعربية، اضبط لغة التطبيق على ar (مع flutter_localizations)، أو مرّر labels: AdaptiveTableLabels.ar.
ميزات الشبكة المتقدمة (باختصار):
| الميزة | طريقة الاستخدام |
|---|---|
| تثبيت أعمدة | pin: ColumnPin.start أو ColumnPin.end في العمود، أو زر 📌 في قائمة الأعمدة |
| بيانات ضخمة مع رأس ثابت | bodyHeight: 500 أو fillHeight: true مع showPagination: false |
| تغيير عرض الأعمدة وترتيبها | اسحب حافة رأس العمود، أو اضغط مطولاً على الرأس واسحبه |
| فلتر لكل عمود | showColumnFilters: true، ثم اكتب مثلاً >1000 أو 100..500 أو =مدفوع |
| فرز بأكثر من عمود | Shift + نقر على رؤوس الأعمدة |
| تجميع الصفوف | groupByColumnId: 'department' وgroupHeaderBuilder للإجماليات |
| التعديل المباشر | isEditable: true في العمود + onCellEdited + canEditCell للصلاحيات |
| لوحة المفاتيح | الأسهم وEnter وTab وEsc وSpace |
| بيانات من السيرفر | dataSource: AdaptiveTableDataSource.fromCallback((q) async => ...) |
دليل الاستخدام الكامل بالعربية في doc/USAGE_AR.md.
License #
MIT. See LICENSE.