mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:27:01 +01:00
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:
parent
2ffae26f93
commit
f7ea0ffb3d
10
AGENTS.md
10
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.
|
||||
|
||||
@ -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
115
lib/ui/note_grid_tile.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/ui/note_sheet_details.dart
Normal file
50
lib/ui/note_sheet_details.dart
Normal 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
76
lib/ui/notes_grid.dart
Normal 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]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,20 +198,26 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
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: () => openNote(
|
||||
context,
|
||||
notes[i],
|
||||
widget.repository,
|
||||
widget.appSettings,
|
||||
onTap: () => open(notes[i]),
|
||||
onActions: () => actions(notes[i]),
|
||||
),
|
||||
onActions: () =>
|
||||
openNoteActions(context, notes[i], widget.repository),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
58
pubspec.lock
58
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:
|
||||
|
||||
14
pubspec.yaml
14
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
|
||||
|
||||
166
test/notes_grid_geometry_test.dart
Normal file
166
test/notes_grid_geometry_test.dart
Normal 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
161
test/notes_grid_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user