bcell 1.1.0
bcell: ^1.1.0 copied to clipboard
A Flutter data-grid widget with a pluto_grid-shaped API and spreadsheet features: a formula engine, multi-sheet workbooks, charts, pivot tables, and CSV/XLSX/PDF import-export.
bcell #
A data grid for Flutter with a pluto_grid-shaped API that has grown into a spreadsheet-capable widget: sortable and resizable columns, tap-to-edit cells and responsive auto-sizing, a formula engine, multi-sheet workbooks, charts and pivot tables, and a broad import/export surface (CSV/TSV/HTML/XML/Markdown/JSON/PDF/XLSX). Styling resolves from your app's ThemeData, so light mode, dark mode, and high-contrast themes work with zero configuration.
▶ Live demo — interactive Widgetbook playground — every feature below, with knobs, theme switching, and device viewports.
Features #
- Sorting — tap a header to cycle ascending → descending → original order, or
stateManager.sortByColumns([...])for multi-column priority sort with tie-breaking. - Aggregations —
stateManager.sum/average/min/max/count('field')over the filtered rows (spreadsheet-footer summaries). - Charts —
BCellChart(stateManager, type: BCellChartType.line/.bar/.pie, categoryField: 'month', valueFields: ['sales', 'cost'])— a companion widget (same calling shape asBCellPagination) that plots the grid's filtered rows and re-draws live on edit/filter/sort; formula cells chart their computed value. Backed byfl_chart; drop to it directly for stacked/area/scatter and other advanced kinds. - Pivot tables —
stateManager.pivot(rowField: 'region', columnField: 'quarter', valueField: 'sales', aggregate: BCellPivotAgg.sum)groups the filtered rows into a rows × columns matrix of aggregated values (sum/count/average/min/max) withvalue(r, c),rowTotal/columnTotal/grandTotal. Values read through the formula engine; totals re-aggregate the raw numbers (so anaveragetotal is a true mean, not a mean of means)..toGrid()renders the result straight intoBCellGrid(columns:, rows:)columns+rows (row-field header + a column per key + optional totals row/column). Single row/column field, keys in first-appearance order — no multi-level nesting or slicers. - Column resizing — drag a header's right edge. Modes:
normal,pushAndPull(total width stays constant, cascades across columns), ornone. - Inline editing — tap a cell to select, tap again to edit. Number columns validate input and keep the old value when parsing fails.
- Column types —
BCellColumnType.text(),.number()(right-aligned, numeric sort/coerce; optional Excel-styleformat:code —#,##0.00,0%,$#,##0.00,000— applies display grouping/decimals/percent/prefix while cells stay raw numbers so sort/export are unaffected), and.date()(holds aDateTime; displaysyyyy-MM-ddby default or a customformat:callback; editing opens a native date picker; import/paste coerce ISO strings and Excel date serials intoDateTimeviaBCellColumnTypeDate.tryParse/fromExcelSerial). - Custom cell renderer —
BCellColumn(renderer: (ctx) => Widget)draws any widget in place of the default cell text (badges, icons, colored text);ctxcarries the column, row, cell, and formatted value. Editing still uses the inline editor. - Conditional formatting —
BCellColumn(cellStyle: (ctx) => BCellStyle?)colors a cell (backgroundColor/textStyle) by its value, e.g. red text for negatives. Lighter thanrendererwhen you only need color; selection/range highlight still paints on top. - Conditional-formatting visuals —
BCellConditionalFormat.colorScale/dataBar/iconSet(stateManager).toRenderer()produces arendererfor a numeric column: a two/three-stop color-scale background, a proportional data bar behind the value, or a threshold-bucketed leading icon (default up/flat/down arrows). Min/max are read from the column's current displayed rows; non-numeric/null cells render plainly. Colors are overridable and default to theme-friendly values. - Banded rows (zebra striping) —
BCellGridStyleConfig(oddRowColor: ...)tints odd-indexed rows for readability; null (default) disables it and every row usesrowColor. Display-only and virtualization-safe (oneisOddcheck per built row), so the 10k-row scroll is unaffected. - Padding, icon & fill-handle styling —
BCellGridStyleConfigalso exposesdefaultColumnTitlePadding/defaultCellPadding(header/cell insets),iconSize/iconColor(the header sort icon; a null color inherits the ambientIconTheme), andfillHandleSize/fillHandleColor/fillHandleBorderColor(the drag-fill square on the focused cell; color defaults toactivatedColor, its outline tocolorScheme.surface). The first four match pluto_grid's field names; defaults reproduce the previous look. - Named ranges —
stateManager.defineName('SALES', 'A2:A10')maps a name to an A1 ref or range so formulas read=SUM(SALES)or=PRICE*QTYinstead of raw addresses. Names are case-insensitive and resolved by rewriting the formula to A1 before evaluation, so display, sort, and export all honour them;removeName/clearNames/definedNames/namedRangeround it out. Zero-cost when unused (the formula path skips substitution until the first name is defined). - Structured references —
stateManager.enableStructuredRefs()lets a formula name a column with[Title](orTable[Title]— the table-name prefix is decorative, the grid holds one table) and get its whole data range:=SUM([Sales])instead of=SUM(C1:C40). The bracketed title is case-insensitive and resolves live to the column's current-displayed-rows range, so filtering/paging and row add/remove stay correct. Composes with named ranges (both work at once). Whole-column only — no[@Col]this-row refs (they'd need the calling cell's row, which the context-free evaluator doesn't carry). - Hyperlink cells —
BCellColumn(renderer: BCellHyperlink.column(onTap: (url) {...}).toRenderer())renders a URL cell as styled, tappable link text (theme primary + underline,Semantics(link: true), 44px min tap target). Optionalurl/labelextractors split display text from the address (e.g. a"Label|https://…"value). Null/empty cells render plain with no tap. No dependency — you wireurl_launcher(or anything) inonTap. - Rich text in cells — set a cell value to a
BCellRichText([BCellTextRun('bold', bold: true), BCellTextRun(' plain')])and give the columnrenderer: BCellRichText.column().toRenderer()(same shape as the hyperlink / conditional-format helpers). Runs carry bold/italic/underline/color and render on one line viaText.rich. Exports flatten to plain text automatically (toString), so CSV/PDF/etc. emit the flat value with no markup leak; a plain string in the same column still renders normally. One-line inline runs only — no rich editor. - Per-row height —
BCellRow(height: 90)overrides that row's height; null rows use the grid default. Virtualization is preserved (itemExtentBuilder), so the 10k-row scroll stays O(1). - Freeze columns —
BCellColumn(frozen: BCellColumnFrozen.start)/.endpins a column to the left or right in a non-scrolling section while the middle scrolls horizontally; all sections stay virtualized and scroll vertically together. Frozen columns are still visible columns for export and keyboard nav. - Merge cells —
BCellValue(value: ..., colspan: 2, rowspan: 2)merges a cell across N columns and/or rows (combined width/height; covered cells draw nothing). Display-only: the data model keeps every cell, so selection, export, and nav are unaffected. Colspan stays on the virtualized path; using rowspan switches to a non-virtualized body (fine for small merged tables, not 10k rows). - Drag-fill — a fill handle on the selected cell's bottom-right corner; drag it down to fill that value down the column (uses
fillSelectionunder the hood). - Series / auto fill —
stateManager.fillSeriesDown()reads the leading seed cells of a selection and extends the pattern into the empty cells below instead of copying: numeric seeds step arithmetically (1, 3 → 5, 7, 9; a lone number steps+1), date columns step by the seed's day-difference (a lone date+1 day), and non-numeric seeds fall back to copying. Every value is coerced to the column type; read-only cells are skipped. - Flash fill —
stateManager.flashFillDown()infers a transform from a column's seed cell(s) against the row's other columns and fills the rest: e.g. a "Full name" column with a seedJohnbeside it detects "the text before the first space" and fills each row's first name; also splits an email at@, copies a column, etc. Multiple seed rows disambiguate; if no single transform explains the seeds it's a no-op. (Copy + delimiter-split only — no regex / template inference.) - Responsive auto-sizing —
BCellAutoSizeMode.equalor.scalefits columns to any viewport (rotation, window resize, split view). Pin a column withsuppressedAutoSize: true. Falls back to horizontal scrolling belowminWidth. - Filtering —
stateManager.setFilter((row) => ...); source rows are untouched, clearing restores everything. - Paging —
stateManager.setPageSize(20)plus the ready-madeBCellPagination(stateManager)pager widget. - Column hiding —
stateManager.hideColumn(column, true); hidden columns are skipped by the grid, CSV, and navigation. - Row operations —
insertRows/prependRows/appendRows/removeRows/removeCurrentRow, withcurrentRow/currentRowIdxgetters. - Keyboard navigation — arrows/Tab move the selection, Enter/F2 edits, Escape cancels an edit without committing.
- CSV export —
stateManager.toCsv()(visible columns, filtered rows, all pages), with opt-in formula-injection sanitizing for untrusted data. - PDF export —
await stateManager.toPdf(title: 'People')returns PDF bytes (visible columns, filtered rows) ready forprinting, share, or file write. PassselectionOnly: truefor a print area — only the selected range's columns and rows (viastateManager.selectionRect); falls back to the whole grid when nothing is selected. - JSON export —
stateManager.toJson()returnsList<Map<String, dynamic>>(visible columns, filtered rows), ready forjsonEncode. - TSV / HTML export —
stateManager.toTsv()(tab-separated, clipboard-friendly, with the same opt-insanitizeFormulasguard as CSV) andtoHtml()(a<table>; every value HTML-escaped against XSS). - TSV / CSV / HTML / Markdown import —
stateManager.importTsv(text, hasHeader: ...)/importCsv(...)/importHtml(...)/importMarkdown(...)parse a delimited,<table>, or| pipe |block and append rows, mapping columns left-to-right onto visible columns and coercing number columns (the inverse oftoTsv/toCsv/toHtml/toMarkdown; CSV uses an RFC 4180 quote-aware parser, HTML unescapes entities, Markdown drops the---separator and unescapes\|). - JSON import —
stateManager.importJson(list)(inverse oftoJson) appendsList<Map<String, dynamic>>rows, mapping each map onto the visible columns by field name and coercing values to each column type. - XML import —
stateManager.importXml(text)(inverse oftoXml) parses<row>elements and maps their<field>children onto the visible columns by tag name (self-describing, like JSON), XML-unescaping values and coercing to each column type. - XML export —
stateManager.toXml()(one<row>per row,<field>elements; every value XML-escaped against injection). - Markdown export —
stateManager.toMarkdown()(GitHub-flavored| col |table; pipes and newlines in values sanitized). - XLSX import/export —
stateManager.toXlsx()returns.xlsxbytes (visible columns header + filtered rows; number columns write numeric cells, date columns write native date cells, formulas export their evaluated result) andimportXlsx(bytes, {hasHeader = true})reads the first sheet back onto the visible columns, coercing numbers/dates. Backed by theexcelpackage. - Undo/redo —
stateManager.undo()/redo()withcanUndo/canRedofor cell edits; a fresh edit clears the redo stack. - Find & Go To —
stateManager.find('query')returns matching cells;goToCell(rowIdx, field)/goToRow(rowIdx)jump the selection to a cell. - Cell notes —
setNote/noteFor/hasNote/setCurrentCellNote/clearNotesattach a text comment to any cell. - Formulas — a cell whose value is
=...is a formula:=A1+B2*3,=SUM(A1:B10),=IF(A1>0, A1, 0),="n=" & B1. Supports arithmetic+ - * / ( ), comparisons,&concat and string literals, A1 references +A1:B2ranges (columns = visible columns, rows 1-based), and functions SUM/AVERAGE/MIN/MAX/MEDIAN/COUNT/COUNTA/SUMIF/COUNTIF, ROUND/ROUNDUP/ROUNDDOWN/TRUNC/CEILING/FLOOR/ABS/INT/SIGN/MOD/POWER/SQRT/PI, IF/AND/OR/NOT/IFERROR, UPPER/LOWER/LEFT/RIGHT/MID/LEN/TRIM/CONCAT/SUBSTITUTE/REPT/EXACT (SUMIF/COUNTIF take a value or an operator criterion like">5"). The library also ships Date & Time (TODAY/NOW/DATE/YEAR/MONTH/DAY/HOUR/MINUTE/SECOND/WEEKDAY/DATEDIF/EDATE/EOMONTH), Statistical (VAR/STDEV/VARP/STDEVP/MODE/LARGE/SMALL/PERCENTILE/QUARTILE/RANK), Financial (PMT/FV/PV/NPV/IRR), Lookup (MATCH/INDEX/VLOOKUP/HLOOKUP), Information (ISBLANK/ISNUMBER/ISTEXT/ISERROR/ISNA/NA/N/TYPE/IFNA), Logical (XOR/IFS/SWITCH), Math/Trig (SIN/COS/TAN/EXP/LN/LOG10/LOG/GCD/LCM/SUMPRODUCT), multi-criteria aggregates (SUMIFS/COUNTIFS/AVERAGEIFS/AVERAGEIF/COUNTBLANK), and more text (FIND/SEARCH/REPLACE/VALUE/PROPER/TEXT/TEXTJOIN/CHOOSE). The cell shows the evaluated result (raw formula stays editable); sort and every export use the evaluated value too; a bad formula shows#DIV/0!/#NAME?/#CIRC!/#NUM!. Evaluated results are memoized per notification (keyed by the raw formula string) and the cache clears on any edit/sort/filter/row change, so paint, sort, and export don't re-walk the same formula repeatedly. Spreadsheet-pivot feature — no dependency graph / array formulas / incremental recalc yet. - Multi-sheet workbook —
BCellWorkbookholds an ordered list of namedBCellSheets (each = title + columns + rows) with one active sheet andadd/remove/rename/select/move/setTabColor/setProtected/setHiddenops.BCellSheetTabBar(workbook: ...)renders the tab strip (theme-resolved style, active tab highlighted, tap switches; long-press-drag a tab onto another to reorder; a per-sheettabColorshows as an Excel-style accent underline; ahiddensheet gets no tab) andBCellWorkbookView(workbook: ...)shows the active sheet as aBCellGrid; aprotectedsheet is shown read-only, andsetProtected(i, true, password: ...)locks the toggle behind an Excel-style optional password (unprotecting needs the matching password; only a SHA-256 hash is stored and persisted, never the plaintext). Hiding keeps at least one visible sheet and never leaves the active sheet hidden. A thin wrapper overBCellGrid— no grid internals.BCellWorkbook.toJson()/BCellWorkbook.fromJson(map)persist the whole workbook (sheets, tab colors, protected/hidden flags, column definitions incl. number-format codes, row data) to ajsonEncode-ready map and back; function-based column config (validators/renderers) and cell merges don't round-trip;loadJson(map)loads one in place (listeners survive). Version history:saveVersion({name})snapshots the workbook (in-memory, built ontoJson),versionslists them,restoreVersion(i)rolls the workbook back in place,removeVersion/clearVersionsmanage the list. - Encrypted workbook file —
workbook.toEncryptedBytes(password)seals thetoJson()snapshot into an authenticated AES-256-GCM blob (key from PBKDF2-HMAC-SHA256 with a random per-file salt and a 100k-iteration default; fresh random nonce per call, so the same workbook never encrypts to the same bytes).decryptWorkbook(bytes, password)opens it into a new workbook andloadEncryptedBytes(bytes, password)loads it in place; a wrong password or a tampered byte throwsBCellDecryptException— never silent garbage. The blob is self-describing (version, salt, iteration count, nonce ride along), so old files keep opening if the defaults change. Whole-file encryption only; whattoJson()doesn't persist isn't protected either. - Shared-workbook merge —
workbook.mergeChanges(base: baseJson, theirs: theirJson)folds another copy's edits into this workbook (Excel's "Compare and Merge Workbooks"): a three-way, cell-level merge overtoJson()snapshots. A cell only the other copy changed lands here; a cell both copies changed differently keeps the local value and is returned as aBCellMergeConflict(sheet, row, field, base/mine/theirs values) so the host can offer "take theirs". Rows the other copy appended and sheets it added come along. Rows align by index and sheets by name (mid-sheet inserts/deletes or renames in either copy can mis-merge below them); deletions and sheet metadata don't propagate; snapshot transport between users is the host's job. - Read-only grid —
BCellGrid(mode: BCellGridMode.readOnly)(orstateManager.setGridMode(BCellGridMode.readOnly), mirroring pluto_grid'smode) blocks every edit — typing, paste, fill, drag-fill — plus structural row changes (insert/remove/import), so a protected sheet can't be mutated. Selection, sort, filter, and export still work. Column-levelBCellColumn(readOnly: true)still applies on top innormalmode. Drives workbook sheet protection. - Shared font — pass a base
font:(aTextStyle) to anyBCellGrid/BCellPagination/BCellSheetTabBarfor a per-widget font, or drive them all from one font bus: create aBCellFontController, and either wrap a subtree inBCellFontScope(controller: ...)or hand the controller to widgets viafontController:. Callingcontroller.setFont(...)re-fonts every subscribed BCell widget live — one place to theme all BCell text. Precedence: per-widgetfont→ controller → scope →ThemeData; the existingcolumnTextStyle/cellTextStylestill override per field. - Data validation — per-column
validator: (value) => errorOrNull; a rejected edit keeps the old value (enforced centrally, so it also guards paste).changeCellValue(..., onInvalid: ...)surfaces the error. - Range selection — Shift+click / Shift+Arrow selects a cell rectangle;
extendSelectionTo(cell),isInRange(cell),selectedCells,rangeAsTsv(). In-range cells highlight.fillSelection(value)writes one value across the range,fillDown()(Ctrl+D) /fillRight()(Ctrl+R) copy the top-row / left-column value across the range, andclearSelection()(Delete/Backspace) blanks it — all skip read-only columns. - Clipboard —
copySelectionToClipboard()copies the selection (a range as a TSV block, else the focus cell),cutSelectionToClipboard()copies then clears the cell, andpasteFromClipboard()pastes at the current cell — a TSV block spills down and right, clipped to the grid (number columns coerce like the editor). - Row selection —
setRowChecked/toggleRowChecked/setAllRowsCheckedwithcheckedRows/isAllChecked/isAnyCheckedfor checkbox-style multi-row selection. - Performance — virtualized rows (
ListView.builder+itemExtentBuilder, so per-row heights stay O(1)), O(1) cell position lookup, selection changes rebuild only the affected cells. A 10k-row regression test guards this.
Requirements #
- Flutter SDK with Dart
^3.8.1 - Runtime dependencies (resolved automatically by
pub):pdf(toPdf),excel(toXlsx/importXlsx),fl_chart(BCellChart), andcrypto+pointycastle(sheet-protection password hash and encrypted-workbook files)
How to run this project #
Clone, then from the repo root:
flutter pub get # resolve dependencies
flutter test # run the test suite
flutter analyze # lint (strict analyzer modes are enabled)
Run the example app #
cd example
flutter pub get
flutter run # pick any connected device
Run the Widgetbook playground #
Interactive playground with knobs (row count, row height, column width, read-only, auto-size mode, resize mode), theme switching (auto / light / dark / high-contrast), and device viewports (phone → tablet):
cd widgetbook
flutter pub get
flutter run -d chrome
Usage #
import 'package:bcell/bcell.dart';
BCellGrid(
columns: [
BCellColumn(title: 'Id', field: 'id', type: BCellColumnType.number(), width: 80),
BCellColumn(title: 'Name', field: 'name', type: BCellColumnType.text()),
],
rows: [
BCellRow(cells: {
'id': BCellValue(value: 1),
'name': BCellValue(value: 'Ada Lovelace'),
}),
],
onLoaded: (event) => stateManager = event.stateManager,
onChanged: (event) => print('${event.oldValue} -> ${event.value}'),
configuration: const BCellGridConfiguration(
columnSize: BCellGridColumnSizeConfig(
autoSizeMode: BCellAutoSizeMode.scale, // responsive: fill the viewport
resizeMode: BCellResizeMode.pushAndPull, // resizing keeps total width
),
),
)
After the grid mounts, columns and rows are owned by the state manager (same contract as pluto_grid): change data through it — appendRows, removeRows, changeCellValue, toCsv() — obtained from onLoaded.
- Event bus — beyond the four construction-time callbacks (
onLoaded/onChanged/onSelected/onSorted),stateManager.eventManageris a single broadcast stream of everyBCellGridEvent, so a consumer can subscribe/unsubscribe at runtime from one place (mirrors pluto_grid'seventManager):final sub = stateManager.eventManager.listener((e) { if (e is BCellGridOnChangedEvent) ... });thensub.cancel(). The four event classes all implementBCellGridEvent, so filter withis/whereType. Backed by the Dart stdlib broadcast stream (no rxdart); created lazily and disposed with the manager — grids that never subscribe pay nothing. The callbacks still fire; the bus is additive.
Additional information #
- The public API deliberately mirrors pluto_grid's calling structure with
Pluto*renamed toBCell*; implementation is written from scratch. - Performance notes and measurement guidance:
.claude/PERFORMANCE.md. Frame timings must be measured in profile mode on a real device — debug-mode numbers are not valid. - Issues and contributions are welcome on the repository tracker.