design_system 0.3.0: FitGrid paged grid
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
design_system tests / test (push) Has been cancelled

FitGrid lays items out in a grid sized to the box it is given and pages
the overflow sideways with dots, so a screen never scrolls vertically.
First consumer: tunedeck's home tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZT4baL56wRDmNioxj
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 19:21:20 +01:00
parent def48e346b
commit b3c9611af2
4 changed files with 380 additions and 1 deletions

View File

@ -14,6 +14,7 @@ library;
export 'src/confirm.dart';
export 'src/empty_state.dart';
export 'src/feedback.dart';
export 'src/fit_grid.dart';
export 'src/section_header.dart';
export 'src/status_colors.dart';
export 'src/theme.dart';

View File

@ -0,0 +1,181 @@
import 'dart:math' as math;
import 'package:design_system/src/tokens.dart';
import 'package:flutter/material.dart';
/// A grid of square cells that always fits its box, so it never scrolls
/// vertically.
///
/// Rows per page come from the height the parent gives it; the overflow moves
/// to further pages in a horizontal [PageView], marked by dots once there is
/// more than one. [itemBuilder] gets the global item index and must fill the
/// cell it is given: a square of up to column width plus [labelHeight], the
/// rows stretching to use up the leftover height.
class FitGrid extends StatefulWidget {
/// Creates the grid.
const FitGrid({
required this.itemCount,
required this.itemBuilder,
this.columns = 3,
this.labelHeight = 0,
this.minSquare = 1,
this.spacing = AppSpacing.sm,
this.padding = const EdgeInsets.symmetric(horizontal: AppSpacing.md),
super.key,
});
/// Number of items across all pages.
final int itemCount;
/// Builds item `index`, counted over all pages.
final IndexedWidgetBuilder itemBuilder;
/// Cells per row.
final int columns;
/// Height reserved under each square, for a caption.
final double labelHeight;
/// Least square side, as a fraction of the column width. Below 1 the
/// builder may be given a cell too short for a full-width square, and must
/// shrink its square to the cell height minus [labelHeight].
final double minSquare;
/// Gap between cells, both ways.
final double spacing;
/// Space around the cells.
final EdgeInsets padding;
@override
State<FitGrid> createState() => _FitGridState();
}
const double _dotSize = AppSpacing.sm;
const double _dotsHeight = AppSpacing.md + AppSpacing.xs;
class _FitGridState extends State<FitGrid> {
final _controller = PageController();
var _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
int _rowsFor(double height, double cellHeight) => math.max(
1,
((height + widget.spacing) / (cellHeight + widget.spacing)).floor(),
);
int _pagesFor(int perPage) =>
math.max(1, (widget.itemCount / perPage).ceil());
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, box) {
final w = widget;
final inner = box.maxWidth - w.padding.horizontal;
final cellW = (inner - w.spacing * (w.columns - 1)) / w.columns;
final height = box.maxHeight - w.padding.vertical;
final minCellH = cellW * w.minSquare + w.labelHeight;
var rows = _rowsFor(height, minCellH);
final pages = _pagesFor(rows * w.columns);
if (pages > 1) {
rows = _rowsFor(height - _dotsHeight, minCellH);
}
final perPage = rows * w.columns;
final count = _pagesFor(perPage);
final pageHeight = height - (count > 1 ? _dotsHeight : 0);
final cellH = math.max(
minCellH,
(pageHeight + w.spacing) / rows - w.spacing,
);
return Column(
children: [
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: count,
onPageChanged: (p) => setState(() => _page = p),
itemBuilder: (context, p) => Padding(
padding: w.padding,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topCenter,
child: _pageOf(
context,
p * perPage,
rows,
Size(cellW, cellH),
),
),
),
),
),
if (count > 1)
_Dots(count: count, current: math.min(_page, count - 1)),
],
);
},
);
Widget _pageOf(BuildContext context, int first, int rows, Size cell) {
final w = widget;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var r = 0; r < rows; r++) ...[
if (r > 0) SizedBox(height: w.spacing),
Row(
children: [
for (var c = 0; c < w.columns; c++) ...[
if (c > 0) SizedBox(width: w.spacing),
SizedBox.fromSize(
size: cell,
child: first + r * w.columns + c < w.itemCount
? w.itemBuilder(context, first + r * w.columns + c)
: null,
),
],
],
),
],
],
);
}
}
class _Dots extends StatelessWidget {
const _Dots({required this.count, required this.current});
final int count;
final int current;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ExcludeSemantics(
child: SizedBox(
height: _dotsHeight,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
Container(
key: ValueKey<String>('fit-grid-dot-$i'),
width: _dotSize,
height: _dotSize,
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == current ? scheme.primary : scheme.outlineVariant,
),
),
],
),
),
);
}
}

View File

@ -5,7 +5,7 @@ description: >-
helpers that were each reimplemented in three or more repos. Owns the tokens
-- consumers import them rather than transcribing the token table by hand.
publish_to: 'none'
version: 0.2.1
version: 0.3.0
environment:
sdk: ^3.12.2

View File

@ -0,0 +1,197 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
final _dot = find.byWidgetPredicate(
(w) => switch (w.key) {
ValueKey<String>(:final value) => value.startsWith('fit-grid-dot'),
_ => false,
},
);
Future<void> _pump(
WidgetTester tester, {
required int count,
Size size = const Size(300, 400),
int columns = 3,
double labelHeight = 20,
double minSquare = 1,
}) {
return tester.pumpWidget(
MaterialApp(
home: Center(
child: SizedBox.fromSize(
size: size,
child: FitGrid(
itemCount: count,
columns: columns,
labelHeight: labelHeight,
minSquare: minSquare,
itemBuilder: (context, i) =>
Container(key: ValueKey('cell-$i'), child: Text('item $i')),
),
),
),
),
);
}
int _onScreen(WidgetTester tester) => find
.textContaining('item ')
.evaluate()
.where((e) => find.byWidget(e.widget).hitTestable().evaluate().isNotEmpty)
.length;
void main() {
testWidgets('few items: one page, no dots, nothing overflows', (
tester,
) async {
await _pump(tester, count: 4);
expect(_onScreen(tester), 4);
expect(_dot, findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('rows come from the height: taller box fits more per page', (
tester,
) async {
await _pump(tester, count: 60, size: const Size(300, 300));
final short = _onScreen(tester);
await _pump(tester, count: 60, size: const Size(300, 600));
final tall = _onScreen(tester);
expect(short % 3, 0);
expect(tall, greaterThan(short));
});
testWidgets('leftover height goes into the cells: grid fills the box', (
tester,
) async {
await _pump(tester, count: 9);
expect(_onScreen(tester), 9);
final box = tester.getRect(find.byType(FitGrid));
final last = tester.getRect(find.byKey(const ValueKey('cell-8')));
expect(last.bottom, closeTo(box.bottom, 1));
final first = tester.getRect(find.byKey(const ValueKey('cell-0')));
expect(first.height, greaterThan(first.width + 20));
});
testWidgets('minSquare lets a row be shorter than column + label', (
tester,
) async {
await _pump(tester, count: 60, size: const Size(300, 400), labelHeight: 40);
final full = _onScreen(tester);
await _pump(
tester,
count: 60,
size: const Size(300, 400),
labelHeight: 40,
minSquare: 0.6,
);
expect(_onScreen(tester), greaterThan(full));
final cell = tester.getRect(find.byKey(const ValueKey('cell-0')));
final colW = cell.width;
expect(cell.height, greaterThanOrEqualTo(colW * 0.6 + 40 - 0.5));
expect(cell.height, lessThan(colW + 40));
});
testWidgets('paged grid fills the box above the dots', (tester) async {
await _pump(tester, count: 40);
final perPage = _onScreen(tester);
final box = tester.getRect(find.byType(FitGrid));
final last = tester.getRect(find.byKey(ValueKey('cell-${perPage - 1}')));
expect(last.bottom, closeTo(box.bottom - 20, 1));
final dot = tester.getRect(_dot.first);
expect(dot.top - last.bottom, lessThan(12));
});
testWidgets('cells are square plus the label, within the box', (
tester,
) async {
await _pump(tester, count: 3);
final cell = tester.getSize(find.text('item 0').first);
expect(cell.width, greaterThan(0));
final box = tester.getRect(find.byType(FitGrid));
final last = tester.getRect(find.text('item 2'));
expect(box.contains(last.center), isTrue);
});
testWidgets('overflow goes to more pages with one dot per page', (
tester,
) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
expect(perPage, lessThan(30));
final pages = (30 / perPage).ceil();
expect(_dot, findsNWidgets(pages));
expect(find.text('item $perPage').hitTestable(), findsNothing);
});
testWidgets('swiping left shows the next page and moves the dot', (
tester,
) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
Color? active() {
final colors = [
for (final e in _dot.evaluate())
((e.widget as Container).decoration! as BoxDecoration).color,
];
return colors.first;
}
final before = active();
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
await tester.pumpAndSettle();
expect(find.text('item $perPage').hitTestable(), findsOneWidget);
expect(find.text('item 0').hitTestable(), findsNothing);
expect(active(), isNot(before));
});
testWidgets('the last page keeps cell size (partial rows are padded)', (
tester,
) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
final firstWidth = tester.getSize(find.text('item 0')).width;
for (var i = 0; i < 20; i++) {
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
await tester.pumpAndSettle();
}
expect(find.text('item 29').hitTestable(), findsOneWidget);
expect(tester.getSize(find.text('item 29')).width, firstWidth);
expect(perPage, greaterThan(0));
});
testWidgets('a box shorter than one row still shows a row', (tester) async {
await _pump(tester, count: 6, size: const Size(300, 60));
expect(find.text('item 0'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('list shrinking drops the page back inside the range', (
tester,
) async {
await _pump(tester, count: 30);
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
await tester.pumpAndSettle();
await _pump(tester, count: 2);
await tester.pumpAndSettle();
expect(find.text('item 0').hitTestable(), findsOneWidget);
expect(_dot, findsNothing);
});
testWidgets('empty list renders nothing and does not throw', (tester) async {
await _pump(tester, count: 0);
expect(find.textContaining('item '), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('columns is honoured', (tester) async {
await _pump(tester, count: 6, columns: 2);
final x0 = tester.getTopLeft(find.text('item 0')).dx;
final x1 = tester.getTopLeft(find.text('item 1')).dx;
final x2 = tester.getTopLeft(find.text('item 2')).dx;
expect(x1, greaterThan(x0));
expect(x2, x0);
});
}