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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 09:27:20 +01:00
parent 2ffae26f93
commit f7ea0ffb3d
10 changed files with 635 additions and 54 deletions

View File

@ -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.

View File

@ -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<NoteActionsSheet> {
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
NoteSheetDetails(note: widget.note),
const SizedBox(height: 16),
_EnumChips<Status>(
label: 'Status',

115
lib/ui/note_grid_tile.dart Normal file
View File

@ -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)),
),
],
),
],
),
),
),
);
}
}

View File

@ -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,
),
],
);
}
}

76
lib/ui/notes_grid.dart Normal file
View File

@ -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<Note> notes;
/// Tap on a tile.
final ValueChanged<Note> onOpen;
/// Long-press on a tile.
final ValueChanged<Note> 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]),
),
),
),
);
}
}

View File

@ -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<NotesListScreen> {
),
);
}
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]),
),
),
);
},
),

View File

@ -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:

View File

@ -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

View File

@ -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<Note> _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<Rect> _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<ScrollableState>(
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);
});
}

161
test/notes_grid_test.dart Normal file
View File

@ -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<void> _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<NoteGridTile>(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<Badge>(find.byType(Badge)).isLabelVisible, isFalse);
});
}