geniuslink_design_system 2.3.0
geniuslink_design_system: ^2.3.0 copied to clipboard
GeniusLink Design System for Flutter — a themeable, MVC widget kit (BrowserStyleTabBar, EditableTable with typed columns, ReadableTable, Tree, NavigationSidebar) with light/dark + LTR/RTL support.
GeniusLink Design System #
A themeable, MVC Flutter widget kit ported from the GeniusLink web design system. Five production-grade, self-contained components — a browser-style tab bar, an Excel-style editable table with nine typed column kinds, a read-only readable table with selection + sort, a customisable tree, and a responsive app navigation sidebar — all theme-aware (light + dark) and bilingual (LTR + RTL).
📺 Visual tour: open
doc/showcase.htmlin a browser for a designed walkthrough of every part and feature.
Features #
- 🧩 Five components, one kit —
BrowserStyleTabBar,EditableTable,ReadableTable,Tree,NavigationSidebar. - 🎨 Self-contained theming — each component carries its own
ThemeExtensionwith ready-made.light/.darkpresets. No global token file required. - 🏛️ Strict MVC — immutable models, a
ChangeNotifiercontroller as the single source of truth, and a thin view. Drive any component from outside, or from its own page content via anInheritedNotifierscope. - ⌨️ Full keyboard control — spreadsheet navigation, inline editing, copy/cut/paste, undo/redo, and an in-widget shortcuts reference.
- 🌍 RTL + dark everywhere — mirrors via
Directionality+EdgeInsetsDirectional. - 🔌 Zero third-party dependencies — pure Flutter + Material.
Install #
dependencies:
geniuslink_design_system:
git: # or a path/hosted source
url: https://example.com/geniuslink_design_system.git
import 'package:geniuslink_design_system/geniuslink_design_system.dart';
Prefer a leaner import? Each component ships its own barrel:
import 'package:geniuslink_design_system/geniuslink_browser_tabs.dart';
import 'package:geniuslink_design_system/geniuslink_editable_table.dart';
import 'package:geniuslink_design_system/geniuslink_readable_table.dart';
import 'package:geniuslink_design_system/geniuslink_tree.dart';
import 'package:geniuslink_design_system/geniuslink_navigation_sidebar.dart';
Register the theme extensions you use (each falls back to its .dark preset if absent):
MaterialApp(
theme: ThemeData(extensions: const [
BrowserStyleTabBarThemeData.light,
EditableTableThemeData.light, // also styles ReadableTable
TreeThemeData.light,
NavigationSidebarThemeData.light,
]),
darkTheme: ThemeData(extensions: const [
BrowserStyleTabBarThemeData.dark,
EditableTableThemeData.dark, // also styles ReadableTable
TreeThemeData.dark,
NavigationSidebarThemeData.dark,
]),
);
Run the example #
The package is a library (no lib/main.dart). Run the example app:
cd geniuslink_design_system_flutter/example
flutter pub get
flutter run -d chrome # or any device / emulator
It opens on a launcher of demos, each hosting the components in a realistic shell — an all-in-one ERP Console (tree sidebar + tabs + tables), an EditableTable gallery (every column type), a ReadableTable gallery (every selection mode + sort), a Tree gallery, plus Figma- and Chrome-style tab-bar shells.
Components #
1 · EditableTable #
An Excel-style data-entry grid. Click to select, type to overwrite, Enter ↓ / Tab → to move, sort by clicking a header, undo/redo — with nine typed column kinds, row-aware validation, optional delete confirmation, and a totals footer.
Quick start #
EditableTable(
columns: [
EditableColumn(key: 'name', label: 'Account', required: true),
NumericColumn(key: 'balance', label: 'Balance', includeInTotal: true),
],
initialRows: const [
{'name': 'Cash', 'balance': '42,500.00'},
{'name': 'Bank', 'balance': '186,420.00'},
],
showTotals: true,
unitLabel: 'SAR',
onChanged: (rows) => debugPrint('${rows.length} rows'),
);
EditableRow is just Map<String, String> — values are the strings the user typed; you parse on read. Provide columns + initialRows (the widget owns a controller), or pass a controller: to drive/observe it externally.
Column types #
Each kind is an ergonomic subclass of EditableColumn — pass the right one; the table picks the editor automatically.
| Column | Editor | Stores |
|---|---|---|
EditableColumn |
inline text | free text |
NumericColumn |
inline numeric (min/max/decimals) | grouped number 1,234.00 |
DateColumn |
masked YYYY-MM-DD + 📅 calendar button |
ISO date |
TimeColumn |
masked HH:mm + 🕑 clock button |
24h time |
ComboBoxColumn |
free text + suggestions ▾ | any string |
DropdownColumn |
popup menu (strict) | one of options |
ColorPickerColumn |
swatch menu | #RRGGBB hex |
ReadonlyColumn |
— (never editable) | display only |
ComputedColumn |
— (derived from the row) | compute(row) |
final columns = <EditableColumn>[
const ReadonlyColumn(key: 'id', label: 'ID', mono: true),
const EditableColumn(key: 'task', label: 'Task', required: true),
// Numeric — clamped, integer:
const NumericColumn(key: 'qty', label: 'Qty', min: 0, decimals: 0),
const NumericColumn(key: 'price', label: 'Price', min: 0, decimals: 2, includeInTotal: true),
// Computed — recomputes on every edit:
ComputedColumn(
key: 'total', label: 'Total', includeInTotal: true,
compute: (r) {
final q = EditableTableFormat.parseNumber(r['qty'] ?? '') ?? 0;
final p = EditableTableFormat.parseNumber(r['price'] ?? '') ?? 0;
return EditableTableFormat.formatNumber(q * p);
},
),
// Web-style date & time fields — type with the keyboard, or use the picker button:
const DateColumn(key: 'due', label: 'Due date'),
const TimeColumn(key: 'at', label: 'Time'),
// Strict dropdown vs. free-text combo:
const DropdownColumn(key: 'status', label: 'Status', options: ['Open', 'Active', 'Done']),
const ComboBoxColumn(key: 'tag', label: 'Tag', options: ['Design', 'Build', 'QA']),
// Colour — cell shows a swatch + hex, edits via a swatch menu:
const ColorPickerColumn(key: 'color', label: 'Colour'),
];
Validation #
Two hooks, both fed into the red cell border and the toolbar's validity badge:
// value-only
NumericColumn(
key: 'bal', label: 'Balance',
validate: (v) => (EditableTableFormat.parseNumber(v) ?? 0) < 0 ? 'No negatives' : null,
);
// row-aware (cross-column) — receives the whole row
EditableColumn(
key: 'total', label: 'Line Total',
cellValidator: (value, row) {
final q = EditableTableFormat.parseNumber(row['qty'] ?? '');
final p = EditableTableFormat.parseNumber(row['price'] ?? '');
final t = EditableTableFormat.parseNumber(value);
if (q == null || p == null || t == null) return null;
return (q * p - t).abs() > 0.01 ? '≠ Qty × Price' : null;
},
);
Custom cell rendering #
cellBuilder replaces a cell's read-only content with any widget (a chip, badge, progress bar…). The cell stays selectable/editable; you get the value, the whole row, selection/invalid state, and a requestEdit callback:
DropdownColumn(
key: 'status', label: 'Status', options: ['Open', 'Active', 'Done'],
cellBuilder: (context, cell) => GestureDetector(
onTap: cell.requestEdit,
child: Chip(label: Text(cell.value)),
),
);
Keyboard shortcuts #
Press the ⌨ button in the toolbar (or ⌘/Ctrl + /) for the in-widget cheatsheet.
↑ ↓ ← → |
Move between cells |
Tab / ⇧Tab |
Next / previous cell — Tab past the last cell appends a row (growOnTab) |
Home / End · ⌘+Home/End |
First / last column · first / last cell |
Type · Enter / F2 |
Overwrite · edit (or open a select) |
Enter ↓ · Tab → |
Commit & move |
⌘+Enter · ⌘+D · ⌘+⌫ |
Add row · duplicate row · delete row |
⌘+C / X / V · ⌘+Z / ⇧Z |
Copy / cut / paste cell · undo / redo |
Options #
EditableTable(
columns: columns,
showToolbar: true, // validity badge, clipboard hint, shortcuts, undo/redo
showRowNumbers: true, // A1-style gutter
showActions: true, // per-row insert-below / delete
showTotals: true, // footer summing includeInTotal columns
totalsLabel: 'Total',
unitLabel: 'SAR',
confirmDelete: true, // popup before deleting (set false = instant)
growOnTab: true, // Tab on the last cell adds a new row
showShortcutsHelp: true, // the ⌨ reference button
);
Driving it from code — EditableTableController #
final c = EditableTableController(columns: columns, rows: seed);
c.addRow();
c.duplicateSelectedRow();
c.sortByColumn(1);
c.undo();
final picked = c.checkedLeafIds; // Tree analogue; here use c.rows
EditableTable(controller: c); // observe / share it
// from inside a custom cell / page:
EditableTableController.of(context)?.addRow();
2 · ReadableTable #
A read-only, generic, MVC display grid that shares the EditableTable look (it reuses EditableTableThemeData — identical header, hairline grid, surfaces, type ramp). Where EditableTable edits strings, ReadableTable<T> displays strongly-typed row values — and adds the read-only interaction layer a display grid needs: selection, keyboard navigation and click-to-sort. It's generic over the row value type T: each row is one T, and every ReadableColumn<T> renders itself from that value via cell, so row code reads value.field with no casting.
Quick start #
ReadableTable<Account>(
selectionMode: ReadableSelectionMode.multiRow,
columns: [
ReadableColumn('Code', width: 90, sortable: true,
sortKey: (a) => a.code, cell: (ctx, a) => Text(a.code)),
ReadableColumn('Account', flex: 2, sortable: true,
sortKey: (a) => a.name, cell: (ctx, a) => Text(a.name)),
ReadableColumn('Balance', align: ReadableAlign.end, sortable: true,
sortKey: (a) => a.balance, cell: (ctx, a) => Text(a.fmt)),
],
rows: accounts, // List<Account>
onRowSelectionChanged: (rows) => debugPrint('$rows'), // List<Account>
);
Columns size by width: (fixed px) or flex: (proportional, filling the row). Cells are arbitrary widgets — status pills, two-line bilingual text, progress bars — placed and aligned for you, no horizontal scroll. Provide columns + rows (the widget owns a controller), or pass a controller: to drive/observe it externally.
Just a grid of pre-built widgets? Use
ReadableTable<List<Widget>>and have each column returnvalue[i]. That's exactly how the desktopGLTablewrapper keeps itsList<List<Widget>>API.
Selection — five modes #
Set selectionMode: to one of ReadableSelectionMode.{none, singleRow, multiRow, singleCell, multiCell} (default none = display only). Pointer: click selects; Ctrl/⌘-click toggles; Shift-click extends a range (linear for rows, rectangular for cells). onRowSelectionChanged reports the selected values (List<T>); onCellSelectionChanged gives a Set<ReadableCell>.
Column sort #
Mark a column sortable: true and click its header to cycle asc → desc (an arrow marks the active column). Supply sortKey: (value) => Comparable per column — numbers sort numerically, strings alphabetically. Sorting reorders the rows and remaps the selection / cursor so they follow their rows. initialSortColumn / initialSortAscending / onSortChanged round it out.
Keyboard #
Focus the table (click it), then — press ? (or ⌘/Ctrl + /) for the in-widget cheatsheet:
↑ ↓ · ← → |
Move active row · move active cell (cell modes) |
Space |
Toggle (multi) / select the active row or cell |
⇧ + ↑↓←→ |
Extend the selection (multi modes) |
⌘/Ctrl + A |
Select all rows / cells |
Enter |
Activate the active row (onRowTap → value + index) |
Home / End · ⌘+Home/End |
Row edges · grid corners |
Esc |
Clear the selection |
Driving it — ReadableTableController<T> #
The controller is the single source of truth; the view is a thin render of it. It exposes intention-revealing operations — select / add / delete / replace rows by index · value · where · firstWhere — and is published to descendants via a scope (ReadableTableController.of<T>(context)).
final c = ReadableTableController<Account>(
columns: columns, rows: accounts,
selectionMode: ReadableSelectionMode.multiRow,
);
ReadableTable<Account>(controller: c); // observe / share it
| Group | Operations |
|---|---|
| Select rows | selectRowAt(index, {additive, range}) · selectRowByValue(value, {additive}) · selectRowsWhere(test, {additive}) · selectAllRows() · clearSelection() |
| Add rows | insertRowAt(index, value) · addRowWhere(test, value, {after, firstOnly}) · addRow(value) (end) |
| Delete rows | deleteRowAt(index) · deleteRowsWhere(test) · deleteRowByValue(value) · deleteSelectedRows() |
| Replace row | replaceRowAt(index, value) · replaceRowByValue(old, new) · replaceRowsWhere(test, update) · replaceFirstWhere(test, update) |
| Cells / sort / data | selectCellAt(r, c, …) · selectAllCells() · sortByColumn(ci) · clearSort() · setRows(values) |
c.selectRowsWhere((a) => a.type == 'Expense'); // select … where
c.addRowWhere((a) => a.type == 'Asset', newAccount); // add … where (after match)
c.deleteRowByValue(oldAccount); // delete … by value
c.replaceFirstWhere((a) => a.type == 'Asset', // replace … firstWhere
(a) => a.copyWith(balance: a.balance * 1.1));
// from inside a custom cell / page:
ReadableTableController.of<Account>(context)?.deleteSelectedRows();
Options #
ReadableTable<T>(
controller: c, // or columns + rows
selectionMode: ReadableSelectionMode.none,
showHeader: true,
zebra: false, // faint fill on odd rows
hoverHighlight: true, // tint the row under the pointer
rowMinHeight: 52,
cellPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
onRowTap: (value, index) {},
emptyState: const Text('No rows'),
);
Defaults reproduce a plain, non-interactive ledger (selectionMode: none, no keyboard) — so adopting ReadableTable for display-only tables changes nothing until you opt into a mode.
3 · Tree #
A customisable generic hierarchical tree / outline — file explorers, category outlines, layer panels, a chart of accounts. Every node is TreeNode<T> carrying a strongly-typed value, so row code reads node.value with no casting. Indent guide-lines, disclosure twisties, inline rename, search-with-highlight, tri-state checkboxes, full keyboard nav, a context menu, and undo/redo.
Quick start #
// Untyped (T = dynamic) — the simplest form:
Tree(
roots: const [
TreeNode(id: 'src', label: 'src', folder: true, children: [
TreeNode(id: 'main', label: 'main.dart'),
TreeNode(id: 'ui', label: 'ui', folder: true, children: [
TreeNode(id: 'button', label: 'button.dart', badge: 'edited'),
]),
]),
TreeNode(id: 'readme', label: 'README.md'),
],
initiallyExpanded: const {'src', 'ui'},
onSelected: (node) => debugPrint('opened ${node.id}'),
);
Typed nodes — Tree<T> #
// Each node carries a typed Account; node.value is an Account, no casts.
final roots = <TreeNode<Account>>[
TreeNode(id: '1000', label: 'Assets',
value: Account(code: '1000', type: 'Asset'),
children: [ /* … */ ]),
];
Tree<Account>(
roots: roots,
trailingBuilder: (ctx, row) => Text(row.node.value!.nature), // DR / CR
onActivated: (n) => openLedger(n.value!),
);
See example/lib/tree_demo.dart + account_tree_data.dart for a full typed
chart-of-accounts, and docs/components-tree.html for an interactive gallery
with three value types (Account, FileMeta, Person).
Options & hooks #
Tree(
roots: roots,
showToolbar: true, // search + expand/collapse all + undo/redo
showSearch: true,
showCheckboxes: false, // tri-state checks; onCheckedChanged gives leaf ids
showFooter: true,
showGuides: true, // the │ ├ └ indent guides
dense: false,
editable: true, // inline rename (F2 / double-click) + structural edits
iconBuilder: (row) => row.node.isFolder ? Icons.folder : Icons.description,
trailingBuilder: (context, row) => null, // inject host widgets per row
labelBuilder: (context, row) => null, // fully replace the label cell
contextActions: (node) => [ // extra right-click items
TreeAction(label: 'Open', icon: Icons.open_in_new, onSelected: (c, n) {}),
],
onSelected: (n) {},
onActivated: (n) {}, // double-click / Enter on a leaf
onCheckedChanged: (ids) {},
onChanged: (roots) {}, // structural change
);
Driving it — TreeController #
final t = TreeController(roots: roots, expanded: {'src'}, selected: 'main');
t.addChild('src', label: 'new.dart');
t.expandAll();
t.beginEdit('main'); // inline rename
t.undo();
// from inside row content:
TreeController.of(context)?.addChild(parentId);
Keyboard (focus the tree body): ↑ ↓ move · → ← expand/step-in / collapse/step-out · Home/End jump · Enter toggle a group / activate a leaf · Space check · F2 rename · Delete remove · / (or ⌘F) focus search · Esc clear search · * / \ expand / collapse all · ⌘Z undo (⇧ redo) · ? shortcuts cheatsheet.
4 · BrowserStyleTabBar #
A browser-style workspace tab strip — pinned / closable / dirty tabs, drag-to-reorder, a context menu, an overflow dropdown, a dirty-close confirm dialog, and live mini-page previews (the page's real captured frame on hover). Renders only the strip and drives the active-screen body.
Quick start #
// self-contained — owns a controller seeded with the default set:
const BrowserStyleTabBar();
// seed your own tabs:
BrowserStyleTabBar(tabsState: [
BrowserTab(id: 1, title: 'Chart of Accounts', kind: GLTabKind.ledger, pinned: true),
BrowserTab(id: 2, title: 'Journal Entry', kind: GLTabKind.doc, dirty: true),
BrowserTab(id: 3, title: 'Dashboard', kind: GLTabKind.chart),
]);
// external controller + custom page content:
BrowserStyleTabBar(
controller: myController,
pageBuilder: (ctx, tab) => MyPage(tab),
);
Driving it — BrowserStyleTabBarController #
final tabs = BrowserStyleTabBarController(tabs: [...], activeId: 2);
tabs.add(title: 'New report', kind: GLTabKind.chart);
tabs.setDirty(myId, true);
tabs.select(otherId);
// any page can drive the strip:
BrowserStyleTabBarController.of(context)?.add(title: 'Detail', kind: GLTabKind.doc);
Ops: select · add · close · closeOthers · closeToRight · duplicate · togglePin · reorder · setDirty · rename · mutate. of(context) returns null outside a tab bar, so pages stay reusable stand-alone.
5 · NavigationSidebar #
A themeable, responsive app navigation sidebar — the GeniusLink web nav ported whole. One data model (titled sections of a node tree) renders in three modes that the host picks from the available width: a full expanded labelled tree with │ ├ └ connectors, an icon-only rail whose modules open a grouped hover flyout, and an off-canvas drawer with a scrim for small screens. Active-screen highlight, auto-expanding ancestors, badges (count / status), two-key shortcut hints, optional header/footer slots — all theme-aware and RTL-mirrored.
Quick start #
// self-contained — owns a controller seeded with your sections:
NavigationSidebar<String>(
sections: mySections, // List<NavSection<String>>
active: 'accounts',
mode: NavSidebarMode.expanded,
onNavigate: (node) => openScreen(node.value!),
);
Each node is a NavNode<T> carrying a typed value; compose a List<NavSection<T>> to describe the whole sidebar. A node's role is derived from its position — a depth-0 leaf is a flat direct destination, a depth-0 branch is a collapsible module, a nested branch is a group header, and a nested leaf is an item (boxed icon):
final sections = <NavSection<String>>[
NavSection(title: 'Overview', items: [
NavNode(id: 'dashboard', label: 'Dashboard', icon: Icons.dashboard_outlined,
value: 'dashboard', shortcut: ['g', 'd']),
]),
NavSection(title: 'Finance', items: [
NavNode(id: 'accountsHub', label: 'Accounts', icon: Icons.menu_book_outlined, children: [
NavNode(id: 'coa', label: 'Chart of Accounts', children: [
NavNode(id: 'accounts', label: 'Chart of Accounts', icon: Icons.menu_book_outlined, value: 'accounts'),
NavNode(id: 'accountTree', label: 'Account Tree', icon: Icons.account_tree_outlined, value: 'accountTree',
badge: NavBadge('3'), shortcut: ['g', 't']),
]),
]),
]),
];
Responsive — three modes #
The view doesn't guess the layout; the host derives the mode from the available width (a LayoutBuilder + NavSidebarBreakpoints does it in a line) and the same controller drives all three:
LayoutBuilder(builder: (context, c) {
final mode = const NavSidebarBreakpoints().modeFor(c.maxWidth); // expanded ≥1200 · rail ≥768 · else drawer
if (mode == NavSidebarMode.drawer) {
return Stack(children: [
page,
Positioned.fill(child: NavigationSidebar<String>(controller: nav, mode: NavSidebarMode.drawer)),
]); // open via nav.openDrawer(); a tap on a destination navigates *and* dismisses
}
return Row(children: [
NavigationSidebar<String>(controller: nav, mode: mode), // expanded or rail
Expanded(child: page),
]);
});
Options & slots #
NavigationSidebar<T>(
controller: nav, // or sections + active
mode: NavSidebarMode.expanded, // expanded · rail · drawer
showGuides: true, // the │ ├ └ connector lines
railFlyouts: true, // module hover flyouts in the rail
drawerTitle: 'Navigation',
header: (ctx, collapsed) => Brand(collapsed: collapsed), // logo slot
footer: (ctx, collapsed) => HelpCard(collapsed: collapsed),
onNavigate: (node) {},
);
Driving it — NavigationSidebarController #
final nav = NavigationSidebarController<String>(
sections: sections, active: 'accounts',
);
nav.navigate('settingsHub'); // sets active + auto-opens ancestors + closes the drawer
nav.toggleCollapsed(); // expanded ⇄ rail
nav.openDrawer(); // mobile
nav.expandAll();
// from inside page content:
NavigationSidebarController.of<String>(context)?.navigate('dashboard');
Badges carry a tone — NavBadge('Live', tone: NavBadgeTone.success) — drawn as a pill on the row (a dot on a collapsed module / rail icon). See example/lib/navigation_sidebar_demo.dart for the full shell (app bar + sidebar + faux page) with live Light/Dark, LTR/RTL and a device-width simulator.
Theming #
Every component is self-contained: all of its surfaces live in one ThemeExtension with .light / .dark presets (lerped on theme change). Instance fields are the swappable surfaces (bg / surface / hover / border / fg1..fg4); static consts are the brand constants (accent + semantic palette, font families Manrope / Inter / JetBrainsMono, radii, shadows, motion).
ThemeData(extensions: const [EditableTableThemeData.light]);
final t = EditableTableThemeData.of(context); // falls back to .dark
// tweak a preset:
EditableTableThemeData.light.copyWith(surface: const Color(0xFFFBFBFD));
Brand tokens: blue #4A7CFF · green #1DB88A · orange #F97316 · 4px radii · 40px controls.
RTL & internationalisation #
Wrap any component in Directionality(textDirection: TextDirection.rtl, …) — strips, guides, gutters, and menus all mirror. The example's ERP Console flips EN ⇄ AR (LTR ⇄ RTL) live.
Architecture #
lib/
├── geniuslink_design_system.dart unified barrel (exports the 5 below)
├── geniuslink_browser_tabs.dart · BrowserStyleTabBar barrel
├── geniuslink_editable_table.dart · EditableTable barrel
├── geniuslink_readable_table.dart · ReadableTable barrel (shares the editable theme)
├── geniuslink_tree.dart · Tree barrel
├── geniuslink_navigation_sidebar.dart · NavigationSidebar barrel
└── design_system/components/
├── data/
│ ├── editable_table_models.dart Model — columns, cell ref, formatters
│ ├── editable_table_columns.dart Model — typed column subclasses
│ ├── editable_table_controller.dart Controller — ChangeNotifier + scope
│ ├── editable_table_theme.dart Theme — ThemeExtension (Editable + Readable)
│ ├── editable_table.dart View — EditableTable widget
│ ├── readable_table_models.dart Model — ReadableColumn<T>, cell, enums
│ ├── readable_table_controller.dart Controller — ChangeNotifier + scope
│ ├── readable_table.dart View — ReadableTable<T> (generic · MVC)
│ └── tree_*.dart Tree — model · controller · theme · view
└── navigation/
├── browser_style_tab_bar*.dart BrowserStyleTabBar — MVC + overlays + pages
└── navigation_sidebar_*.dart NavigationSidebar — model · controller · theme · view
Each component follows Model → Controller → View → Theme: immutable data, a ChangeNotifier as the single source of truth, a thin view that forwards every gesture/keystroke, and a ThemeExtension. Controllers are exposed to descendant page content via an InheritedNotifier scope, so any child can drive the component.
License #
MIT © GeniusLink.