diff --git a/design_system/lib/design_system.dart b/design_system/lib/design_system.dart index 38daa96..b876944 100644 --- a/design_system/lib/design_system.dart +++ b/design_system/lib/design_system.dart @@ -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'; diff --git a/design_system/lib/src/fit_grid.dart b/design_system/lib/src/fit_grid.dart new file mode 100644 index 0000000..6fe1388 --- /dev/null +++ b/design_system/lib/src/fit_grid.dart @@ -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 createState() => _FitGridState(); +} + +const double _dotSize = AppSpacing.sm; +const double _dotsHeight = AppSpacing.md + AppSpacing.xs; + +class _FitGridState extends State { + 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('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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/design_system/pubspec.yaml b/design_system/pubspec.yaml index 168cc52..952cf96 100644 --- a/design_system/pubspec.yaml +++ b/design_system/pubspec.yaml @@ -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 diff --git a/design_system/test/fit_grid_test.dart b/design_system/test/fit_grid_test.dart new file mode 100644 index 0000000..e7093df --- /dev/null +++ b/design_system/test/fit_grid_test.dart @@ -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(:final value) => value.startsWith('fit-grid-dot'), + _ => false, + }, +); + +Future _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); + }); +}