From f7ea0ffb3d0eb12ed81ee855b97642264048d35b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 09:27:20 +0100 Subject: [PATCH] Notes list: 2-column tile grid on phones, whole rows fill the screen Below 600dp the notes list is a compact tile grid (title + priority/status cues). Tile height is derived so whole rows exactly fill the viewport between the status and navigation bars; past a screenful it scrolls vertically (no paging). Body and dates moved into the long-press sheet. Wider windows keep the list. Not using design_system FitGrid: it is square-celled and pages horizontally. Pins: design_system v0.3.0 (newest tag); also bumped crdt_sync_dart 0.11.2, sync_settings_ui 0.4.2, file_picker 13.3.1, idb_shim 2.11.1, sqflite_common_ffi 2.4.5, web 1.1.2 because the freshness gate was already red on them at HEAD. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 +- lib/ui/note_actions_sheet.dart | 2 + lib/ui/note_grid_tile.dart | 115 ++++++++++++++++++++ lib/ui/note_sheet_details.dart | 50 +++++++++ lib/ui/notes_grid.dart | 76 +++++++++++++ lib/ui/notes_list_screen.dart | 37 ++++--- pubspec.lock | 58 +++++----- pubspec.yaml | 14 +-- test/notes_grid_geometry_test.dart | 166 +++++++++++++++++++++++++++++ test/notes_grid_test.dart | 161 ++++++++++++++++++++++++++++ 10 files changed, 635 insertions(+), 54 deletions(-) create mode 100644 lib/ui/note_grid_tile.dart create mode 100644 lib/ui/note_sheet_details.dart create mode 100644 lib/ui/notes_grid.dart create mode 100644 test/notes_grid_geometry_test.dart create mode 100644 test/notes_grid_test.dart diff --git a/AGENTS.md b/AGENTS.md index 7625501..68aa810 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,11 @@ Three layers under `lib/`, each single-purpose: with the structured template; lazy note creation on first keystroke. - `notes_list_screen.dart`: list + search + sort + filter sheet + per-note sheet. **Default view hides Done/Abandoned and shows no filter badge** - (looks unfiltered). + (looks unfiltered). Under 600dp wide (`kNotesGridMaxWidth`) the notes are + a 2-column `NotesGrid` of compact `NoteGridTile`s (title + priority/status + cues; body and dates are in the long-press sheet). The tile height is + derived so whole rows exactly fill the viewport; past one screenful it + scrolls vertically (no paging). Wider windows (desktop) keep the list. - `settings_screen.dart`: GitHub connect/test, device-flow auth, and the Export/Import backup actions. - `main.dart`: bootstrap only (`// coverage:ignore-file`) — wires platform DB @@ -140,8 +144,8 @@ rest of the wrapper one request at a time. BACKLOG.md rewrites links to ## Testing -- **The suite must stay quick and fully green.** It is currently **587 tests at - 100% line coverage** (3213 lines), ~40s on this machine with +- **The suite must stay quick and fully green.** It is currently **604 tests at + 100% line coverage** (3305 lines), ~40s on this machine with `--concurrency=3` (unbounded concurrency under `capped.sh`'s 4 GiB gets its test processes SIGTERMed while the wrapper still exits 0 — check the last line says "All tests passed"). Don't regress either. diff --git a/lib/ui/note_actions_sheet.dart b/lib/ui/note_actions_sheet.dart index b26a67b..76cd166 100644 --- a/lib/ui/note_actions_sheet.dart +++ b/lib/ui/note_actions_sheet.dart @@ -10,6 +10,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_template.dart'; +import 'package:todo/ui/note_sheet_details.dart'; import 'package:todo/ui/note_task_details.dart'; /// The per-note bottom sheet: status/priority chips, and delete. @@ -66,6 +67,7 @@ class _NoteActionsSheetState extends State { maxLines: 2, overflow: TextOverflow.ellipsis, ), + NoteSheetDetails(note: widget.note), const SizedBox(height: 16), _EnumChips( label: 'Status', diff --git a/lib/ui/note_grid_tile.dart b/lib/ui/note_grid_tile.dart new file mode 100644 index 0000000..c1f38e4 --- /dev/null +++ b/lib/ui/note_grid_tile.dart @@ -0,0 +1,115 @@ +/// One compact tile of the notes grid: title plus priority and status cues. +library; + +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_template.dart'; + +/// A note as a grid cell. It fills whatever cell it is given. +class NoteGridTile extends StatelessWidget { + /// Creates a tile for [note]. + const NoteGridTile({ + required this.note, + required this.onTap, + required this.onActions, + super.key, + }); + + /// The note this tile displays. + final Note note; + + /// Open the full note. + final VoidCallback onTap; + + /// Open the per-note sheet (body, dates, priority/status, delete). + final VoidCallback onActions; + + /// Danger for high, the accent for medium, muted for low. + Color accentFor(ColorScheme scheme) => switch (note.priority) { + Priority.high => scheme.error, + Priority.medium => scheme.primary, + Priority.low => scheme.outline, + }; + + static IconData _statusIcon(Status s) => switch (s) { + Status.todo => Icons.radio_button_unchecked, + Status.inProgress => Icons.timelapse, + Status.done => Icons.check_circle_outline, + Status.abandoned => Icons.cancel_outlined, + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final title = noteTitle(note.text); + final cue = theme.textTheme.labelSmall; + final closed = + note.status == Status.done || note.status == Status.abandoned; + return Material( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.md), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + onLongPress: onActions, + child: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: accentFor(scheme), width: 4), + ), + ), + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm, + AppSpacing.sm, + AppSpacing.sm, + AppSpacing.xs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Expanded: a big text scale shortens the title, never overflows. + Expanded( + child: Text( + title.isEmpty ? '(empty)' : title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: closed ? scheme.onSurfaceVariant : null, + decoration: closed ? TextDecoration.lineThrough : null, + ), + ), + ), + Row( + children: [ + Icon( + _statusIcon(note.status), + size: 14, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.xs), + Flexible( + child: Text( + note.status.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: cue, + ), + ), + const Spacer(), + Text( + note.priority.label, + maxLines: 1, + style: cue?.copyWith(color: accentFor(scheme)), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/note_sheet_details.dart b/lib/ui/note_sheet_details.dart new file mode 100644 index 0000000..5494a4d --- /dev/null +++ b/lib/ui/note_sheet_details.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'package:todo/data/note.dart'; +import 'package:todo/images/attachment_links.dart'; + +/// [text] without the line that serves as the title. +String noteBodyOf(String text) { + final lines = text.split('\n'); + final title = lines.indexWhere( + (l) => l.trim().isNotEmpty && attachmentOfLine(l.trim()) == null, + ); + if (title < 0) return text.trim(); + return [...lines.take(title), ...lines.skip(title + 1)].join('\n').trim(); +} + +String _stamp(DateTime t) { + String two(int n) => n.toString().padLeft(2, '0'); + final date = '${t.year}-${two(t.month)}-${two(t.day)}'; + return '$date ${two(t.hour)}:${two(t.minute)}'; +} + +/// What a grid tile leaves out: the note body and its two dates. +class NoteSheetDetails extends StatelessWidget { + /// Shows the body and the dates of [note]. + const NoteSheetDetails({required this.note, super.key}); + + /// The note being shown. + final Note note; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).textTheme; + final body = noteBodyOf(note.text); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (body.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(body, style: theme.bodyMedium), + ], + const SizedBox(height: 12), + Text( + 'Edited ${_stamp(note.updatedAt)}' + ' · Created ${_stamp(note.createdAt)}', + style: theme.bodySmall, + ), + ], + ); + } +} diff --git a/lib/ui/notes_grid.dart b/lib/ui/notes_grid.dart new file mode 100644 index 0000000..fdd155f --- /dev/null +++ b/lib/ui/notes_grid.dart @@ -0,0 +1,76 @@ +import 'dart:math' as math; + +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'package:todo/data/note.dart'; +import 'package:todo/ui/note_grid_tile.dart'; + +/// Below this width the notes list is a grid; at or above it, the list. +const double kNotesGridMaxWidth = 600; + +const _columns = 2; +const double _gap = AppSpacing.sm; +const double _sidePadding = AppSpacing.md - AppSpacing.xs; +const _minTileHeight = 96.0; + +double _tileHeightFor(double height) { + final rows = math.max(1, ((height + _gap) / (_minTileHeight + _gap)).floor()); + return (height + _gap) / rows - _gap; +} + +/// Phone notes list: two columns, scrolling only past one screenful. +class NotesGrid extends StatelessWidget { + /// Creates the grid; [onOpen] and [onActions] get the tapped note. + const NotesGrid({ + required this.notes, + required this.onOpen, + required this.onActions, + super.key, + }); + + /// Notes in display order. + final List notes; + + /// Tap on a tile. + final ValueChanged onOpen; + + /// Long-press on a tile. + final ValueChanged onActions; + + Widget _tile(Note note) { + return NoteGridTile( + key: ValueKey(note.id), + note: note, + onTap: () => onOpen(note), + onActions: () => onActions(note), + ); + } + + SliverGridDelegate _delegate(double viewportHeight) { + return SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _columns, + mainAxisSpacing: _gap, + crossAxisSpacing: _gap, + mainAxisExtent: _tileHeightFor(viewportHeight), + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: _gap), + child: SafeArea( + top: false, + child: LayoutBuilder( + builder: (context, box) => GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: _sidePadding), + gridDelegate: _delegate(box.maxHeight), + itemCount: notes.length, + itemBuilder: (context, i) => _tile(notes[i]), + ), + ), + ), + ); + } +} diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index 799a77a..5b3c9a7 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -7,6 +7,7 @@ import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/ui/note_tile.dart'; import 'package:todo/ui/notes_filter_sheet.dart'; +import 'package:todo/ui/notes_grid.dart'; import 'package:todo/ui/notes_list_defaults.dart'; import 'package:todo/ui/notes_list_navigation.dart'; @@ -197,21 +198,27 @@ class _NotesListScreenState extends State { ), ); } - return ListView.separated( - itemCount: notes.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, i) => NoteTile( - key: ValueKey(notes[i].id), - note: notes[i], - onTap: () => openNote( - context, - notes[i], - widget.repository, - widget.appSettings, - ), - onActions: () => - openNoteActions(context, notes[i], widget.repository), - ), + void open(Note n) => + openNote(context, n, widget.repository, widget.appSettings); + void actions(Note n) => + openNoteActions(context, n, widget.repository); + return LayoutBuilder( + builder: (context, box) => box.maxWidth < kNotesGridMaxWidth + ? NotesGrid( + notes: notes, + onOpen: open, + onActions: actions, + ) + : ListView.separated( + itemCount: notes.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) => NoteTile( + key: ValueKey(notes[i].id), + note: notes[i], + onTap: () => open(notes[i]), + onActions: () => actions(notes[i]), + ), + ), ); }, ), diff --git a/pubspec.lock b/pubspec.lock index c6f8347..c9bd9bc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: android_file_picker - sha256: "14ab27769b54c48d5a8a71aa9858372b7a3ae77572ea0a8aee744643c5d8c53d" + sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.2" archive: dependency: transitive description: @@ -109,11 +109,11 @@ packages: dependency: "direct main" description: path: crdt_sync_dart - ref: "crdt_sync_dart-v0.11.1" - resolved-ref: "978d32af3af66f0d6500c4c6c0c6df5370ebe9a2" + ref: "crdt_sync_dart-v0.11.2" + resolved-ref: "1b5515fd8b1192207896c409212483aeb09704ea" url: "https://github.com/kuhyx/utils" source: git - version: "0.11.1" + version: "0.11.2" cross_file: dependency: transitive description: @@ -150,11 +150,11 @@ packages: dependency: "direct main" description: path: design_system - ref: "design_system-v0.2.1" - resolved-ref: "978d32af3af66f0d6500c4c6c0c6df5370ebe9a2" + ref: "design_system-v0.3.0" + resolved-ref: b3c9611af212880301ca79ed21f909bc4640f81f url: "https://github.com/kuhyx/utils" source: git - version: "0.2.1" + version: "0.3.0" dev_build: dependency: transitive description: @@ -199,42 +199,42 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea" + sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592" url: "https://pub.dev" source: hosted - version: "13.1.0" + version: "13.3.1" file_picker_darwin: dependency: transitive description: name: file_picker_darwin - sha256: "51aef9f4c80449c736e7cc02198675fda73689f0bd45a84da642cfeab0250d46" + sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54 url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.4.1" file_picker_linux: dependency: transitive description: name: file_picker_linux - sha256: "9c870771500c758eb46506c98532c3ee60e6e51d0a62dcde24a849ef213ea5ce" + sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" file_picker_platform_interface: dependency: transitive description: name: file_picker_platform_interface - sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5 + sha256: "336e7168306834a2b68e6168ed768e00d3fabeac23154d5793e277ca9f97cf20" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.2.0" file_picker_web: dependency: transitive description: name: file_picker_web - sha256: b3004268da0c1b52baa18df430e7ec4438194de855a807d26b447cbf07ef3496 + sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.2.0" file_selector: dependency: "direct main" description: @@ -479,10 +479,10 @@ packages: dependency: "direct main" description: name: idb_shim - sha256: "4f32cd03ce97e2996b57cea03b2d098f5153ac55435a27cf2c89716db571728d" + sha256: "923c92ef88569de4ec1b257b6a6d10360dc3407179f61d0c73329701a7d7b12e" url: "https://pub.dev" source: hosted - version: "2.10.1+1" + version: "2.11.1" image: dependency: "direct main" description: @@ -956,10 +956,10 @@ packages: dependency: "direct main" description: name: sqflite_common_ffi - sha256: "4cb6179390549743fc1a5a2fb8f134e5ad87ce459f6f4e563531487dadd81710" + sha256: "119d730460264a1f11f414d9eaa43fb686789357e81ce35f9e8ccb66ddd040e4" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.4.5" sqflite_common_ffi_web: dependency: transitive description: @@ -1028,11 +1028,11 @@ packages: dependency: "direct main" description: path: sync_settings_ui - ref: "sync_settings_ui-v0.4.1" - resolved-ref: "51700aa89eaf1789ae98491aaa854c54d35b4170" + ref: "sync_settings_ui-v0.4.2" + resolved-ref: a4c7d29595bb9cf75f06877545249ed77d5560a9 url: "https://github.com/kuhyx/utils" source: git - version: "0.4.1" + version: "0.4.2" synchronized: dependency: transitive description: @@ -1165,10 +1165,10 @@ packages: dependency: "direct main" description: name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" win32: dependency: transitive description: @@ -1181,10 +1181,10 @@ packages: dependency: transitive description: name: windows_file_picker - sha256: "9f3aa833068b09e380fdc59560ad260a4a14e9397173abb533699f967443602e" + sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4f2075a..06ffd5f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: crdt_sync: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_dart-v0.11.1 + ref: crdt_sync_dart-v0.11.2 path: crdt_sync_dart # The following adds the Cupertino Icons font to your application. @@ -45,9 +45,9 @@ dependencies: design_system: git: url: https://github.com/kuhyx/utils - ref: design_system-v0.2.1 + ref: design_system-v0.3.0 path: design_system - file_picker: 13.1.0 + file_picker: 13.3.1 file_selector: 1.1.0 flutter: sdk: flutter @@ -66,7 +66,7 @@ dependencies: # this plugin does not exist. See lib/sync/google_sign_in_backend.dart. google_sign_in: 7.2.0 http: 1.6.0 - idb_shim: 2.10.1+1 + idb_shim: 2.11.1 # Re-encodes attached images (resize, EXIF strip) in pure Dart, so the # phone and the desktop wrapper share one code path. image: 4.12.0 @@ -79,7 +79,7 @@ dependencies: path_provider: 2.1.6 share_plus: 13.3.1 shared_preferences: 2.5.6 - sqflite_common_ffi: 2.4.4 + sqflite_common_ffi: 2.4.5 # Retained only to read the legacy on-device DB during the one-time # migration in NoteRepository.open; remove once all devices are migrated. sqlite_crdt: 3.0.4 @@ -91,12 +91,12 @@ dependencies: sync_settings_ui: git: url: https://github.com/kuhyx/utils - ref: sync_settings_ui-v0.4.1 + ref: sync_settings_ui-v0.4.2 path: sync_settings_ui url_launcher: 6.3.3 uuid: 4.6.0 # Paste and drag-and-drop listeners on the desktop (web) page. - web: 1.1.1 + web: 1.1.2 dev_dependencies: # Virtual clock for unit-testing debounced timers (e.g. LocalBackup). fake_async: 1.3.3 diff --git a/test/notes_grid_geometry_test.dart b/test/notes_grid_geometry_test.dart new file mode 100644 index 0000000..6756c40 --- /dev/null +++ b/test/notes_grid_geometry_test.dart @@ -0,0 +1,166 @@ +/// Pixel 6a geometry of the notes grid: whole tiles only, nothing under the +/// status bar or the navigation bar, no overflow, scrolls only past a screen. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/ui/note_grid_tile.dart'; + +import 'notes_list_harness.dart'; + +const _dpr = 2.625; +const _navBar = 63 / _dpr; +const _statusBar = 132 / _dpr; +const _screenHeight = 2400 / _dpr; + +/// 1080x2400 px at 2.625, with the real system-bar insets. +void _pixel6a(WidgetTester t, {double keyboardDp = 0}) { + const bars = FakeViewPadding(top: 132, bottom: 63); + t.view + ..physicalSize = const Size(1080, 2400) + ..devicePixelRatio = _dpr + ..padding = bars + ..viewPadding = bars + ..viewInsets = FakeViewPadding(bottom: keyboardDp * _dpr); + addTearDown(t.view.reset); +} + +List _notes(int n) => [ + for (var i = 0; i < n; i++) + note( + 'n$i', + 'Note $i with a rather long title that wraps onto several lines', + priority: Priority.values[i % 3], + ), +]; + +Rect _viewport(WidgetTester t) => t.getRect(find.byType(GridView)); + +/// Tiles whose rect touches the viewport. +List _shown(WidgetTester t) { + final view = _viewport(t); + return [ + for (final e in find.byType(NoteGridTile).evaluate()) + t.getRect(find.byWidget(e.widget)), + ].where((r) => r.overlaps(view)).toList(); +} + +double _maxScroll(WidgetTester t) => t + .state( + find.descendant( + of: find.byType(GridView), + matching: find.byType(Scrollable), + ), + ) + .position + .maxScrollExtent; + +void _expectWholeTilesOnly(WidgetTester t) { + final view = _viewport(t); + final shown = _shown(t); + expect(shown, isNotEmpty); + for (final r in shown) { + expect(r.top, greaterThanOrEqualTo(view.top - 0.01), reason: '$r'); + expect(r.bottom, lessThanOrEqualTo(view.bottom + 0.01), reason: '$r'); + } + // The rows use up the whole viewport: no strip of dead space under them. + final lastBottom = shown.map((r) => r.bottom).reduce((a, b) => a > b ? a : b); + expect(lastBottom, closeTo(view.bottom, 0.01)); +} + +void main() { + testWidgets('Pixel 6a: whole tiles between the system bars', (t) async { + _pixel6a(t); + await pumpList(t, seed: _notes(300)); + + expect(t.takeException(), isNull); + final view = _viewport(t); + expect(view.top, greaterThanOrEqualTo(_statusBar + kToolbarHeight)); + expect(view.bottom, closeTo(_screenHeight - _navBar, 0.01)); + _expectWholeTilesOnly(t); + // Two columns, a real screenful, and tiles big enough to read. + final shown = _shown(t); + expect(shown.length.isEven, isTrue); + expect(shown.length, greaterThanOrEqualTo(10)); + expect(shown.first.height, greaterThanOrEqualTo(88)); + expect(shown.first.width, closeTo(shown[1].width, 0.01)); + expect(shown[1].left, greaterThan(shown.first.right)); + }); + + testWidgets('Pixel 6a: 300 notes scroll vertically to the last one', ( + t, + ) async { + _pixel6a(t); + await pumpList(t, seed: _notes(300)); + + expect(_maxScroll(t), greaterThan(0)); + await t.drag(find.byType(GridView), const Offset(0, -500000)); + await t.pump(); + await t.pump(const Duration(seconds: 1)); + + expect(find.textContaining('Note 299 '), findsOneWidget); + expect(find.textContaining('Note 299 ').hitTestable(), findsOneWidget); + expect(_viewport(t).bottom, closeTo(_screenHeight - _navBar, 0.01)); + }); + + testWidgets('Pixel 6a: exactly one screenful does not scroll', (t) async { + _pixel6a(t); + await pumpList(t, seed: _notes(300)); + final perScreen = _shown(t).length; + + await t.pumpWidget(const SizedBox()); + await pumpList(t, seed: _notes(perScreen)); + expect(_maxScroll(t), 0); + _expectWholeTilesOnly(t); + expect(_shown(t), hasLength(perScreen)); + }); + + testWidgets('Pixel 6a: a few notes keep full-size tiles at the top', ( + t, + ) async { + _pixel6a(t); + await pumpList(t, seed: _notes(300)); + final full = _shown(t).first; + + await t.pumpWidget(const SizedBox()); + await pumpList(t, seed: _notes(3)); + expect(_maxScroll(t), 0); + expect(t.takeException(), isNull); + final first = t.getRect(find.byType(NoteGridTile).first); + expect(first.size, full.size); + expect(first.top, closeTo(_viewport(t).top, 0.01)); + }); + + testWidgets('Pixel 6a: toolbar, search and a tile are all tappable', ( + t, + ) async { + _pixel6a(t); + await pumpList(t, seed: _notes(20)); + + expect(find.byIcon(Icons.sort).hitTestable(), findsOneWidget); + expect(find.byIcon(Icons.filter_list).hitTestable(), findsOneWidget); + expect(find.byType(TextField).hitTestable(), findsOneWidget); + expect(find.byType(NoteGridTile).hitTestable(), findsWidgets); + }); + + testWidgets('Pixel 6a with the keyboard open: still whole tiles', (t) async { + _pixel6a(t, keyboardDp: 300); + await pumpList(t, seed: _notes(300)); + + expect(t.takeException(), isNull); + _expectWholeTilesOnly(t); + }); + + testWidgets('Pixel 6a at 2x text scale: no overflow, tiles stay whole', ( + t, + ) async { + _pixel6a(t); + t.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue); + await pumpList(t, seed: _notes(300)); + + expect(t.takeException(), isNull); + _expectWholeTilesOnly(t); + }); +} diff --git a/test/notes_grid_test.dart b/test/notes_grid_test.dart new file mode 100644 index 0000000..05be803 --- /dev/null +++ b/test/notes_grid_test.dart @@ -0,0 +1,161 @@ +/// What the narrow notes grid shows and does, apart from its geometry. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/ui/note_grid_tile.dart'; + +import 'notes_list_harness.dart'; + +void _phone(WidgetTester t) { + t.view + ..physicalSize = const Size(1080, 2400) + ..devicePixelRatio = 2.625; + addTearDown(t.view.reset); +} + +Future _openSheet(WidgetTester t, String title) async { + await t.longPress(find.text(title)); + await t.pump(); + await t.pump(const Duration(milliseconds: 300)); +} + +void main() { + testWidgets('phone width shows tiles, desktop width keeps the list', ( + t, + ) async { + _phone(t); + await pumpList(t, seed: [note('a', 'Buy milk')]); + expect(find.byType(NoteGridTile), findsOneWidget); + expect(find.byType(ListTile), findsNothing); + + t.view + ..physicalSize = const Size(3840, 2160) + ..devicePixelRatio = 1; + await t.pump(); + expect(find.byType(NoteGridTile), findsNothing); + expect(find.byType(ListTile), findsOneWidget); + }); + + testWidgets('a tile is title plus priority and status cues only', (t) async { + _phone(t); + await pumpList( + t, + seed: [ + note( + 'a', + 'Buy milk\n\n## what\nTwo litres', + priority: Priority.high, + status: Status.inProgress, + ), + ], + ); + + expect(find.text('Buy milk'), findsOneWidget); + expect(find.text('High'), findsOneWidget); + expect(find.text('In progress'), findsOneWidget); + expect(find.textContaining('Two litres'), findsNothing); + expect(find.textContaining('edited'), findsNothing); + expect(find.byTooltip('Quick actions'), findsNothing); + }); + + testWidgets('priority and status each get their own cue', (t) async { + _phone(t); + await pumpList( + t, + seed: [ + note('h', 'H', priority: Priority.high), + note('m', 'M'), + note('l', 'L', priority: Priority.low, status: Status.inProgress), + ], + ); + final scheme = Theme.of(t.element(find.byType(Scaffold))).colorScheme; + final accents = { + for (final id in ['h', 'm', 'l']) + t.widget(find.byKey(ValueKey(id))).accentFor(scheme), + }; + expect(accents, hasLength(3)); + expect(find.byIcon(Icons.radio_button_unchecked), findsNWidgets(2)); + expect(find.byIcon(Icons.timelapse), findsOneWidget); + }); + + testWidgets('every status has an icon', (t) async { + _phone(t); + await pumpList( + t, + seed: [for (final s in Status.values) note(s.name, s.name, status: s)], + ); + expect(find.byIcon(Icons.check_circle_outline), findsOneWidget); + expect(find.byIcon(Icons.cancel_outlined), findsOneWidget); + }); + + testWidgets('an empty note reads (empty)', (t) async { + _phone(t); + await pumpList(t, seed: [note('a', '')]); + expect(find.text('(empty)'), findsOneWidget); + }); + + testWidgets('tap opens the note', (t) async { + _phone(t); + await pumpList(t, seed: [note('a', 'Open me')]); + + await t.tap(find.text('Open me')); + await t.pumpAndSettle(); + + expect(find.text('Priority'), findsOneWidget); + expect(find.text('View'), findsOneWidget); + }); + + testWidgets('long-press opens the sheet with body, dates and actions', ( + t, + ) async { + _phone(t); + final repo = await pumpList( + t, + seed: [note('a', 'Buy milk\n\n## what\nTwo litres of oat')], + ); + + await _openSheet(t, 'Buy milk'); + + expect(find.textContaining('Two litres of oat'), findsOneWidget); + expect(find.textContaining('Edited'), findsOneWidget); + expect(find.textContaining('Created'), findsOneWidget); + await t.tap(find.text('In progress')); + await t.pump(); + expect((await repo.listNotes()).single.status, Status.inProgress); + expect(find.text('Delete note'), findsOneWidget); + }); + + testWidgets('a title-only note adds no body to the sheet', (t) async { + _phone(t); + await pumpList(t, seed: [note('a', 'Just a title')]); + + await _openSheet(t, 'Just a title'); + + expect(find.text('Just a title'), findsNWidgets(2)); + expect(find.textContaining('Edited'), findsOneWidget); + }); + + testWidgets('the sheet can delete a grid note', (t) async { + _phone(t); + final repo = await pumpList(t, seed: [note('a', 'Delete me')]); + + await _openSheet(t, 'Delete me'); + await t.tap(find.text('Delete note')); + await t.pumpAndSettle(); + await t.tap(find.text('Delete')); + await t.pumpAndSettle(); + + expect(await repo.listNotes(), isEmpty); + }); + + testWidgets('empty state and the default filter survive the grid', (t) async { + _phone(t); + final repo = await pumpList(t); + + expect(find.textContaining('No notes'), findsOneWidget); + expect(repo.lastFilter!.statuses, {Status.todo, Status.inProgress}); + expect(t.widget(find.byType(Badge)).isLabelVisible, isFalse); + }); +}