feat(todo): dialogs on the desktop, the tabs reordered, a task's path and menu (ADR 0033, D11 to D14)

- halkyon_ui: showAdaptiveSheet shows a dialog on Linux, macOS and Windows
  at every width (bottom sheets stay on phones and tablets below expanded
  windows), so Learn and Train follow; SheetScaffold takes a heading in
  the title's place and may leave out its close button.
- Todo: the tabs are Inbox, Today, Upcoming, Browse, Today still opening;
  `serverpod start` launches the app on Linux, as it has no web build.
- A task's sheet shows where the task is (its project, an icon once tasks
  are above it, and the tasks above it, each opening what it names) and a
  ⋮ menu in place of its close button: when it was added (its UUID v7),
  Duplicate with its sub-tasks, hiding its done sub-tasks (synced in its
  payload), Close in the pane, Delete after asking. The description's icon
  stays by its first line.
- A row names its project and section at the end of its date's line; every
  menu entry has its icon.
- Learn: a studyset's path starts at the Library tab's icon, named by its
  tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-01 16:53:38 +01:00
commit b908019969
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 1050 additions and 125 deletions

View file

@ -42,7 +42,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first tab with something in it; a tab with none of its own while the studyset's topics hold some says that it is in them, each topic holding some with how much, everything inside it counted, opening it, Browse all for flashcards, and how to add some here after all, decided 2026-10-14 after people testing the app looked for a grouping studyset's material in its empty tabs; the tabs' counts stay its own), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`; how many steps of its learning paths are done and the PDFs not read to their end, begun first, each opening the reader, decided 2026-10-17: `MaterialProgressCubit`; all of it across its topics) and Study plan (the paths finished; Next up: studying for the exam while one is ahead and its pace leaves cards for the day, then the cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; at the top the exam the person prepares for, below; `PathListState.stepsDone`). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab (the tab row folding into the title as the content scrolls, halkyon_ui's `FoldingTabs`, as Strava's). The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana, accents, tone marks and umlauts left out, so strasse finds Straße and xue xi xué xí; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, by bare letters, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked.
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows on phones and tablets, and a dialog from there on and on the desktop at every width (Todo's ADR 0033, D11); a sheet may put a widget of its own in its title's place (`heading`) and leave out its close button where its action's menu holds Close (`closeButton`). A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked.
- **Every text field has a limit** (decided 2026-10-11): `TextLimits` (halkyon_ui) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, an email address 254, a message or a web address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names.
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (halkyon_app's, shared by every Halkyon app) picks the language, never by country alone, and every `MaterialApp` takes `appLocalizationsDelegates` (halkyon_app's and halkyon_ui's texts among them; halkyon_app's plain apps take it from `learnFrame`), not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.

View file

@ -3,11 +3,14 @@ import 'dart:math' as math;
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Where a studyset sits, on one line: "Library › Japanese › Kana ›
/// Hiragana", the steps as plain links in a muted colour, the studyset
/// itself last, bold in the primary colour.
/// Where a studyset sits, on one line: "📖 › Japanese › Kana › Hiragana",
/// the library as the Library tab's icon, named by its tooltip (as Todo's
/// path starts at its project's icon, Todo's ADR 0033, D13), the other
/// steps as plain links in a muted colour, the studyset itself last, bold
/// in the primary colour.
///
/// Every step before it goes back up there ([goUpTo]): the screens behind
/// a studyset are its path, so going up closes the screens above, as the
@ -40,6 +43,9 @@ class const StudysetBreadcrumb({
/// How high every step is, to tap.
static const minTarget = 48.0;
/// How wide a step shown as its icon is.
static const _iconWidth = 32.0;
/// How far before the screen's end the line ends when [trailing] ends it.
static const trailingEndPadding = 8.0;
@ -57,6 +63,7 @@ class const StudysetBreadcrumb({
_Step(
l10n.libraryTitle,
enabled ? () => goUpTo(context, null, all) : null,
icon: LucideIcons.book,
),
for (final ancestor in ancestors)
_Step(
@ -85,7 +92,10 @@ class const StudysetBreadcrumb({
}
final separatorWidth = widthOf('›', muted) + 2 * _separatorPadding;
final widths = [for (final step in steps) widthOf(step.label, muted)];
final widths = [
for (final step in steps)
if (step.icon == null) widthOf(step.label, muted) else _iconWidth,
];
final currentWidth = widthOf(current.content.title, active);
const foldWidth = _Fold.width;
// Fold the first steps away until the rest fits; at the least, the
@ -120,13 +130,23 @@ class const StudysetBreadcrumb({
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: step.onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: minTarget),
child: Center(
widthFactor: 1,
child: Text(step.label, maxLines: 1, style: muted),
child: switch (step.icon) {
final icon? => Tooltip(
message: step.label,
child: SizedBox(
width: _iconWidth,
height: minTarget,
child: Icon(icon, size: 18, color: muted.color),
),
),
),
null => ConstrainedBox(
constraints: const BoxConstraints(minHeight: minTarget),
child: Center(
widthFactor: 1,
child: Text(step.label, maxLines: 1, style: muted),
),
),
},
),
),
separator(),
@ -160,8 +180,13 @@ class const StudysetBreadcrumb({
}
}
/// A step of the way, and where it leads; nowhere while null.
class const _Step(final String label, final VoidCallback? onTap);
/// A step of the way, and where it leads; nowhere while null. One with an
/// [icon] shows it, its [label] the tooltip, and both in the fold's menu.
class const _Step(
final String label,
final VoidCallback? onTap, {
final IconData? icon,
});
/// The steps that didn't fit, behind a "…" that turns primary under the
/// pointer, nearest first in its menu; a square target as high as a step.
@ -184,7 +209,11 @@ class _FoldState() extends State<_Fold> {
Widget build(BuildContext context) => MenuAnchor(
menuChildren: [
for (final step in widget.steps)
MenuItemButton(onPressed: step.onTap, child: Text(step.label)),
MenuItemButton(
onPressed: step.onTap,
leadingIcon: step.icon == null ? null : Icon(step.icon),
child: Text(step.label),
),
],
builder: (context, menu, _) => MouseRegion(
cursor: SystemMouseCursors.click,

View file

@ -321,7 +321,22 @@ void main() {
of: find.byType(StudysetBreadcrumb),
matching: find.text(text),
);
expect(step('Library'), findsOneWidget);
// The library is its icon, named by its tooltip (Todo's ADR 0033).
expect(step('Library'), findsNothing);
expect(
find.descendant(
of: find.byType(StudysetBreadcrumb),
matching: find.byTooltip('Library'),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byType(StudysetBreadcrumb),
matching: find.byIcon(LucideIcons.book),
),
findsOneWidget,
);
expect(step('Japanese'), findsOneWidget);
expect(step('Kana'), findsOneWidget);
expect(step('›'), findsNWidgets(2));
@ -1412,7 +1427,7 @@ void main() {
.getCenter(
find.descendant(
of: find.byType(StudysetBreadcrumb),
matching: find.text('Library'),
matching: find.byIcon(LucideIcons.book),
),
)
.dy,
@ -1576,7 +1591,9 @@ void main() {
.ancestor(
of: find.descendant(
of: find.byType(StudysetBreadcrumb),
matching: find.text(text),
matching: text == 'Library'
? find.byIcon(LucideIcons.book)
: find.text(text),
),
matching: find.byType(GestureDetector),
)