feat(app): Home, the Library and the studyset sheets laid out as before

Home: plain cards on the surface, 16 dp apart, the goal first and the
Pomodoro timer after it in a new dashboard. The streak chip sits centred
in the app bar, and its sheet shows the days inside a big flame and the
week as marks over the weekdays' initials, with a word on today in a box.
The week names its day as "Thursday, Oct 8" and its days carry no
tooltip. The heatmap scrolls its weekdays with the weeks, outlines the
days to come, and names a day's activities with its date. The goal card
has a ring of 5 dp, a check once met and a pencil, and its sheet saves
across its width with Remove goal below. Arranging puts an eye on each
widget's corner and lifts the one picked up, with a tap the hand feels;
with every widget hidden, Customise brings arranging back. The stepper
moves to core, where the goal's sheet takes it too.

Library: the counts line includes files (PDFs); Refresh in the app bar
and pulling the list down sync now; a list that can't be read offers
Retry, which reads it again; the star keeps its square corners.

Studyset sheet: "Title *", the secondary title explained behind a
question mark, Inherit as a box beside the colour, and the description
in up to three lines with a chevron. Move sheet: round marks, the
studyset itself marked as current, the top level only while it is
inside another, archived studysets last under their heading, the
destination named in the footer, and a new studyset to move into, made
only once the move is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-08 11:12:23 +02:00
commit aa69be47d1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
35 changed files with 1831 additions and 806 deletions

View file

@ -97,7 +97,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved. - **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared. - **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below). - **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, and the sheets below).
- **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 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. - **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 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.
- **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` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, 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. - **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` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, 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.
@ -151,7 +151,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables. - **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device. - **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number. - **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. - **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on).
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. - **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint. - **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again. - **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.

View file

@ -33,7 +33,7 @@
"studysetSheetSave": "Speichern", "studysetSheetSave": "Speichern",
"studysetSheetTitleLabel": "Titel", "studysetSheetTitleLabel": "Titel",
"studysetSheetSecondaryLabel": "Zweittitel", "studysetSheetSecondaryLabel": "Zweittitel",
"studysetSheetSecondaryHelper": "Wird als kleines Label über dem Titel angezeigt.", "studysetSheetSecondaryHelper": "Wird als kleines Label über dem Titel auf Karten und im Kopf des Lernsets angezeigt. Pfade nutzen es, um Stufen zu kennzeichnen (Challenge, Meilenstein, Lektion).",
"studysetSheetColor": "Farbe", "studysetSheetColor": "Farbe",
"studysetSheetDiscardTitle": "Änderungen verwerfen?", "studysetSheetDiscardTitle": "Änderungen verwerfen?",
"studysetSheetDiscardMessage": "Du hast ungespeicherte Änderungen an diesem Lernset. Verwerfen und das Formular schließen?", "studysetSheetDiscardMessage": "Du hast ungespeicherte Änderungen an diesem Lernset. Verwerfen und das Formular schließen?",
@ -44,6 +44,9 @@
"studysetCountFlashcards": "{count, plural, =1{1 Karteikarte} other{{count} Karteikarten}}", "studysetCountFlashcards": "{count, plural, =1{1 Karteikarte} other{{count} Karteikarten}}",
"studysetCountNotes": "{count, plural, =1{1 Notiz} other{{count} Notizen}}", "studysetCountNotes": "{count, plural, =1{1 Notiz} other{{count} Notizen}}",
"studysetCountFiles": "{count, plural, =1{1 Datei} other{{count} Dateien}}",
"libraryRefresh": "Aktualisieren",
"libraryRetry": "Erneut versuchen",
"studysetNoContent": "Noch kein Inhalt", "studysetNoContent": "Noch kein Inhalt",
"studysetActionReview": "Wiederholen", "studysetActionReview": "Wiederholen",
"studysetActionStudy": "Lernen", "studysetActionStudy": "Lernen",
@ -233,18 +236,25 @@
"studysetDragToReorder": "Zum Umsortieren ziehen", "studysetDragToReorder": "Zum Umsortieren ziehen",
"studysetMoveTitle": "Lernset verschieben nach", "studysetMoveTitle": "Lernset verschieben nach",
"studysetMoveTopLevel": "Oberste Ebene", "studysetMoveTopLevel": "Oberste Ebene",
"studysetMoveCurrent": "Aktueller Ort", "studysetMoveCurrent": "(aktuell)",
"studysetMoveTopLevelRow": "Oberste Ebene (in keinem Lernset)",
"studysetMoveArchived": "Archiviert",
"studysetMoveNew": "Neues Lernset",
"studysetMoveSelectDestination": "Wähle ein Ziel",
"studysetMoveDestination": "Ziel: {name}",
"studysetMoveConfirm": "Verschieben", "studysetMoveConfirm": "Verschieben",
"studysetMoveShowInside": "Inhalt anzeigen", "studysetMoveShowInside": "Unter-Lernsets anzeigen",
"studysetMoveHideInside": "Inhalt ausblenden", "studysetMoveHideInside": "Unter-Lernsets ausblenden",
"studysetMoved": "Nach „{destination}“ verschoben.", "studysetMoved": "„{title}“ nach {destination} verschoben.",
"studysetReadMore": "Mehr anzeigen", "studysetReadMore": "Mehr anzeigen",
"studysetDescriptionTitle": "Beschreibung", "studysetDescriptionTitle": "Beschreibung",
"studysetDescriptionEditTitle": "Beschreibung bearbeiten", "studysetDescriptionEditTitle": "Beschreibung bearbeiten",
"richTextSave": "Speichern", "richTextSave": "Speichern",
"studysetSheetDescription": "Beschreibung", "studysetSheetDescription": "Beschreibung",
"studysetSheetDescriptionAdd": "Beschreibung hinzufügen", "studysetSheetDescriptionAdd": "Tippen, um eine Beschreibung hinzuzufügen",
"studysetSheetInheritColor": "Gleiche Farbe wie das Lernset, in dem es liegt", "studysetSheetDescriptionAdded": "Beschreibung hinzugefügt — zum Bearbeiten tippen",
"studysetSheetInheritColor": "Erben",
"fieldRequired": "Pflichtfeld",
"exploreTitle": "Entdecken", "exploreTitle": "Entdecken",
"exploreEmpty": "Lernsets, die andere teilen, erscheinen hier.", "exploreEmpty": "Lernsets, die andere teilen, erscheinen hier.",
@ -602,8 +612,9 @@
"homeShowWidget": "{label} anzeigen", "homeShowWidget": "{label} anzeigen",
"homeHideWidget": "{label} ausblenden", "homeHideWidget": "{label} ausblenden",
"homeAllHidden": "Alle Widgets sind ausgeblendet", "homeAllHidden": "Alle Widgets sind ausgeblendet",
"homeAllHiddenHint": "Halte hier lange gedrückt, um welche zurückzuholen.", "homeAllHiddenBody": "Blende einige wieder ein, um dein Dashboard zu füllen.",
"homeWidgetGoal": "Lernziel", "homeCustomise": "Anpassen",
"homeWidgetGoal": "Tagesziel",
"homeWidgetWeek": "Wochenübersicht", "homeWidgetWeek": "Wochenübersicht",
"homeWidgetActivity": "Aktivitäts-Heatmap", "homeWidgetActivity": "Aktivitäts-Heatmap",
"homeActivityTitle": "Deine Aktivität", "homeActivityTitle": "Deine Aktivität",
@ -635,11 +646,10 @@
"weekPreviousDay": "Vorheriger Tag", "weekPreviousDay": "Vorheriger Tag",
"weekNextDay": "Nächster Tag", "weekNextDay": "Nächster Tag",
"weekRestDay": "Ruhetag", "weekRestDay": "Ruhetag",
"weekDueCount": "{count, plural, =1{1 Karte fällig} other{{count} Karten fällig}}",
"heatmapLess": "Weniger", "heatmapLess": "Weniger",
"heatmapMore": "Mehr", "heatmapMore": "Mehr",
"heatmapTotal": "{count, plural, =1{1 Aktivität im letzten Jahr} other{{count} Aktivitäten im letzten Jahr}}", "heatmapTotal": "{count, plural, =1{1 Aktivität im letzten Jahr} other{{count} Aktivitäten im letzten Jahr}}",
"heatmapDay": "{count, plural, =0{Nichts} =1{1 Aktivität} other{{count} Aktivitäten}} · {date}", "heatmapDay": "{count, plural, =1{1 Aktivität} other{{count} Aktivitäten}} · {date}",
"commonCancel": "Abbrechen", "commonCancel": "Abbrechen",
"commonSave": "Speichern", "commonSave": "Speichern",
@ -882,7 +892,7 @@
"readerCaptureFailed": "Die Seite konnte nicht dargestellt werden. Versuche es erneut, sobald sie geladen ist.", "readerCaptureFailed": "Die Seite konnte nicht dargestellt werden. Versuche es erneut, sobald sie geladen ist.",
"readerScreenshotSaved": "Bildschirmfoto gespeichert", "readerScreenshotSaved": "Bildschirmfoto gespeichert",
"imageOpenPdfPage": "PDF auf dieser Seite öffnen", "imageOpenPdfPage": "PDF auf dieser Seite öffnen",
"homeWidgetDeadlines": "Lesefristen", "homeWidgetDeadlines": "PDF-Lesefristen",
"deadlinesTitle": "Lesefristen", "deadlinesTitle": "Lesefristen",
"deadlinesCurrent": "Aktuell", "deadlinesCurrent": "Aktuell",
"deadlinesUpcoming": "Demnächst", "deadlinesUpcoming": "Demnächst",

View file

@ -34,7 +34,7 @@
"studysetSheetSave": "Save", "studysetSheetSave": "Save",
"studysetSheetTitleLabel": "Title", "studysetSheetTitleLabel": "Title",
"studysetSheetSecondaryLabel": "Secondary title", "studysetSheetSecondaryLabel": "Secondary title",
"studysetSheetSecondaryHelper": "Shown as a small label above the title.", "studysetSheetSecondaryHelper": "Shown as a small label above the title on cards and the studyset's header. Paths use it to label tiers (Challenge, Milestone, Lesson).",
"studysetSheetColor": "Colour", "studysetSheetColor": "Colour",
"studysetSheetDiscardTitle": "Discard changes?", "studysetSheetDiscardTitle": "Discard changes?",
"studysetSheetDiscardMessage": "You have unsaved changes to this studyset. Discard them and close the form?", "studysetSheetDiscardMessage": "You have unsaved changes to this studyset. Discard them and close the form?",
@ -49,6 +49,10 @@
"@studysetCountFlashcards": {"placeholders": {"count": {"type": "int"}}}, "@studysetCountFlashcards": {"placeholders": {"count": {"type": "int"}}},
"studysetCountNotes": "{count, plural, =1{1 note} other{{count} notes}}", "studysetCountNotes": "{count, plural, =1{1 note} other{{count} notes}}",
"@studysetCountNotes": {"placeholders": {"count": {"type": "int"}}}, "@studysetCountNotes": {"placeholders": {"count": {"type": "int"}}},
"studysetCountFiles": "{count, plural, =1{1 file} other{{count} files}}",
"@studysetCountFiles": {"placeholders": {"count": {"type": "int"}}},
"libraryRefresh": "Refresh",
"libraryRetry": "Retry",
"studysetNoContent": "No content yet", "studysetNoContent": "No content yet",
"studysetActionReview": "Review", "studysetActionReview": "Review",
"studysetActionStudy": "Study", "studysetActionStudy": "Study",
@ -257,19 +261,27 @@
"studysetDragToReorder": "Drag to reorder", "studysetDragToReorder": "Drag to reorder",
"studysetMoveTitle": "Move studyset to", "studysetMoveTitle": "Move studyset to",
"studysetMoveTopLevel": "Top level", "studysetMoveTopLevel": "Top level",
"studysetMoveCurrent": "Where it is now", "studysetMoveCurrent": "(current)",
"studysetMoveTopLevelRow": "Top level (no parent)",
"studysetMoveArchived": "Archived",
"studysetMoveNew": "New studyset",
"studysetMoveSelectDestination": "Select a destination",
"studysetMoveDestination": "Destination: {name}",
"@studysetMoveDestination": {"placeholders": {"name": {"type": "String"}}},
"studysetMoveConfirm": "Move", "studysetMoveConfirm": "Move",
"studysetMoveShowInside": "Show what is inside", "studysetMoveShowInside": "Show sub-studysets",
"studysetMoveHideInside": "Hide what is inside", "studysetMoveHideInside": "Hide sub-studysets",
"studysetMoved": "Moved to {destination}.", "studysetMoved": "Moved \"{title}\" to {destination}.",
"@studysetMoved": {"placeholders": {"destination": {"type": "String"}}}, "@studysetMoved": {"placeholders": {"title": {"type": "String"}, "destination": {"type": "String"}}},
"studysetReadMore": "Read more", "studysetReadMore": "Read more",
"studysetDescriptionTitle": "Description", "studysetDescriptionTitle": "Description",
"studysetDescriptionEditTitle": "Edit description", "studysetDescriptionEditTitle": "Edit description",
"richTextSave": "Save", "richTextSave": "Save",
"studysetSheetDescription": "Description", "studysetSheetDescription": "Description",
"studysetSheetDescriptionAdd": "Add a description", "studysetSheetDescriptionAdd": "Tap to add a description",
"studysetSheetInheritColor": "Same colour as the studyset it is in", "studysetSheetDescriptionAdded": "Description added — tap to edit",
"studysetSheetInheritColor": "Inherit",
"fieldRequired": "required",
"exploreTitle": "Explore", "exploreTitle": "Explore",
"exploreEmpty": "Studysets that others share will appear here.", "exploreEmpty": "Studysets that others share will appear here.",
@ -677,8 +689,9 @@
"homeHideWidget": "Hide {label}", "homeHideWidget": "Hide {label}",
"@homeHideWidget": {"placeholders": {"label": {"type": "String"}}}, "@homeHideWidget": {"placeholders": {"label": {"type": "String"}}},
"homeAllHidden": "All widgets are hidden", "homeAllHidden": "All widgets are hidden",
"homeAllHiddenHint": "Long-press here to bring some back.", "homeAllHiddenBody": "Bring some back to fill your dashboard.",
"homeWidgetGoal": "Study goal", "homeCustomise": "Customise",
"homeWidgetGoal": "Daily goal",
"homeWidgetWeek": "Week overview", "homeWidgetWeek": "Week overview",
"homeWidgetActivity": "Activity heatmap", "homeWidgetActivity": "Activity heatmap",
"homeActivityTitle": "Your activity", "homeActivityTitle": "Your activity",
@ -713,13 +726,11 @@
"weekPreviousDay": "Previous day", "weekPreviousDay": "Previous day",
"weekNextDay": "Next day", "weekNextDay": "Next day",
"weekRestDay": "Rest day", "weekRestDay": "Rest day",
"weekDueCount": "{count, plural, =1{1 card due} other{{count} cards due}}",
"@weekDueCount": {"placeholders": {"count": {"type": "int"}}},
"heatmapLess": "Less", "heatmapLess": "Less",
"heatmapMore": "More", "heatmapMore": "More",
"heatmapTotal": "{count, plural, =1{1 activity in the last year} other{{count} activities in the last year}}", "heatmapTotal": "{count, plural, =1{1 activity in the last year} other{{count} activities in the last year}}",
"@heatmapTotal": {"placeholders": {"count": {"type": "int"}}}, "@heatmapTotal": {"placeholders": {"count": {"type": "int"}}},
"heatmapDay": "{count, plural, =0{Nothing} =1{1 activity} other{{count} activities}} · {date}", "heatmapDay": "{count, plural, =1{1 activity} other{{count} activities}} · {date}",
"@heatmapDay": {"placeholders": {"count": {"type": "int"}, "date": {"type": "String"}}}, "@heatmapDay": {"placeholders": {"count": {"type": "int"}, "date": {"type": "String"}}},
"commonCancel": "Cancel", "commonCancel": "Cancel",
@ -1006,7 +1017,7 @@
"readerCaptureFailed": "The page couldn't be drawn. Try again once it has loaded.", "readerCaptureFailed": "The page couldn't be drawn. Try again once it has loaded.",
"readerScreenshotSaved": "Screenshot saved", "readerScreenshotSaved": "Screenshot saved",
"imageOpenPdfPage": "Open the PDF at this page", "imageOpenPdfPage": "Open the PDF at this page",
"homeWidgetDeadlines": "Reading deadlines", "homeWidgetDeadlines": "PDF reading deadlines",
"deadlinesTitle": "Reading deadlines", "deadlinesTitle": "Reading deadlines",
"deadlinesCurrent": "Current", "deadlinesCurrent": "Current",
"deadlinesUpcoming": "Upcoming", "deadlinesUpcoming": "Upcoming",

View file

@ -3,7 +3,7 @@
"studysetSheetColor": "Color", "studysetSheetColor": "Color",
"colorPickerLabel": "Color: {color}", "colorPickerLabel": "Color: {color}",
"studysetSheetInheritColor": "Same color as the studyset it is in",
"tagSheetColor": "Color", "tagSheetColor": "Color",
"formatAlignCenter": "Center" "formatAlignCenter": "Center",
"homeCustomise": "Customize"
} }

View file

@ -271,7 +271,7 @@ abstract class AppLocalizations {
/// No description provided for @studysetSheetSecondaryHelper. /// No description provided for @studysetSheetSecondaryHelper.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Shown as a small label above the title.'** /// **'Shown as a small label above the title on cards and the studyset\'s header. Paths use it to label tiers (Challenge, Milestone, Lesson).'**
String get studysetSheetSecondaryHelper; String get studysetSheetSecondaryHelper;
/// No description provided for @studysetSheetColor. /// No description provided for @studysetSheetColor.
@ -322,6 +322,24 @@ abstract class AppLocalizations {
/// **'{count, plural, =1{1 note} other{{count} notes}}'** /// **'{count, plural, =1{1 note} other{{count} notes}}'**
String studysetCountNotes(int count); String studysetCountNotes(int count);
/// No description provided for @studysetCountFiles.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 file} other{{count} files}}'**
String studysetCountFiles(int count);
/// No description provided for @libraryRefresh.
///
/// In en, this message translates to:
/// **'Refresh'**
String get libraryRefresh;
/// No description provided for @libraryRetry.
///
/// In en, this message translates to:
/// **'Retry'**
String get libraryRetry;
/// No description provided for @studysetNoContent. /// No description provided for @studysetNoContent.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -1459,9 +1477,39 @@ abstract class AppLocalizations {
/// No description provided for @studysetMoveCurrent. /// No description provided for @studysetMoveCurrent.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Where it is now'** /// **'(current)'**
String get studysetMoveCurrent; String get studysetMoveCurrent;
/// No description provided for @studysetMoveTopLevelRow.
///
/// In en, this message translates to:
/// **'Top level (no parent)'**
String get studysetMoveTopLevelRow;
/// No description provided for @studysetMoveArchived.
///
/// In en, this message translates to:
/// **'Archived'**
String get studysetMoveArchived;
/// No description provided for @studysetMoveNew.
///
/// In en, this message translates to:
/// **'New studyset'**
String get studysetMoveNew;
/// No description provided for @studysetMoveSelectDestination.
///
/// In en, this message translates to:
/// **'Select a destination'**
String get studysetMoveSelectDestination;
/// No description provided for @studysetMoveDestination.
///
/// In en, this message translates to:
/// **'Destination: {name}'**
String studysetMoveDestination(String name);
/// No description provided for @studysetMoveConfirm. /// No description provided for @studysetMoveConfirm.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -1471,20 +1519,20 @@ abstract class AppLocalizations {
/// No description provided for @studysetMoveShowInside. /// No description provided for @studysetMoveShowInside.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Show what is inside'** /// **'Show sub-studysets'**
String get studysetMoveShowInside; String get studysetMoveShowInside;
/// No description provided for @studysetMoveHideInside. /// No description provided for @studysetMoveHideInside.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Hide what is inside'** /// **'Hide sub-studysets'**
String get studysetMoveHideInside; String get studysetMoveHideInside;
/// No description provided for @studysetMoved. /// No description provided for @studysetMoved.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Moved to {destination}.'** /// **'Moved \"{title}\" to {destination}.'**
String studysetMoved(String destination); String studysetMoved(String title, String destination);
/// No description provided for @studysetReadMore. /// No description provided for @studysetReadMore.
/// ///
@ -1519,15 +1567,27 @@ abstract class AppLocalizations {
/// No description provided for @studysetSheetDescriptionAdd. /// No description provided for @studysetSheetDescriptionAdd.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add a description'** /// **'Tap to add a description'**
String get studysetSheetDescriptionAdd; String get studysetSheetDescriptionAdd;
/// No description provided for @studysetSheetDescriptionAdded.
///
/// In en, this message translates to:
/// **'Description added — tap to edit'**
String get studysetSheetDescriptionAdded;
/// No description provided for @studysetSheetInheritColor. /// No description provided for @studysetSheetInheritColor.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Same colour as the studyset it is in'** /// **'Inherit'**
String get studysetSheetInheritColor; String get studysetSheetInheritColor;
/// No description provided for @fieldRequired.
///
/// In en, this message translates to:
/// **'required'**
String get fieldRequired;
/// No description provided for @exploreTitle. /// No description provided for @exploreTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -3610,16 +3670,22 @@ abstract class AppLocalizations {
/// **'All widgets are hidden'** /// **'All widgets are hidden'**
String get homeAllHidden; String get homeAllHidden;
/// No description provided for @homeAllHiddenHint. /// No description provided for @homeAllHiddenBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Long-press here to bring some back.'** /// **'Bring some back to fill your dashboard.'**
String get homeAllHiddenHint; String get homeAllHiddenBody;
/// No description provided for @homeCustomise.
///
/// In en, this message translates to:
/// **'Customise'**
String get homeCustomise;
/// No description provided for @homeWidgetGoal. /// No description provided for @homeWidgetGoal.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Study goal'** /// **'Daily goal'**
String get homeWidgetGoal; String get homeWidgetGoal;
/// No description provided for @homeWidgetWeek. /// No description provided for @homeWidgetWeek.
@ -3808,12 +3874,6 @@ abstract class AppLocalizations {
/// **'Rest day'** /// **'Rest day'**
String get weekRestDay; String get weekRestDay;
/// No description provided for @weekDueCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 card due} other{{count} cards due}}'**
String weekDueCount(int count);
/// No description provided for @heatmapLess. /// No description provided for @heatmapLess.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -3835,7 +3895,7 @@ abstract class AppLocalizations {
/// No description provided for @heatmapDay. /// No description provided for @heatmapDay.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count, plural, =0{Nothing} =1{1 activity} other{{count} activities}} · {date}'** /// **'{count, plural, =1{1 activity} other{{count} activities}} · {date}'**
String heatmapDay(int count, String date); String heatmapDay(int count, String date);
/// No description provided for @commonCancel. /// No description provided for @commonCancel.
@ -5287,7 +5347,7 @@ abstract class AppLocalizations {
/// No description provided for @homeWidgetDeadlines. /// No description provided for @homeWidgetDeadlines.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Reading deadlines'** /// **'PDF reading deadlines'**
String get homeWidgetDeadlines; String get homeWidgetDeadlines;
/// No description provided for @deadlinesTitle. /// No description provided for @deadlinesTitle.

View file

@ -98,7 +98,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get studysetSheetSecondaryHelper => String get studysetSheetSecondaryHelper =>
'Wird als kleines Label über dem Titel angezeigt.'; 'Wird als kleines Label über dem Titel auf Karten und im Kopf des Lernsets angezeigt. Pfade nutzen es, um Stufen zu kennzeichnen (Challenge, Meilenstein, Lektion).';
@override @override
String get studysetSheetColor => 'Farbe'; String get studysetSheetColor => 'Farbe';
@ -171,6 +171,23 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String studysetCountFiles(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Dateien',
one: '1 Datei',
);
return '$_temp0';
}
@override
String get libraryRefresh => 'Aktualisieren';
@override
String get libraryRetry => 'Erneut versuchen';
@override @override
String get studysetNoContent => 'Noch kein Inhalt'; String get studysetNoContent => 'Noch kein Inhalt';
@ -845,20 +862,37 @@ class AppLocalizationsDe extends AppLocalizations {
String get studysetMoveTopLevel => 'Oberste Ebene'; String get studysetMoveTopLevel => 'Oberste Ebene';
@override @override
String get studysetMoveCurrent => 'Aktueller Ort'; String get studysetMoveCurrent => '(aktuell)';
@override
String get studysetMoveTopLevelRow => 'Oberste Ebene (in keinem Lernset)';
@override
String get studysetMoveArchived => 'Archiviert';
@override
String get studysetMoveNew => 'Neues Lernset';
@override
String get studysetMoveSelectDestination => 'Wähle ein Ziel';
@override
String studysetMoveDestination(String name) {
return 'Ziel: $name';
}
@override @override
String get studysetMoveConfirm => 'Verschieben'; String get studysetMoveConfirm => 'Verschieben';
@override @override
String get studysetMoveShowInside => 'Inhalt anzeigen'; String get studysetMoveShowInside => 'Unter-Lernsets anzeigen';
@override @override
String get studysetMoveHideInside => 'Inhalt ausblenden'; String get studysetMoveHideInside => 'Unter-Lernsets ausblenden';
@override @override
String studysetMoved(String destination) { String studysetMoved(String title, String destination) {
return 'Nach „$destination“ verschoben.'; return '„$title“ nach $destination verschoben.';
} }
@override @override
@ -877,11 +911,18 @@ class AppLocalizationsDe extends AppLocalizations {
String get studysetSheetDescription => 'Beschreibung'; String get studysetSheetDescription => 'Beschreibung';
@override @override
String get studysetSheetDescriptionAdd => 'Beschreibung hinzufügen'; String get studysetSheetDescriptionAdd =>
'Tippen, um eine Beschreibung hinzuzufügen';
@override @override
String get studysetSheetInheritColor => String get studysetSheetDescriptionAdded =>
'Gleiche Farbe wie das Lernset, in dem es liegt'; 'Beschreibung hinzugefügt — zum Bearbeiten tippen';
@override
String get studysetSheetInheritColor => 'Erben';
@override
String get fieldRequired => 'Pflichtfeld';
@override @override
String get exploreTitle => 'Entdecken'; String get exploreTitle => 'Entdecken';
@ -2179,11 +2220,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get homeAllHidden => 'Alle Widgets sind ausgeblendet'; String get homeAllHidden => 'Alle Widgets sind ausgeblendet';
@override @override
String get homeAllHiddenHint => String get homeAllHiddenBody =>
'Halte hier lange gedrückt, um welche zurückzuholen.'; 'Blende einige wieder ein, um dein Dashboard zu füllen.';
@override @override
String get homeWidgetGoal => 'Lernziel'; String get homeCustomise => 'Anpassen';
@override
String get homeWidgetGoal => 'Tagesziel';
@override @override
String get homeWidgetWeek => 'Wochenübersicht'; String get homeWidgetWeek => 'Wochenübersicht';
@ -2299,17 +2343,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get weekRestDay => 'Ruhetag'; String get weekRestDay => 'Ruhetag';
@override
String weekDueCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karten fällig',
one: '1 Karte fällig',
);
return '$_temp0';
}
@override @override
String get heatmapLess => 'Weniger'; String get heatmapLess => 'Weniger';
@ -2334,7 +2367,6 @@ class AppLocalizationsDe extends AppLocalizations {
locale: localeName, locale: localeName,
other: '$count Aktivitäten', other: '$count Aktivitäten',
one: '1 Aktivität', one: '1 Aktivität',
zero: 'Nichts',
); );
return '$_temp0 · $date'; return '$_temp0 · $date';
} }
@ -3167,7 +3199,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get imageOpenPdfPage => 'PDF auf dieser Seite öffnen'; String get imageOpenPdfPage => 'PDF auf dieser Seite öffnen';
@override @override
String get homeWidgetDeadlines => 'Lesefristen'; String get homeWidgetDeadlines => 'PDF-Lesefristen';
@override @override
String get deadlinesTitle => 'Lesefristen'; String get deadlinesTitle => 'Lesefristen';

View file

@ -96,7 +96,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get studysetSheetSecondaryHelper => String get studysetSheetSecondaryHelper =>
'Shown as a small label above the title.'; 'Shown as a small label above the title on cards and the studyset\'s header. Paths use it to label tiers (Challenge, Milestone, Lesson).';
@override @override
String get studysetSheetColor => 'Colour'; String get studysetSheetColor => 'Colour';
@ -169,6 +169,23 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String studysetCountFiles(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count files',
one: '1 file',
);
return '$_temp0';
}
@override
String get libraryRefresh => 'Refresh';
@override
String get libraryRetry => 'Retry';
@override @override
String get studysetNoContent => 'No content yet'; String get studysetNoContent => 'No content yet';
@ -840,20 +857,37 @@ class AppLocalizationsEn extends AppLocalizations {
String get studysetMoveTopLevel => 'Top level'; String get studysetMoveTopLevel => 'Top level';
@override @override
String get studysetMoveCurrent => 'Where it is now'; String get studysetMoveCurrent => '(current)';
@override
String get studysetMoveTopLevelRow => 'Top level (no parent)';
@override
String get studysetMoveArchived => 'Archived';
@override
String get studysetMoveNew => 'New studyset';
@override
String get studysetMoveSelectDestination => 'Select a destination';
@override
String studysetMoveDestination(String name) {
return 'Destination: $name';
}
@override @override
String get studysetMoveConfirm => 'Move'; String get studysetMoveConfirm => 'Move';
@override @override
String get studysetMoveShowInside => 'Show what is inside'; String get studysetMoveShowInside => 'Show sub-studysets';
@override @override
String get studysetMoveHideInside => 'Hide what is inside'; String get studysetMoveHideInside => 'Hide sub-studysets';
@override @override
String studysetMoved(String destination) { String studysetMoved(String title, String destination) {
return 'Moved to $destination.'; return 'Moved \"$title\" to $destination.';
} }
@override @override
@ -872,11 +906,16 @@ class AppLocalizationsEn extends AppLocalizations {
String get studysetSheetDescription => 'Description'; String get studysetSheetDescription => 'Description';
@override @override
String get studysetSheetDescriptionAdd => 'Add a description'; String get studysetSheetDescriptionAdd => 'Tap to add a description';
@override @override
String get studysetSheetInheritColor => String get studysetSheetDescriptionAdded => 'Description added — tap to edit';
'Same colour as the studyset it is in';
@override
String get studysetSheetInheritColor => 'Inherit';
@override
String get fieldRequired => 'required';
@override @override
String get exploreTitle => 'Explore'; String get exploreTitle => 'Explore';
@ -2166,10 +2205,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get homeAllHidden => 'All widgets are hidden'; String get homeAllHidden => 'All widgets are hidden';
@override @override
String get homeAllHiddenHint => 'Long-press here to bring some back.'; String get homeAllHiddenBody => 'Bring some back to fill your dashboard.';
@override @override
String get homeWidgetGoal => 'Study goal'; String get homeCustomise => 'Customise';
@override
String get homeWidgetGoal => 'Daily goal';
@override @override
String get homeWidgetWeek => 'Week overview'; String get homeWidgetWeek => 'Week overview';
@ -2283,17 +2325,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get weekRestDay => 'Rest day'; String get weekRestDay => 'Rest day';
@override
String weekDueCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count cards due',
one: '1 card due',
);
return '$_temp0';
}
@override @override
String get heatmapLess => 'Less'; String get heatmapLess => 'Less';
@ -2318,7 +2349,6 @@ class AppLocalizationsEn extends AppLocalizations {
locale: localeName, locale: localeName,
other: '$count activities', other: '$count activities',
one: '1 activity', one: '1 activity',
zero: 'Nothing',
); );
return '$_temp0 · $date'; return '$_temp0 · $date';
} }
@ -3140,7 +3170,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get imageOpenPdfPage => 'Open the PDF at this page'; String get imageOpenPdfPage => 'Open the PDF at this page';
@override @override
String get homeWidgetDeadlines => 'Reading deadlines'; String get homeWidgetDeadlines => 'PDF reading deadlines';
@override @override
String get deadlinesTitle => 'Reading deadlines'; String get deadlinesTitle => 'Reading deadlines';
@ -3681,12 +3711,12 @@ class AppLocalizationsEnUs extends AppLocalizationsEn {
return 'Color: $color'; return 'Color: $color';
} }
@override
String get studysetSheetInheritColor => 'Same color as the studyset it is in';
@override @override
String get tagSheetColor => 'Color'; String get tagSheetColor => 'Color';
@override @override
String get formatAlignCenter => 'Center'; String get formatAlignCenter => 'Center';
@override
String get homeCustomise => 'Customize';
} }

View file

@ -0,0 +1,26 @@
import 'dart:async';
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
/// Syncs now through [bloc] and completes once that sync is over, however it
/// went, or after [timeout]: what a pull to refresh shows its spinner for.
Future<void> syncAndWait(
SyncStatusBloc bloc, {
Duration timeout = const Duration(seconds: 30),
}) {
final done = Completer<void>();
var started = false;
final subscription = bloc.stream.listen((status) {
if (status.syncing) {
started = true;
} else if (started && !done.isCompleted) {
done.complete();
}
});
bloc.add(const SyncStatusEventSyncRequested());
return done.future
.timeout(timeout, onTimeout: () {})
// Not waited for: a broadcast stream's cancel never completes under a
// widget test's fake clock.
.whenComplete(() => unawaited(subscription.cancel()));
}

View file

@ -0,0 +1,71 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A number between a minus and a plus, the width of what holds it, in an
/// outlined box: the number bold ([style], body text by default), [suffix]
/// (" / day") lighter after it. A null action can't go further.
class const CountStepper({
required final String value,
required final VoidCallback? onFewer,
required final VoidCallback? onMore,
/// Said after the number, lighter.
final String suffix = '',
/// The number's style, before it is made bold.
final TextStyle? style,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
IconButton(
tooltip: l10n.studyFewer,
onPressed: onFewer,
icon: const Icon(LucideIcons.minus),
),
Expanded(
child: Text.rich(
TextSpan(
text: value,
children: [
if (suffix.isNotEmpty)
TextSpan(
text: suffix,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
),
],
),
textAlign: TextAlign.center,
style: (style ?? theme.textTheme.bodyLarge)?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: l10n.studyMore,
onPressed: onMore,
icon: const Icon(LucideIcons.plus),
),
],
),
),
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A field's label marked as required: [text] and a lighter asterisk, which
/// screen readers announce as "required".
class const RequiredFieldLabel(final String text, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => Semantics(
label: '$text, ${AppLocalizations.of(context).fieldRequired}',
excludeSemantics: true,
child: Text.rich(
TextSpan(
text: text,
children: [
TextSpan(
text: ' *',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
/// A question mark at a field's end that explains it: [message] shows on a
/// tap (or hovering) and stays for a while to be read.
class const FieldInfoButton(final String message, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => Tooltip(
message: message,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
LucideIcons.circleQuestionMark,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}

View file

@ -1,13 +1,13 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// A widget of the home dashboard. /// A widget of the home dashboard, in the order a new dashboard shows them.
enum HomeWidget() { enum HomeWidget() {
/// The Pomodoro timer.
pomodoro,
/// The study goal. /// The study goal.
goal, goal,
/// The Pomodoro timer.
pomodoro,
/// The reading deadlines of PDFs; it shows only while there are some. /// The reading deadlines of PDFs; it shows only while there are some.
deadlines, deadlines,

View file

@ -1,13 +1,14 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/week_overview.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// A year of studying as a grid, like the contribution grids of code /// A year of studying as a grid, like the contribution grids of code
/// hosts: a column per week, Monday to Sunday, each day as dark as what /// hosts: a column per week, Monday to Sunday, each day as dark as what
/// was studied on it, with the months on top and every other weekday on /// was studied on it, with the months on top and every other weekday on
/// the left. Centred when it fits, scrolling sideways otherwise, the latest /// the left. Centred when it fits, scrolling sideways otherwise, weekdays
/// weeks in view first. /// and all, the latest weeks in view first. Days to come are outlined.
class const ActivityHeatmap({ class const ActivityHeatmap({
/// What was studied, by day. /// What was studied, by day.
required final StudyActivity activity, required final StudyActivity activity,
@ -104,45 +105,32 @@ class const ActivityHeatmap({
Row( Row(
children: [ children: [
for (final monday in mondays) for (final monday in mondays)
Padding( Column(
padding: const EdgeInsets.only(right: _gap), children: [
child: Column( for (var row = 0; row < 7; row++)
children: [ _cellFor(context, addDays(monday, row)),
for (var row = 0; row < 7; row++) ],
_cellFor(context, addDays(monday, row)),
],
),
), ),
], ],
), ),
], ],
); );
final grid = Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [weekdays, const SizedBox(width: 6), weeksGrid],
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// The weekdays stay in view while the weeks scroll.
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) => builder: (context, constraints) =>
_weekdaysWidth + 6 + weeks * _step <= constraints.maxWidth _weekdaysWidth + 6 + weeks * _step <= constraints.maxWidth
? Center( ? Center(child: grid)
child: Row( : SingleChildScrollView(
mainAxisSize: MainAxisSize.min, scrollDirection: Axis.horizontal,
children: [weekdays, const SizedBox(width: 6), weeksGrid], reverse: true,
), child: grid,
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
weekdays,
const SizedBox(width: 6),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: weeksGrid,
),
),
],
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@ -150,18 +138,18 @@ class const ActivityHeatmap({
children: [ children: [
Expanded(child: Text(l10n.heatmapTotal(total), style: muted)), Expanded(child: Text(l10n.heatmapTotal(total), style: muted)),
Text(l10n.heatmapLess, style: muted), Text(l10n.heatmapLess, style: muted),
const SizedBox(width: 4), const SizedBox(width: 6),
for (var level = 0; level <= 4; level++) for (var level = 0; level <= 4; level++)
Container( Container(
width: 10, width: 12,
height: 10, height: 12,
margin: const EdgeInsets.symmetric(horizontal: 1.5), margin: const EdgeInsets.symmetric(horizontal: 1.5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorOf(theme, level), color: colorOf(theme, level),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(3),
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 6),
Text(l10n.heatmapMore, style: muted), Text(l10n.heatmapMore, style: muted),
], ],
), ),
@ -173,13 +161,23 @@ class const ActivityHeatmap({
final theme = Theme.of(context); final theme = Theme.of(context);
final future = day.isAfter(today); final future = day.isAfter(today);
final count = activity.on(day); final count = activity.on(day);
final cell = Container( // Centred in its slot of the grid, the gap shared on every side.
width: _cell, final cell = Padding(
height: _cell, padding: const EdgeInsets.all(_gap / 2),
margin: const EdgeInsets.only(bottom: _gap), child: Container(
decoration: BoxDecoration( width: _cell,
color: future ? Colors.transparent : colorOf(theme, heatLevel(count)), height: _cell,
borderRadius: BorderRadius.circular(3), decoration: BoxDecoration(
color: future ? Colors.transparent : colorOf(theme, heatLevel(count)),
borderRadius: BorderRadius.circular(3),
border: future
? Border.all(
color: theme.colorScheme.outlineVariant.withValues(
alpha: 0.4,
),
)
: null,
),
), ),
); );
if (future) { if (future) {
@ -189,7 +187,8 @@ class const ActivityHeatmap({
return Tooltip( return Tooltip(
message: AppLocalizations.of( message: AppLocalizations.of(
context, context,
).heatmapDay(count, DateFormat.yMMMd(locale).format(day)), ).heatmapDay(count, weekdayAndDate(locale, day)),
waitDuration: const Duration(milliseconds: 300),
child: cell, child: cell,
); );
} }

View file

@ -2,14 +2,14 @@ import 'dart:math' as math;
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart'; import 'package:halkyon_learn_flutter/src/core/widget/count_stepper.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// The study goal: a ring of how far the period got, with what is still to /// The study goal: a ring of how far the period got, with what is still to
/// go; without a goal, an invitation to set one. Tapping opens the goal's /// go and a pencil to change it; without a goal, an invitation to set one.
/// sheet. /// Tapping opens the goal's sheet.
class const GoalCard({ class const GoalCard({
/// The goal; null without one. /// The goal; null without one.
required final StudyGoal? goal, required final StudyGoal? goal,
@ -25,13 +25,14 @@ class const GoalCard({
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme;
final goal = this.goal; final goal = this.goal;
final muted = theme.textTheme.bodyMedium?.copyWith( final small = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: colors.onSurfaceVariant,
); );
return SectionCard( return Card(
padding: EdgeInsets.zero,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async { onTap: () async {
final result = await showAdaptiveSheet<_GoalResult>( final result = await showAdaptiveSheet<_GoalResult>(
context, context,
@ -46,7 +47,7 @@ class const GoalCard({
child: goal == null child: goal == null
? Row( ? Row(
children: [ children: [
Icon(LucideIcons.target, color: theme.colorScheme.primary), Icon(LucideIcons.target, color: colors.primary),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@ -57,10 +58,11 @@ class const GoalCard({
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(l10n.goalEmptySubtitle, style: muted), Text(l10n.goalEmptySubtitle, style: small),
], ],
), ),
), ),
const SizedBox(width: 8),
const Icon(LucideIcons.chevronRight), const Icon(LucideIcons.chevronRight),
], ],
) )
@ -86,26 +88,39 @@ class const GoalCard({
progress, progress,
goal.target, goal.target,
), ),
style: muted, style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
if (progress >= goal.target) if (progress >= goal.target)
Text( Row(
l10n.goalMet, children: [
style: theme.textTheme.bodySmall?.copyWith( Icon(
color: theme.colorScheme.primary, LucideIcons.circleCheck,
fontWeight: FontWeight.w600, size: 16,
), color: colors.primary,
),
const SizedBox(width: 4),
Text(
l10n.goalMet,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w600,
),
),
],
) )
else else
Text( Text(
l10n.goalRemaining(goal.target - progress), l10n.goalRemaining(goal.target - progress),
style: theme.textTheme.bodySmall, style: small,
), ),
], ],
), ),
), ),
const Icon(LucideIcons.chevronRight), const SizedBox(width: 8),
Icon(LucideIcons.pencil, size: 18, color: colors.outline),
], ],
), ),
), ),
@ -120,20 +135,25 @@ class const _Ring({required final int progress, required final int target})
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return SizedBox( return SizedBox.square(
width: 56, dimension: 56,
height: 56,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
SizedBox.expand( SizedBox.expand(
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: math.min(1, progress / target), value: math.min(1, progress / target),
strokeWidth: 6, strokeWidth: 5,
backgroundColor: theme.colorScheme.surfaceContainerHighest, backgroundColor: theme.colorScheme.surfaceContainerHighest,
color: theme.colorScheme.primary,
),
),
Text(
'$progress',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
), ),
), ),
Text('$progress', style: theme.textTheme.titleMedium),
], ],
), ),
); );
@ -143,8 +163,8 @@ class const _Ring({required final int progress, required final int target})
/// What the goal sheet ends with: the goal set, or null once removed. /// What the goal sheet ends with: the goal set, or null once removed.
typedef _GoalResult = ({StudyGoal? goal}); typedef _GoalResult = ({StudyGoal? goal});
/// Setting the study goal: a day or a week, and how many cards; removing an /// Setting the study goal: a day or a week, and how many cards; saving
/// existing one. /// across the sheet's width, and removing an existing one below it.
class const _GoalSheet({required final StudyGoal? initial}) class const _GoalSheet({required final StudyGoal? initial})
extends StatefulWidget { extends StatefulWidget {
@override @override
@ -166,29 +186,35 @@ class _GoalSheetState() extends State<_GoalSheet> {
final daily = _period == GoalPeriod.day; final daily = _period == GoalPeriod.day;
return SheetScaffold( return SheetScaffold(
title: widget.initial == null ? l10n.goalSetTitle : l10n.goalEditTitle, title: widget.initial == null ? l10n.goalSetTitle : l10n.goalEditTitle,
footer: Row( footer: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (widget.initial != null)
TextButton(
onPressed: () => Navigator.of(context).pop((goal: null)),
child: Text(l10n.goalRemove),
),
const Spacer(),
FilledButton( FilledButton(
onPressed: () => Navigator.of( onPressed: () => Navigator.of(
context, context,
).pop((goal: StudyGoal(period: _period, target: _target))), ).pop((goal: StudyGoal(period: _period, target: _target))),
child: Text(l10n.goalSave), child: Text(l10n.goalSave),
), ),
if (widget.initial != null) ...[
const SizedBox(height: 8),
TextButton(
onPressed: () => Navigator.of(context).pop((goal: null)),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
child: Text(l10n.goalRemove),
),
],
], ],
), ),
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 4),
Text( Text(
daily ? l10n.goalDescriptionDaily : l10n.goalDescriptionWeekly, daily ? l10n.goalDescriptionDaily : l10n.goalDescriptionWeekly,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
@ -207,7 +233,6 @@ class _GoalSheetState() extends State<_GoalSheet> {
), ),
], ],
selected: {_period}, selected: {_period},
showSelectedIcon: false,
onSelectionChanged: (selection) => onSelectionChanged: (selection) =>
setState(() => _period = selection.single), setState(() => _period = selection.single),
), ),
@ -217,33 +242,16 @@ class _GoalSheetState() extends State<_GoalSheet> {
style: theme.textTheme.titleSmall, style: theme.textTheme.titleSmall,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( CountStepper(
children: [ value: '$_target',
IconButton.outlined( style: theme.textTheme.titleMedium,
tooltip: l10n.studyFewer, onFewer: _target > StudyGoal.minTarget
onPressed: _target > StudyGoal.minTarget ? () => _setTarget(_target - 1)
? () => _setTarget(_target - 1) : null,
: null, onMore: _target < StudyGoal.maxTarget
icon: const Icon(LucideIcons.minus), ? () => _setTarget(_target + 1)
), : null,
SizedBox(
width: 96,
child: Text(
'$_target',
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge,
),
),
IconButton.outlined(
tooltip: l10n.studyMore,
onPressed: _target < StudyGoal.maxTarget
? () => _setTarget(_target + 1)
: null,
icon: const Icon(LucideIcons.plus),
),
],
), ),
const SizedBox(height: 16),
], ],
), ),
); );

View file

@ -1,3 +1,4 @@
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
@ -7,7 +8,6 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/app_mismatch_banner.dart'; import 'package:halkyon_learn_flutter/src/core/sync/widget/app_mismatch_banner.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart'; import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
@ -55,10 +55,17 @@ class _HomeState() extends State<_Home> {
void _send(HomeEvent event) => context.read<HomeBloc>().add(event); void _send(HomeEvent event) => context.read<HomeBloc>().add(event);
/// Starts arranging the dashboard, with a tap the hand can feel.
void _arrange() {
if (!_arranging) {
HapticFeedback.mediumImpact();
setState(() => _arranging = true);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return BlocBuilder<HomeBloc, HomeState>( return BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) { builder: (context, state) {
final loaded = switch (state) { final loaded = switch (state) {
@ -74,7 +81,6 @@ class _HomeState() extends State<_Home> {
} }
}, },
child: Scaffold( child: Scaffold(
backgroundColor: theme.colorScheme.surfaceContainerLow,
appBar: _arranging appBar: _arranging
? AppBar( ? AppBar(
leading: IconButton( leading: IconButton(
@ -112,7 +118,7 @@ class _HomeState() extends State<_Home> {
tooltip: l10n.settingsTitle, tooltip: l10n.settingsTitle,
onPressed: () => context.push(RoutePaths.settings), onPressed: () => context.push(RoutePaths.settings),
), ),
const SizedBox(width: 4), const SizedBox(width: 8),
], ],
), ),
body: Column( body: Column(
@ -142,29 +148,34 @@ class _HomeState() extends State<_Home> {
onChanged: (goal) => _send(HomeEventGoalSet(goal)), onChanged: (goal) => _send(HomeEventGoalSet(goal)),
), ),
HomeWidget.deadlines => ReadingDeadlinesCard(placeholder: _arranging), HomeWidget.deadlines => ReadingDeadlinesCard(placeholder: _arranging),
HomeWidget.week => SectionCard( HomeWidget.week => Card(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 12), child: Padding(
child: WeekOverview( padding: const EdgeInsets.fromLTRB(4, 12, 4, 12),
selected: selected, child: WeekOverview(
today: today, selected: selected,
week: weekOf(selected, state.activity, state.restDays, state.now), today: today,
due: state.due, week: weekOf(selected, state.activity, state.restDays, state.now),
restDays: state.restDays, due: state.due,
onSelect: (day) => setState(() => _selected = day), restDays: state.restDays,
onToggleRestDay: () => _send(HomeEventRestDayToggled(selected)), onSelect: (day) => setState(() => _selected = day),
onToggleRestDay: () => _send(HomeEventRestDayToggled(selected)),
),
), ),
), ),
HomeWidget.activity => SectionCard( HomeWidget.activity => Card(
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(16),
children: [ child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
l10n.homeActivityTitle, children: [
style: Theme.of(context).textTheme.titleMedium, Text(
), l10n.homeActivityTitle,
const SizedBox(height: 12), style: Theme.of(context).textTheme.titleMedium,
ActivityHeatmap(activity: state.activity, today: today), ),
], const SizedBox(height: 12),
ActivityHeatmap(activity: state.activity, today: today),
],
),
), ),
), ),
}; };
@ -180,36 +191,24 @@ class _HomeState() extends State<_Home> {
onChanged: (layout) => _send(HomeEventLayoutChanged(layout)), onChanged: (layout) => _send(HomeEventLayoutChanged(layout)),
) )
: layout.shown.isEmpty : layout.shown.isEmpty
? GestureDetector( ? _AllHidden(onArrange: _arrange)
behavior: HitTestBehavior.opaque,
onLongPress: () => setState(() => _arranging = true),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.homeAllHidden,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(l10n.homeAllHiddenHint),
],
),
),
)
: ListView( : ListView(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(16),
children: [ children: [
for (final widget in layout.shown) for (final widget in layout.shown)
GestureDetector( GestureDetector(
key: ValueKey(widget), key: ValueKey(widget),
// Taps still reach the widget's own controls. // Taps still reach the widget's own controls.
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onLongPress: () => setState(() => _arranging = true), onLongPress: _arrange,
child: Padding( // The reading deadlines leave no gap while they show
padding: const EdgeInsets.only(bottom: 12), // nothing: they keep their own.
child: section(widget), child: widget == HomeWidget.deadlines
), ? section(widget)
: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: section(widget),
),
), ),
], ],
), ),
@ -218,9 +217,51 @@ class _HomeState() extends State<_Home> {
} }
} }
/// With every widget hidden: what happened, and a way back to arranging.
class const _AllHidden({required final VoidCallback onArrange})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.layoutGrid,
size: 40,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(l10n.homeAllHidden, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
l10n.homeAllHiddenBody,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
FilledButton.tonalIcon(
onPressed: onArrange,
icon: const Icon(LucideIcons.eye, size: 18),
label: Text(l10n.homeCustomise),
),
],
),
),
);
}
}
/// The dashboard being arranged: every widget, hidden ones greyed, each /// The dashboard being arranged: every widget, hidden ones greyed, each
/// with a handle to drag it elsewhere and an eye to hide or show it; the /// with a handle beside it to drag it elsewhere and an eye on its corner to
/// widgets themselves don't react meanwhile. /// hide or show it; the widgets themselves don't react meanwhile. A widget
/// picked up lifts a little, with a tap the hand can feel.
class const _Arranging({ class const _Arranging({
required final HomeLayout layout, required final HomeLayout layout,
required final Widget Function(HomeWidget widget) section, required final Widget Function(HomeWidget widget) section,
@ -229,16 +270,35 @@ class const _Arranging({
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
String label(HomeWidget widget) => switch (widget) { String label(HomeWidget widget) => switch (widget) {
HomeWidget.pomodoro => l10n.homeWidgetPomodoro, HomeWidget.pomodoro => l10n.pomodoroTitle,
HomeWidget.goal => l10n.homeWidgetGoal, HomeWidget.goal => l10n.homeWidgetGoal,
HomeWidget.deadlines => l10n.homeWidgetDeadlines, HomeWidget.deadlines => l10n.homeWidgetDeadlines,
HomeWidget.week => l10n.homeWidgetWeek, HomeWidget.week => l10n.homeWidgetWeek,
HomeWidget.activity => l10n.homeWidgetActivity, HomeWidget.activity => l10n.homeWidgetActivity,
}; };
return ReorderableListView( return ReorderableListView(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(16),
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
onReorderStart: (_) => HapticFeedback.mediumImpact(),
proxyDecorator: (child, index, animation) => AnimatedBuilder(
animation: animation,
child: child,
builder: (context, child) {
final lift = Curves.easeInOut.transform(animation.value);
return Transform.scale(
scale: 1 + 0.03 * lift,
child: Material(
color: Colors.transparent,
elevation: 8 * lift,
shadowColor: theme.colorScheme.shadow,
borderRadius: BorderRadius.circular(12),
child: child,
),
);
},
),
onReorderItem: (from, to) { onReorderItem: (from, to) {
final order = [...layout.order]; final order = [...layout.order];
final moved = order.removeAt(from); final moved = order.removeAt(from);
@ -249,42 +309,53 @@ class const _Arranging({
for (final (i, widget) in layout.order.indexed) for (final (i, widget) in layout.order.indexed)
Padding( Padding(
key: ValueKey(widget), key: ValueKey(widget),
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 16),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ReorderableDragStartListener( ReorderableDragStartListener(
index: i, index: i,
child: Tooltip( child: IconButton(
message: l10n.homeDragToReorder, tooltip: l10n.homeDragToReorder,
child: const Padding( onPressed: () {},
padding: EdgeInsets.all(12), icon: Icon(
child: Icon(LucideIcons.gripVertical), LucideIcons.gripVertical,
color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
), ),
Expanded( Expanded(
child: Opacity( child: Stack(
opacity: layout.hidden.contains(widget) ? 0.4 : 1, children: [
child: IgnorePointer(child: section(widget)), // Room at the top end for the eye on the corner.
), Padding(
), padding: const EdgeInsetsDirectional.only(
IconButton( top: 8,
tooltip: layout.hidden.contains(widget) end: 8,
? l10n.homeShowWidget(label(widget)) ),
: l10n.homeHideWidget(label(widget)), child: IgnorePointer(
icon: Icon( child: Opacity(
layout.hidden.contains(widget) opacity: layout.hidden.contains(widget) ? 0.4 : 1,
? LucideIcons.eyeOff child: section(widget),
: LucideIcons.eye, ),
), ),
onPressed: () => onChanged( ),
HomeLayout( PositionedDirectional(
order: layout.order, top: 0,
hidden: layout.hidden.contains(widget) end: 0,
? ({...layout.hidden}..remove(widget)) child: _EyeBadge(
: {...layout.hidden, widget}, hidden: layout.hidden.contains(widget),
), label: label(widget),
onTap: () => onChanged(
HomeLayout(
order: layout.order,
hidden: layout.hidden.contains(widget)
? ({...layout.hidden}..remove(widget))
: {...layout.hidden, widget},
),
),
),
),
],
), ),
), ),
], ],
@ -294,3 +365,41 @@ class const _Arranging({
); );
} }
} }
/// The eye on a widget's corner while arranging: open while it shows,
/// crossed out while hidden; a tap turns it.
class const _EyeBadge({
required final bool hidden,
required final String label,
required final VoidCallback onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final tooltip = hidden
? l10n.homeShowWidget(label)
: l10n.homeHideWidget(label);
return Tooltip(
message: tooltip,
child: Material(
color: theme.colorScheme.surface,
shape: const CircleBorder(),
elevation: 1,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
hidden ? LucideIcons.eyeOff : LucideIcons.eye,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
semanticLabel: tooltip,
),
),
),
),
);
}
}

View file

@ -25,35 +25,37 @@ class const StreakChip({
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final lit = streak > 0; final lit = streak > 0;
return Tooltip( return Center(
message: AppLocalizations.of(context).homeStreakTooltip, child: Tooltip(
child: InkWell( message: AppLocalizations.of(context).homeStreakTooltip,
borderRadius: BorderRadius.circular(20), child: InkWell(
onTap: () => showAdaptiveSheet<void>( borderRadius: BorderRadius.circular(20),
context, onTap: () => showAdaptiveSheet<void>(
builder: (context) => context,
_StreakSheet(streak: streak, week: week, today: today), builder: (context) =>
), _StreakSheet(streak: streak, week: week, today: today),
child: Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Padding(
child: Row( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
mainAxisSize: MainAxisSize.min, child: Row(
children: [ mainAxisSize: MainAxisSize.min,
Icon( children: [
LucideIcons.flame, Icon(
size: 20, LucideIcons.flame,
color: lit size: 20,
? streakFlameColor color: lit
: theme.colorScheme.onSurfaceVariant, ? streakFlameColor
), : theme.colorScheme.onSurfaceVariant,
const SizedBox(width: 4),
Text(
'$streak',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
), ),
), const SizedBox(width: 4),
], Text(
'$streak',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
), ),
), ),
), ),
@ -61,8 +63,10 @@ class const StreakChip({
} }
} }
/// The streak at large: a flame, how many days, what keeps it going, and /// The streak at large: a big flame with the days inside, what keeps it
/// this week's days. /// going, this week's days as round marks over their initials, and a word
/// on what to do today in a box below. No title: the flame says it; a close
/// button on the top end.
class const _StreakSheet({ class const _StreakSheet({
required final int streak, required final int streak,
required final List<WeekDay> week, required final List<WeekDay> week,
@ -72,162 +76,161 @@ class const _StreakSheet({
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme;
final studiedToday = week.any( final studiedToday = week.any(
(day) => day.state == DayState.studied && day.day == today, (day) => day.state == DayState.studied && day.day == today,
); );
return SheetScaffold( return SafeArea(
title: l10n.homeStreakTooltip, child: Padding(
body: Column( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
children: [ child: Column(
const SizedBox(height: 8), mainAxisSize: MainAxisSize.min,
Icon( children: [
LucideIcons.flame, Align(
size: 64, alignment: AlignmentDirectional.centerEnd,
color: streak > 0 child: IconButton(
? streakFlameColor tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
: theme.colorScheme.onSurfaceVariant, icon: const Icon(LucideIcons.x),
), onPressed: () => Navigator.of(context).pop(),
const SizedBox(height: 8), ),
Text(l10n.streakDays(streak), style: theme.textTheme.headlineSmall),
const SizedBox(height: 4),
Text(
streak == 0 ? l10n.streakStart : l10n.streakGrow,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
), ),
), SizedBox.square(
const SizedBox(height: 20), dimension: 96,
Row( child: Stack(
children: [ alignment: Alignment.center,
for (final day in week) Expanded(child: _StreakDay(day: day)), children: [
], Icon(
), LucideIcons.flame,
const SizedBox(height: 16), size: 88,
Text( color: streak > 0
streak == 0 ? streakFlameColor
? l10n.streakFirstDay : colors.onSurfaceVariant,
: studiedToday ),
? l10n.streakComeBack // In the flame's bulb.
: l10n.streakKeepAlive, Padding(
textAlign: TextAlign.center, padding: const EdgeInsets.only(top: 22),
style: theme.textTheme.bodySmall, child: Text(
), '$streak',
const SizedBox(height: 8), style: theme.textTheme.titleLarge?.copyWith(
], color: Colors.white,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
const SizedBox(height: 8),
Text(
l10n.streakDays(streak),
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
streak == 0 ? l10n.streakStart : l10n.streakGrow,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (final day in week)
_StreakDay(day: day, today: day.day == today),
],
),
const SizedBox(height: 20),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text(
streak == 0
? l10n.streakFirstDay
: studiedToday
? l10n.streakComeBack
: l10n.streakKeepAlive,
textAlign: TextAlign.center,
style: theme.textTheme.titleSmall,
),
),
],
),
), ),
); );
} }
} }
class const _StreakDay({required final WeekDay day}) extends StatelessWidget { /// A day of the streak's week: a round mark over the weekday's initial, a
/// flame where studied, a snowflake where rested, a cross where missed; a
/// ring with a flame for today not studied yet, a plain ring for a day to
/// come; nothing before the person's first activity.
class const _StreakDay({required final WeekDay day, required final bool today})
extends StatelessWidget {
static const _size = 34.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
final locale = Localizations.localeOf(context).toLanguageTag(); final locale = Localizations.localeOf(context).toLanguageTag();
Widget circle({Color? color, Border? border, Widget? child}) => Container(
width: _size,
height: _size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
border: border,
shape: BoxShape.circle,
),
child: child,
);
final mark = switch (day.state) {
DayState.studied => circle(
color: streakFlameColor.withValues(alpha: 0.15),
child: const Icon(LucideIcons.flame, size: 18, color: streakFlameColor),
),
DayState.rested => circle(
color: colors.tertiaryContainer,
child: Icon(
LucideIcons.snowflake,
size: 18,
color: colors.onTertiaryContainer,
),
),
DayState.today => circle(
border: Border.all(color: colors.primary, width: 2),
child: Icon(LucideIcons.flame, size: 16, color: colors.primary),
),
DayState.upcoming => circle(
border: Border.all(color: colors.outlineVariant),
),
DayState.missed => circle(
color: colors.surfaceContainerHighest,
child: Icon(LucideIcons.x, size: 16, color: colors.onSurfaceVariant),
),
DayState.notStarted => circle(),
};
return Column( return Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
mark,
const SizedBox(height: 6),
Text( Text(
DateFormat.E(locale).format(day.day), DateFormat('EEEEE', locale).format(day.day),
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelMedium?.copyWith(
color: colors.onSurfaceVariant, color: today ? colors.primary : colors.onSurfaceVariant,
fontWeight: today ? FontWeight.w700 : null,
), ),
), ),
const SizedBox(height: 6),
DayMarker(day: day, due: 0),
], ],
); );
} }
} }
/// How a day stands, as a small round mark: a check for studied, a
/// snowflake for rested, a cross for missed, a dashed ring for today; a day
/// to come shows how many cards fall due, or a faint dashed ring; a day
/// before the person's first activity shows nothing.
class const DayMarker({
required final WeekDay day,
required final int due,
super.key,
}) extends StatelessWidget {
static const _size = 24.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
Widget solid(Color color, Widget child) => Container(
width: _size,
height: _size,
alignment: Alignment.center,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: child,
);
Widget dashed(Color color, {double width = 1.5}) => SizedBox(
width: _size,
height: _size,
child: CustomPaint(
painter: _DashedCircle(color: color, strokeWidth: width),
),
);
return switch (day.state) {
DayState.studied => solid(
colors.primary.withValues(alpha: 0.15),
Icon(LucideIcons.check, size: 14, color: colors.primary),
),
DayState.rested => solid(
colors.tertiaryContainer,
Icon(
LucideIcons.snowflake,
size: 14,
color: colors.onTertiaryContainer,
),
),
DayState.missed => solid(
colors.surfaceContainerHighest,
Icon(LucideIcons.x, size: 14, color: colors.onSurfaceVariant),
),
DayState.notStarted => const SizedBox.square(dimension: _size),
DayState.today => dashed(colors.primary, width: 2),
DayState.upcoming when due > 0 => solid(
colors.secondaryContainer,
Text(
due > 99 ? '99+' : '$due',
style: theme.textTheme.labelSmall?.copyWith(
color: colors.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
),
),
DayState.upcoming => dashed(colors.outlineVariant),
};
}
}
/// A dashed circle: twelve dashes, a little more line than gap.
class const _DashedCircle({
required final Color color,
required final double strokeWidth,
}) extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
final rect = Rect.fromCircle(
center: size.center(Offset.zero),
radius: (size.shortestSide - strokeWidth) / 2,
);
const dashes = 12;
const sweep = 2 * 3.141592653589793 / dashes;
for (var i = 0; i < dashes; i++) {
canvas.drawArc(rect, i * sweep, sweep * 0.55, false, paint);
}
}
@override
bool shouldRepaint(_DashedCircle oldDelegate) =>
oldDelegate.color != color || oldDelegate.strokeWidth != strokeWidth;
}

View file

@ -1,11 +1,17 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart'; import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/streak.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// [date] as its weekday in full and its day of the month with the month
/// short, in [locale]'s words and order: "Thursday, Oct 8", "Donnerstag,
/// 8. Okt.".
String weekdayAndDate(String locale, DateTime date) =>
'${DateFormat.EEEE(locale).format(date)}, '
'${DateFormat.MMMd(locale).format(date)}';
/// The week of [selected] day: on top the day named, making it a rest day /// The week of [selected] day: on top the day named, making it a rest day
/// when it is today or to come and not studied (a chip, on a phone a /// when it is today or to come and not studied (a chip, on a phone a
/// snowflake toggle for the room; decided 2026-10-07), and going a day back /// snowflake toggle for the room; decided 2026-10-07), and going a day back
@ -53,7 +59,7 @@ class const WeekOverview({
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 8), padding: const EdgeInsets.only(left: 8),
child: Text( child: Text(
DateFormat.MMMMEEEEd(locale).format(selected), weekdayAndDate(locale, selected),
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@ -86,9 +92,12 @@ class const WeekOverview({
onPressed: onToggleRestDay, onPressed: onToggleRestDay,
), ),
if (selected != today) if (selected != today)
TextButton( Padding(
onPressed: () => onSelect(today), padding: const EdgeInsets.only(right: 4),
child: Text(l10n.weekToday), child: TextButton(
onPressed: () => onSelect(today),
child: Text(l10n.weekToday),
),
), ),
IconButton( IconButton(
tooltip: l10n.weekPreviousDay, tooltip: l10n.weekPreviousDay,
@ -161,10 +170,7 @@ class const _Day({
), ),
], ],
); );
final mark = Tooltip( final mark = _Marker(day: day, due: due);
message: due > 0 ? AppLocalizations.of(context).weekDueCount(due) : '',
child: DayMarker(day: day, due: due),
);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3), padding: const EdgeInsets.symmetric(horizontal: 3),
child: InkWell( child: InkWell(
@ -200,3 +206,91 @@ class const _Day({
); );
} }
} }
/// How a day stands, as a small round mark: a check for studied, a
/// snowflake for rested, a cross for missed, a dashed ring for today; a day
/// to come shows how many cards fall due, or a faint dashed ring; a day
/// before the person's first activity shows nothing.
class const _Marker({required final WeekDay day, required final int due})
extends StatelessWidget {
static const _size = 24.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
Widget solid(Color color, Widget child) => Container(
width: _size,
height: _size,
alignment: Alignment.center,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: child,
);
Widget dashed(Color color, {double width = 1.5}) => SizedBox(
width: _size,
height: _size,
child: CustomPaint(
painter: _DashedCircle(color: color, strokeWidth: width),
),
);
return switch (day.state) {
DayState.studied => solid(
colors.primary.withValues(alpha: 0.15),
Icon(LucideIcons.check, size: 14, color: colors.primary),
),
DayState.rested => solid(
colors.tertiaryContainer,
Icon(
LucideIcons.snowflake,
size: 14,
color: colors.onTertiaryContainer,
),
),
DayState.missed => solid(
colors.surfaceContainerHighest,
Icon(LucideIcons.x, size: 14, color: colors.onSurfaceVariant),
),
DayState.notStarted => const SizedBox.square(dimension: _size),
DayState.today => dashed(colors.primary, width: 2),
DayState.upcoming when due > 0 => solid(
colors.secondaryContainer,
Text(
due > 99 ? '99+' : '$due',
style: theme.textTheme.labelSmall?.copyWith(
color: colors.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
),
),
DayState.upcoming => dashed(colors.outlineVariant),
};
}
}
/// A dashed circle: twelve dashes, a little more line than gap.
class const _DashedCircle({
required final Color color,
required final double strokeWidth,
}) extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
final rect = Rect.fromCircle(
center: size.center(Offset.zero),
radius: (size.shortestSide - strokeWidth) / 2,
);
const dashes = 12;
const sweep = 2 * 3.141592653589793 / dashes;
for (var i = 0; i < dashes; i++) {
canvas.drawArc(rect, i * sweep, sweep * 0.55, false, paint);
}
}
@override
bool shouldRepaint(_DashedCircle oldDelegate) =>
oldDelegate.color != color || oldDelegate.strokeWidth != strokeWidth;
}

View file

@ -3,7 +3,6 @@ import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/reading_deadlines_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/reading_deadlines_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/deadline_status.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/deadline_status.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/reading_deadlines.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/reading_deadlines.dart';
@ -12,8 +11,10 @@ import 'package:material_ui/material_ui.dart';
/// Home's reading deadlines: those running, the fewest days left first, each /// Home's reading deadlines: those running, the fewest days left first, each
/// with the page to reach today; then those to come. A tap opens the /// with the page to reach today; then those to come. A tap opens the
/// timeline. Without any it shows nothing, or while Home is arranged a word /// timeline. Without any it shows nothing, and leaves no gap: it keeps the
/// on when it appears. /// one below it itself. While Home is arranged ([placeholder]) it shows its
/// name and when it appears instead, so that it can be moved and hidden
/// whether there are deadlines or not.
class const ReadingDeadlinesCard({final bool placeholder = false, super.key}) class const ReadingDeadlinesCard({final bool placeholder = false, super.key})
extends StatelessWidget { extends StatelessWidget {
@override @override
@ -28,83 +29,89 @@ class const ReadingDeadlinesCard({final bool placeholder = false, super.key})
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final deadlines = state.deadlines; final deadlines = state.deadlines;
if (placeholder) {
return Card(
child: ListTile(
leading: const Icon(LucideIcons.calendarClock),
title: Text(l10n.homeWidgetDeadlines),
subtitle: Text(l10n.deadlinesPlaceholder),
),
);
}
if (deadlines.isEmpty) { if (deadlines.isEmpty) {
return !placeholder return const SizedBox.shrink();
? const SizedBox.shrink()
: SectionCard(
child: ListTile(
leading: const Icon(LucideIcons.calendarClock),
title: Text(l10n.deadlinesTitle),
subtitle: Text(l10n.deadlinesPlaceholder),
),
);
} }
TextStyle? heading() => theme.textTheme.labelMedium?.copyWith( TextStyle? heading() => theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 1.2, letterSpacing: 1.2,
); );
return SectionCard( return Padding(
padding: EdgeInsets.zero, padding: const EdgeInsets.only(bottom: 16),
child: InkWell( child: Card(
borderRadius: BorderRadius.circular(16), child: InkWell(
onTap: () => context.push(RoutePaths.readingTimeline), borderRadius: BorderRadius.circular(12),
child: Padding( onTap: () => context.push(RoutePaths.readingTimeline),
padding: const EdgeInsets.all(16), child: Padding(
child: Column( padding: const EdgeInsets.all(16),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Row( children: [
children: [ Row(
Icon( children: [
LucideIcons.calendarClock, Icon(
color: theme.colorScheme.primary, LucideIcons.calendarClock,
), color: theme.colorScheme.primary,
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.deadlinesTitle,
style: theme.textTheme.titleMedium,
), ),
), const SizedBox(width: 12),
Tooltip( Expanded(
message: l10n.deadlinesOpenTimeline, child: Text(
child: const Icon(LucideIcons.chevronRight), l10n.deadlinesTitle,
), style: theme.textTheme.titleMedium,
], ),
), ),
if (deadlines.current.isNotEmpty) ...[ Tooltip(
const SizedBox(height: 12), message: l10n.deadlinesOpenTimeline,
Text(l10n.deadlinesCurrent.toUpperCase(), style: heading()), child: const Icon(LucideIcons.chevronRight),
for (final deadline in deadlines.current) ),
_Current(deadline: deadline), ],
],
if (deadlines.upcoming.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
l10n.deadlinesUpcoming.toUpperCase(),
style: heading(),
), ),
for (final (:pdf, :status) in deadlines.upcoming) if (deadlines.current.isNotEmpty) ...[
Padding( const SizedBox(height: 12),
padding: const EdgeInsets.only(top: 8), Text(
child: Row( l10n.deadlinesCurrent.toUpperCase(),
children: [ style: heading(),
Expanded(
child: Text(
pdf.content.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
l10n.deadlineStartsIn(status.daysUntilStart),
style: theme.textTheme.bodySmall,
),
],
),
), ),
for (final deadline in deadlines.current)
_Current(deadline: deadline),
],
if (deadlines.upcoming.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
l10n.deadlinesUpcoming.toUpperCase(),
style: heading(),
),
for (final (:pdf, :status) in deadlines.upcoming)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Expanded(
child: Text(
pdf.content.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
l10n.deadlineStartsIn(status.daysUntilStart),
style: theme.textTheme.bodySmall,
),
],
),
),
],
], ],
], ),
), ),
), ),
), ),

View file

@ -4,7 +4,6 @@ import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart'; import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart'; import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_run.dart';
@ -26,123 +25,128 @@ class const PomodoroCard({super.key}) extends StatelessWidget {
final run = state.run; final run = state.run;
final profile = run?.profile ?? state.settings.selected; final profile = run?.profile ?? state.settings.selected;
void send(PomodoroEvent event) => context.read<PomodoroBloc>().add(event); void send(PomodoroEvent event) => context.read<PomodoroBloc>().add(event);
return SectionCard( return Card(
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.stretch, padding: const EdgeInsets.all(16),
children: [ child: Column(
Row( crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Row(
child: Text( children: [
l10n.pomodoroTitle, Expanded(
style: theme.textTheme.titleMedium, child: Text(
l10n.pomodoroTitle,
style: theme.textTheme.titleMedium,
),
), ),
), if (run == null)
if (run == null) PopupMenuButton<String>(
PopupMenuButton<String>( tooltip: l10n.pomodoroChooseProfile,
tooltip: l10n.pomodoroChooseProfile, initialValue: profile.id,
initialValue: profile.id, onSelected: (id) => send(PomodoroEventProfileChosen(id)),
onSelected: (id) => send(PomodoroEventProfileChosen(id)), itemBuilder: (context) => [
itemBuilder: (context) => [ for (final each in state.settings.profiles)
for (final each in state.settings.profiles) PopupMenuItem(
PopupMenuItem( value: each.id,
value: each.id, child: Text(l10n.pomodoroProfileName(each)),
child: Text(l10n.pomodoroProfileName(each)),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.pomodoroProfileName(profile),
style: theme.textTheme.labelLarge,
), ),
const SizedBox(width: 4), ],
const Icon(LucideIcons.chevronDown, size: 18), child: Padding(
], padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.pomodoroProfileName(profile),
style: theme.textTheme.labelLarge,
),
const SizedBox(width: 4),
const Icon(LucideIcons.chevronDown, size: 18),
],
),
), ),
),
)
else
Text(
l10n.pomodoroProfileName(profile),
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
IconButton(
tooltip: l10n.pomodoroProfiles,
icon: const Icon(LucideIcons.settings2),
onPressed: () => showPomodoroProfilesSheet(context),
),
],
),
const SizedBox(height: 8),
Center(
child: PomodoroCountdown(
run: run ?? PomodoroRun.ready(profile),
style: theme.textTheme.displayMedium,
),
),
Center(
child: Text(
switch (run) {
null => l10n.pomodoroSummaryOf(profile),
final run when !run.isRunning =>
'${l10n.pomodoroPhaseTitle(run)} · ${l10n.pomodoroPaused}',
final run => l10n.pomodoroPhaseTitle(run),
},
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
if (run == null)
FilledButton.icon(
onPressed: () => send(const PomodoroEventStartRequested()),
icon: const Icon(LucideIcons.play),
label: Text(l10n.pomodoroStart),
)
else ...[
if (run.isRunning)
FilledButton.tonalIcon(
onPressed: () => send(const PomodoroEventPauseRequested()),
icon: const Icon(LucideIcons.pause),
label: Text(l10n.pomodoroPause),
) )
else else
FilledButton.icon( Text(
onPressed: () => send(const PomodoroEventResumeRequested()), l10n.pomodoroProfileName(profile),
icon: const Icon(LucideIcons.play), style: theme.textTheme.labelLarge?.copyWith(
label: Text(l10n.pomodoroResume), color: theme.colorScheme.onSurfaceVariant,
),
), ),
OutlinedButton.icon( IconButton(
onPressed: () => send(const PomodoroEventSkipRequested()), tooltip: l10n.pomodoroProfiles,
icon: const Icon(LucideIcons.skipForward), icon: const Icon(LucideIcons.settings2),
label: Text(l10n.pomodoroSkip), onPressed: () => showPomodoroProfilesSheet(context),
),
OutlinedButton.icon(
onPressed: () => send(const PomodoroEventStopRequested()),
icon: const Icon(LucideIcons.square),
label: Text(l10n.pomodoroStop),
), ),
], ],
], ),
), const SizedBox(height: 8),
const _TopBarSuggestion(), Center(
], child: PomodoroCountdown(
run: run ?? PomodoroRun.ready(profile),
style: theme.textTheme.displayMedium,
),
),
Center(
child: Text(
switch (run) {
null => l10n.pomodoroSummaryOf(profile),
final run when !run.isRunning =>
'${l10n.pomodoroPhaseTitle(run)} · ${l10n.pomodoroPaused}',
final run => l10n.pomodoroPhaseTitle(run),
},
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
if (run == null)
FilledButton.icon(
onPressed: () => send(const PomodoroEventStartRequested()),
icon: const Icon(LucideIcons.play),
label: Text(l10n.pomodoroStart),
)
else ...[
if (run.isRunning)
FilledButton.tonalIcon(
onPressed: () =>
send(const PomodoroEventPauseRequested()),
icon: const Icon(LucideIcons.pause),
label: Text(l10n.pomodoroPause),
)
else
FilledButton.icon(
onPressed: () =>
send(const PomodoroEventResumeRequested()),
icon: const Icon(LucideIcons.play),
label: Text(l10n.pomodoroResume),
),
OutlinedButton.icon(
onPressed: () => send(const PomodoroEventSkipRequested()),
icon: const Icon(LucideIcons.skipForward),
label: Text(l10n.pomodoroSkip),
),
OutlinedButton.icon(
onPressed: () => send(const PomodoroEventStopRequested()),
icon: const Icon(LucideIcons.square),
label: Text(l10n.pomodoroStop),
),
],
],
),
const _TopBarSuggestion(),
],
),
), ),
); );
} }

View file

@ -5,10 +5,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/count_stepper.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart'; import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_texts.dart'; import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_texts.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/count_stepper.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';

View file

@ -4,7 +4,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
/// Where the studying of some cards stands, as the library shows it, with /// Where the studying of some cards stands, as the library shows it, with
/// how many notes and learning paths there are beside them. /// how many notes, learning paths and files (PDFs) there are beside them.
final class const StudyProgress({ final class const StudyProgress({
/// How many cards there are. /// How many cards there are.
required final int cards, required final int cards,
@ -26,23 +26,39 @@ final class const StudyProgress({
/// How many learning paths there are with the cards, counted as notes /// How many learning paths there are with the cards, counted as notes
/// are. /// are.
final int paths = 0, final int paths = 0,
/// How many files (PDFs) there are with the cards, counted as notes are.
final int files = 0,
}) with Equatable { }) with Equatable {
/// The share of mature cards, from 0 to 1; null without cards. /// The share of mature cards, from 0 to 1; null without cards.
double? get mastery => cards == 0 ? null : mature / cards; double? get mastery => cards == 0 ? null : mature / cards;
/// This progress with [notes] notes and [paths] paths beside the cards. /// This progress with [notes] notes, [paths] paths and [files] files
StudyProgress withContent({required int notes, required int paths}) => /// beside the cards.
StudyProgress( StudyProgress withContent({
cards: cards, required int notes,
due: due, required int paths,
reviewsDue: reviewsDue, required int files,
mature: mature, }) => StudyProgress(
notes: notes, cards: cards,
paths: paths, due: due,
); reviewsDue: reviewsDue,
mature: mature,
notes: notes,
paths: paths,
files: files,
);
@override @override
List<Object?> get props => [cards, due, reviewsDue, mature, notes, paths]; List<Object?> get props => [
cards,
due,
reviewsDue,
mature,
notes,
paths,
files,
];
} }
/// The progress of the cards in [groups] (each studyset's, in tree order, as /// The progress of the cards in [groups] (each studyset's, in tree order, as

Some files were not shown because too many files have changed in this diff Show more