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

View file

@ -33,7 +33,7 @@
"studysetSheetSave": "Speichern",
"studysetSheetTitleLabel": "Titel",
"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",
"studysetSheetDiscardTitle": "Änderungen verwerfen?",
"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}}",
"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",
"studysetActionReview": "Wiederholen",
"studysetActionStudy": "Lernen",
@ -233,18 +236,25 @@
"studysetDragToReorder": "Zum Umsortieren ziehen",
"studysetMoveTitle": "Lernset verschieben nach",
"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",
"studysetMoveShowInside": "Inhalt anzeigen",
"studysetMoveHideInside": "Inhalt ausblenden",
"studysetMoved": "Nach „{destination}“ verschoben.",
"studysetMoveShowInside": "Unter-Lernsets anzeigen",
"studysetMoveHideInside": "Unter-Lernsets ausblenden",
"studysetMoved": "„{title}“ nach {destination} verschoben.",
"studysetReadMore": "Mehr anzeigen",
"studysetDescriptionTitle": "Beschreibung",
"studysetDescriptionEditTitle": "Beschreibung bearbeiten",
"richTextSave": "Speichern",
"studysetSheetDescription": "Beschreibung",
"studysetSheetDescriptionAdd": "Beschreibung hinzufügen",
"studysetSheetInheritColor": "Gleiche Farbe wie das Lernset, in dem es liegt",
"studysetSheetDescriptionAdd": "Tippen, um eine Beschreibung hinzuzufügen",
"studysetSheetDescriptionAdded": "Beschreibung hinzugefügt — zum Bearbeiten tippen",
"studysetSheetInheritColor": "Erben",
"fieldRequired": "Pflichtfeld",
"exploreTitle": "Entdecken",
"exploreEmpty": "Lernsets, die andere teilen, erscheinen hier.",
@ -602,8 +612,9 @@
"homeShowWidget": "{label} anzeigen",
"homeHideWidget": "{label} ausblenden",
"homeAllHidden": "Alle Widgets sind ausgeblendet",
"homeAllHiddenHint": "Halte hier lange gedrückt, um welche zurückzuholen.",
"homeWidgetGoal": "Lernziel",
"homeAllHiddenBody": "Blende einige wieder ein, um dein Dashboard zu füllen.",
"homeCustomise": "Anpassen",
"homeWidgetGoal": "Tagesziel",
"homeWidgetWeek": "Wochenübersicht",
"homeWidgetActivity": "Aktivitäts-Heatmap",
"homeActivityTitle": "Deine Aktivität",
@ -635,11 +646,10 @@
"weekPreviousDay": "Vorheriger Tag",
"weekNextDay": "Nächster Tag",
"weekRestDay": "Ruhetag",
"weekDueCount": "{count, plural, =1{1 Karte fällig} other{{count} Karten fällig}}",
"heatmapLess": "Weniger",
"heatmapMore": "Mehr",
"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",
"commonSave": "Speichern",
@ -882,7 +892,7 @@
"readerCaptureFailed": "Die Seite konnte nicht dargestellt werden. Versuche es erneut, sobald sie geladen ist.",
"readerScreenshotSaved": "Bildschirmfoto gespeichert",
"imageOpenPdfPage": "PDF auf dieser Seite öffnen",
"homeWidgetDeadlines": "Lesefristen",
"homeWidgetDeadlines": "PDF-Lesefristen",
"deadlinesTitle": "Lesefristen",
"deadlinesCurrent": "Aktuell",
"deadlinesUpcoming": "Demnächst",

View file

@ -34,7 +34,7 @@
"studysetSheetSave": "Save",
"studysetSheetTitleLabel": "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",
"studysetSheetDiscardTitle": "Discard changes?",
"studysetSheetDiscardMessage": "You have unsaved changes to this studyset. Discard them and close the form?",
@ -49,6 +49,10 @@
"@studysetCountFlashcards": {"placeholders": {"count": {"type": "int"}}},
"studysetCountNotes": "{count, plural, =1{1 note} other{{count} notes}}",
"@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",
"studysetActionReview": "Review",
"studysetActionStudy": "Study",
@ -257,19 +261,27 @@
"studysetDragToReorder": "Drag to reorder",
"studysetMoveTitle": "Move studyset to",
"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",
"studysetMoveShowInside": "Show what is inside",
"studysetMoveHideInside": "Hide what is inside",
"studysetMoved": "Moved to {destination}.",
"@studysetMoved": {"placeholders": {"destination": {"type": "String"}}},
"studysetMoveShowInside": "Show sub-studysets",
"studysetMoveHideInside": "Hide sub-studysets",
"studysetMoved": "Moved \"{title}\" to {destination}.",
"@studysetMoved": {"placeholders": {"title": {"type": "String"}, "destination": {"type": "String"}}},
"studysetReadMore": "Read more",
"studysetDescriptionTitle": "Description",
"studysetDescriptionEditTitle": "Edit description",
"richTextSave": "Save",
"studysetSheetDescription": "Description",
"studysetSheetDescriptionAdd": "Add a description",
"studysetSheetInheritColor": "Same colour as the studyset it is in",
"studysetSheetDescriptionAdd": "Tap to add a description",
"studysetSheetDescriptionAdded": "Description added — tap to edit",
"studysetSheetInheritColor": "Inherit",
"fieldRequired": "required",
"exploreTitle": "Explore",
"exploreEmpty": "Studysets that others share will appear here.",
@ -677,8 +689,9 @@
"homeHideWidget": "Hide {label}",
"@homeHideWidget": {"placeholders": {"label": {"type": "String"}}},
"homeAllHidden": "All widgets are hidden",
"homeAllHiddenHint": "Long-press here to bring some back.",
"homeWidgetGoal": "Study goal",
"homeAllHiddenBody": "Bring some back to fill your dashboard.",
"homeCustomise": "Customise",
"homeWidgetGoal": "Daily goal",
"homeWidgetWeek": "Week overview",
"homeWidgetActivity": "Activity heatmap",
"homeActivityTitle": "Your activity",
@ -713,13 +726,11 @@
"weekPreviousDay": "Previous day",
"weekNextDay": "Next day",
"weekRestDay": "Rest day",
"weekDueCount": "{count, plural, =1{1 card due} other{{count} cards due}}",
"@weekDueCount": {"placeholders": {"count": {"type": "int"}}},
"heatmapLess": "Less",
"heatmapMore": "More",
"heatmapTotal": "{count, plural, =1{1 activity in the last year} other{{count} activities in the last year}}",
"@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"}}},
"commonCancel": "Cancel",
@ -1006,7 +1017,7 @@
"readerCaptureFailed": "The page couldn't be drawn. Try again once it has loaded.",
"readerScreenshotSaved": "Screenshot saved",
"imageOpenPdfPage": "Open the PDF at this page",
"homeWidgetDeadlines": "Reading deadlines",
"homeWidgetDeadlines": "PDF reading deadlines",
"deadlinesTitle": "Reading deadlines",
"deadlinesCurrent": "Current",
"deadlinesUpcoming": "Upcoming",

View file

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

View file

@ -271,7 +271,7 @@ abstract class AppLocalizations {
/// No description provided for @studysetSheetSecondaryHelper.
///
/// 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;
/// No description provided for @studysetSheetColor.
@ -322,6 +322,24 @@ abstract class AppLocalizations {
/// **'{count, plural, =1{1 note} other{{count} notes}}'**
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.
///
/// In en, this message translates to:
@ -1459,9 +1477,39 @@ abstract class AppLocalizations {
/// No description provided for @studysetMoveCurrent.
///
/// In en, this message translates to:
/// **'Where it is now'**
/// **'(current)'**
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.
///
/// In en, this message translates to:
@ -1471,20 +1519,20 @@ abstract class AppLocalizations {
/// No description provided for @studysetMoveShowInside.
///
/// In en, this message translates to:
/// **'Show what is inside'**
/// **'Show sub-studysets'**
String get studysetMoveShowInside;
/// No description provided for @studysetMoveHideInside.
///
/// In en, this message translates to:
/// **'Hide what is inside'**
/// **'Hide sub-studysets'**
String get studysetMoveHideInside;
/// No description provided for @studysetMoved.
///
/// In en, this message translates to:
/// **'Moved to {destination}.'**
String studysetMoved(String destination);
/// **'Moved \"{title}\" to {destination}.'**
String studysetMoved(String title, String destination);
/// No description provided for @studysetReadMore.
///
@ -1519,15 +1567,27 @@ abstract class AppLocalizations {
/// No description provided for @studysetSheetDescriptionAdd.
///
/// In en, this message translates to:
/// **'Add a description'**
/// **'Tap to add a description'**
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.
///
/// In en, this message translates to:
/// **'Same colour as the studyset it is in'**
/// **'Inherit'**
String get studysetSheetInheritColor;
/// No description provided for @fieldRequired.
///
/// In en, this message translates to:
/// **'required'**
String get fieldRequired;
/// No description provided for @exploreTitle.
///
/// In en, this message translates to:
@ -3610,16 +3670,22 @@ abstract class AppLocalizations {
/// **'All widgets are hidden'**
String get homeAllHidden;
/// No description provided for @homeAllHiddenHint.
/// No description provided for @homeAllHiddenBody.
///
/// In en, this message translates to:
/// **'Long-press here to bring some back.'**
String get homeAllHiddenHint;
/// **'Bring some back to fill your dashboard.'**
String get homeAllHiddenBody;
/// No description provided for @homeCustomise.
///
/// In en, this message translates to:
/// **'Customise'**
String get homeCustomise;
/// No description provided for @homeWidgetGoal.
///
/// In en, this message translates to:
/// **'Study goal'**
/// **'Daily goal'**
String get homeWidgetGoal;
/// No description provided for @homeWidgetWeek.
@ -3808,12 +3874,6 @@ abstract class AppLocalizations {
/// **'Rest day'**
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.
///
/// In en, this message translates to:
@ -3835,7 +3895,7 @@ abstract class AppLocalizations {
/// No description provided for @heatmapDay.
///
/// 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);
/// No description provided for @commonCancel.
@ -5287,7 +5347,7 @@ abstract class AppLocalizations {
/// No description provided for @homeWidgetDeadlines.
///
/// In en, this message translates to:
/// **'Reading deadlines'**
/// **'PDF reading deadlines'**
String get homeWidgetDeadlines;
/// No description provided for @deadlinesTitle.

View file

@ -98,7 +98,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get studysetSheetColor => 'Farbe';
@ -171,6 +171,23 @@ class AppLocalizationsDe extends AppLocalizations {
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
String get studysetNoContent => 'Noch kein Inhalt';
@ -845,20 +862,37 @@ class AppLocalizationsDe extends AppLocalizations {
String get studysetMoveTopLevel => 'Oberste Ebene';
@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
String get studysetMoveConfirm => 'Verschieben';
@override
String get studysetMoveShowInside => 'Inhalt anzeigen';
String get studysetMoveShowInside => 'Unter-Lernsets anzeigen';
@override
String get studysetMoveHideInside => 'Inhalt ausblenden';
String get studysetMoveHideInside => 'Unter-Lernsets ausblenden';
@override
String studysetMoved(String destination) {
return 'Nach „$destination“ verschoben.';
String studysetMoved(String title, String destination) {
return '„$title“ nach $destination verschoben.';
}
@override
@ -877,11 +911,18 @@ class AppLocalizationsDe extends AppLocalizations {
String get studysetSheetDescription => 'Beschreibung';
@override
String get studysetSheetDescriptionAdd => 'Beschreibung hinzufügen';
String get studysetSheetDescriptionAdd =>
'Tippen, um eine Beschreibung hinzuzufügen';
@override
String get studysetSheetInheritColor =>
'Gleiche Farbe wie das Lernset, in dem es liegt';
String get studysetSheetDescriptionAdded =>
'Beschreibung hinzugefügt — zum Bearbeiten tippen';
@override
String get studysetSheetInheritColor => 'Erben';
@override
String get fieldRequired => 'Pflichtfeld';
@override
String get exploreTitle => 'Entdecken';
@ -2179,11 +2220,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get homeAllHidden => 'Alle Widgets sind ausgeblendet';
@override
String get homeAllHiddenHint =>
'Halte hier lange gedrückt, um welche zurückzuholen.';
String get homeAllHiddenBody =>
'Blende einige wieder ein, um dein Dashboard zu füllen.';
@override
String get homeWidgetGoal => 'Lernziel';
String get homeCustomise => 'Anpassen';
@override
String get homeWidgetGoal => 'Tagesziel';
@override
String get homeWidgetWeek => 'Wochenübersicht';
@ -2299,17 +2343,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get heatmapLess => 'Weniger';
@ -2334,7 +2367,6 @@ class AppLocalizationsDe extends AppLocalizations {
locale: localeName,
other: '$count Aktivitäten',
one: '1 Aktivität',
zero: 'Nichts',
);
return '$_temp0 · $date';
}
@ -3167,7 +3199,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get imageOpenPdfPage => 'PDF auf dieser Seite öffnen';
@override
String get homeWidgetDeadlines => 'Lesefristen';
String get homeWidgetDeadlines => 'PDF-Lesefristen';
@override
String get deadlinesTitle => 'Lesefristen';

View file

@ -96,7 +96,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get studysetSheetColor => 'Colour';
@ -169,6 +169,23 @@ class AppLocalizationsEn extends AppLocalizations {
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
String get studysetNoContent => 'No content yet';
@ -840,20 +857,37 @@ class AppLocalizationsEn extends AppLocalizations {
String get studysetMoveTopLevel => 'Top level';
@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
String get studysetMoveConfirm => 'Move';
@override
String get studysetMoveShowInside => 'Show what is inside';
String get studysetMoveShowInside => 'Show sub-studysets';
@override
String get studysetMoveHideInside => 'Hide what is inside';
String get studysetMoveHideInside => 'Hide sub-studysets';
@override
String studysetMoved(String destination) {
return 'Moved to $destination.';
String studysetMoved(String title, String destination) {
return 'Moved \"$title\" to $destination.';
}
@override
@ -872,11 +906,16 @@ class AppLocalizationsEn extends AppLocalizations {
String get studysetSheetDescription => 'Description';
@override
String get studysetSheetDescriptionAdd => 'Add a description';
String get studysetSheetDescriptionAdd => 'Tap to add a description';
@override
String get studysetSheetInheritColor =>
'Same colour as the studyset it is in';
String get studysetSheetDescriptionAdded => 'Description added — tap to edit';
@override
String get studysetSheetInheritColor => 'Inherit';
@override
String get fieldRequired => 'required';
@override
String get exploreTitle => 'Explore';
@ -2166,10 +2205,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get homeAllHidden => 'All widgets are hidden';
@override
String get homeAllHiddenHint => 'Long-press here to bring some back.';
String get homeAllHiddenBody => 'Bring some back to fill your dashboard.';
@override
String get homeWidgetGoal => 'Study goal';
String get homeCustomise => 'Customise';
@override
String get homeWidgetGoal => 'Daily goal';
@override
String get homeWidgetWeek => 'Week overview';
@ -2283,17 +2325,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get heatmapLess => 'Less';
@ -2318,7 +2349,6 @@ class AppLocalizationsEn extends AppLocalizations {
locale: localeName,
other: '$count activities',
one: '1 activity',
zero: 'Nothing',
);
return '$_temp0 · $date';
}
@ -3140,7 +3170,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get imageOpenPdfPage => 'Open the PDF at this page';
@override
String get homeWidgetDeadlines => 'Reading deadlines';
String get homeWidgetDeadlines => 'PDF reading deadlines';
@override
String get deadlinesTitle => 'Reading deadlines';
@ -3681,12 +3711,12 @@ class AppLocalizationsEnUs extends AppLocalizationsEn {
return 'Color: $color';
}
@override
String get studysetSheetInheritColor => 'Same color as the studyset it is in';
@override
String get tagSheetColor => 'Color';
@override
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';
/// A widget of the home dashboard.
/// A widget of the home dashboard, in the order a new dashboard shows them.
enum HomeWidget() {
/// The Pomodoro timer.
pomodoro,
/// The study goal.
goal,
/// The Pomodoro timer.
pomodoro,
/// The reading deadlines of PDFs; it shows only while there are some.
deadlines,

View file

@ -1,13 +1,14 @@
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/widget/week_overview.dart';
import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart';
/// 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
/// 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
/// weeks in view first.
/// the left. Centred when it fits, scrolling sideways otherwise, weekdays
/// and all, the latest weeks in view first. Days to come are outlined.
class const ActivityHeatmap({
/// What was studied, by day.
required final StudyActivity activity,
@ -104,45 +105,32 @@ class const ActivityHeatmap({
Row(
children: [
for (final monday in mondays)
Padding(
padding: const EdgeInsets.only(right: _gap),
child: Column(
children: [
for (var row = 0; row < 7; row++)
_cellFor(context, addDays(monday, row)),
],
),
Column(
children: [
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(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The weekdays stay in view while the weeks scroll.
LayoutBuilder(
builder: (context, constraints) =>
_weekdaysWidth + 6 + weeks * _step <= constraints.maxWidth
? Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [weekdays, const SizedBox(width: 6), weeksGrid],
),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
weekdays,
const SizedBox(width: 6),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: weeksGrid,
),
),
],
? Center(child: grid)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: grid,
),
),
const SizedBox(height: 10),
@ -150,18 +138,18 @@ class const ActivityHeatmap({
children: [
Expanded(child: Text(l10n.heatmapTotal(total), style: muted)),
Text(l10n.heatmapLess, style: muted),
const SizedBox(width: 4),
const SizedBox(width: 6),
for (var level = 0; level <= 4; level++)
Container(
width: 10,
height: 10,
width: 12,
height: 12,
margin: const EdgeInsets.symmetric(horizontal: 1.5),
decoration: BoxDecoration(
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),
],
),
@ -173,13 +161,23 @@ class const ActivityHeatmap({
final theme = Theme.of(context);
final future = day.isAfter(today);
final count = activity.on(day);
final cell = Container(
width: _cell,
height: _cell,
margin: const EdgeInsets.only(bottom: _gap),
decoration: BoxDecoration(
color: future ? Colors.transparent : colorOf(theme, heatLevel(count)),
borderRadius: BorderRadius.circular(3),
// Centred in its slot of the grid, the gap shared on every side.
final cell = Padding(
padding: const EdgeInsets.all(_gap / 2),
child: Container(
width: _cell,
height: _cell,
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) {
@ -189,7 +187,8 @@ class const ActivityHeatmap({
return Tooltip(
message: AppLocalizations.of(
context,
).heatmapDay(count, DateFormat.yMMMd(locale).format(day)),
).heatmapDay(count, weekdayAndDate(locale, day)),
waitDuration: const Duration(milliseconds: 300),
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/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:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// 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
/// sheet.
/// go and a pencil to change it; without a goal, an invitation to set one.
/// Tapping opens the goal's sheet.
class const GoalCard({
/// The goal; null without one.
required final StudyGoal? goal,
@ -25,13 +25,14 @@ class const GoalCard({
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final goal = this.goal;
final muted = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
final small = theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
);
return SectionCard(
padding: EdgeInsets.zero,
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
final result = await showAdaptiveSheet<_GoalResult>(
context,
@ -46,7 +47,7 @@ class const GoalCard({
child: goal == null
? Row(
children: [
Icon(LucideIcons.target, color: theme.colorScheme.primary),
Icon(LucideIcons.target, color: colors.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
@ -57,10 +58,11 @@ class const GoalCard({
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 2),
Text(l10n.goalEmptySubtitle, style: muted),
Text(l10n.goalEmptySubtitle, style: small),
],
),
),
const SizedBox(width: 8),
const Icon(LucideIcons.chevronRight),
],
)
@ -86,26 +88,39 @@ class const GoalCard({
progress,
goal.target,
),
style: muted,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
if (progress >= goal.target)
Text(
l10n.goalMet,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
Row(
children: [
Icon(
LucideIcons.circleCheck,
size: 16,
color: colors.primary,
),
const SizedBox(width: 4),
Text(
l10n.goalMet,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w600,
),
),
],
)
else
Text(
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
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: 56,
height: 56,
return SizedBox.square(
dimension: 56,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox.expand(
child: CircularProgressIndicator(
value: math.min(1, progress / target),
strokeWidth: 6,
strokeWidth: 5,
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.
typedef _GoalResult = ({StudyGoal? goal});
/// Setting the study goal: a day or a week, and how many cards; removing an
/// existing one.
/// Setting the study goal: a day or a week, and how many cards; saving
/// across the sheet's width, and removing an existing one below it.
class const _GoalSheet({required final StudyGoal? initial})
extends StatefulWidget {
@override
@ -166,29 +186,35 @@ class _GoalSheetState() extends State<_GoalSheet> {
final daily = _period == GoalPeriod.day;
return SheetScaffold(
title: widget.initial == null ? l10n.goalSetTitle : l10n.goalEditTitle,
footer: Row(
footer: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.initial != null)
TextButton(
onPressed: () => Navigator.of(context).pop((goal: null)),
child: Text(l10n.goalRemove),
),
const Spacer(),
FilledButton(
onPressed: () => Navigator.of(
context,
).pop((goal: StudyGoal(period: _period, target: _target))),
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(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
const SizedBox(height: 4),
Text(
daily ? l10n.goalDescriptionDaily : l10n.goalDescriptionWeekly,
style: theme.textTheme.bodyMedium?.copyWith(
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
@ -207,7 +233,6 @@ class _GoalSheetState() extends State<_GoalSheet> {
),
],
selected: {_period},
showSelectedIcon: false,
onSelectionChanged: (selection) =>
setState(() => _period = selection.single),
),
@ -217,33 +242,16 @@ class _GoalSheetState() extends State<_GoalSheet> {
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Row(
children: [
IconButton.outlined(
tooltip: l10n.studyFewer,
onPressed: _target > StudyGoal.minTarget
? () => _setTarget(_target - 1)
: null,
icon: const Icon(LucideIcons.minus),
),
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),
),
],
CountStepper(
value: '$_target',
style: theme.textTheme.titleMedium,
onFewer: _target > StudyGoal.minTarget
? () => _setTarget(_target - 1)
: null,
onMore: _target < StudyGoal.maxTarget
? () => _setTarget(_target + 1)
: null,
),
const SizedBox(height: 16),
],
),
);

View file

@ -1,3 +1,4 @@
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.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/sync_trouble_badge.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/model/home_layout.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);
/// Starts arranging the dashboard, with a tap the hand can feel.
void _arrange() {
if (!_arranging) {
HapticFeedback.mediumImpact();
setState(() => _arranging = true);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) {
final loaded = switch (state) {
@ -74,7 +81,6 @@ class _HomeState() extends State<_Home> {
}
},
child: Scaffold(
backgroundColor: theme.colorScheme.surfaceContainerLow,
appBar: _arranging
? AppBar(
leading: IconButton(
@ -112,7 +118,7 @@ class _HomeState() extends State<_Home> {
tooltip: l10n.settingsTitle,
onPressed: () => context.push(RoutePaths.settings),
),
const SizedBox(width: 4),
const SizedBox(width: 8),
],
),
body: Column(
@ -142,29 +148,34 @@ class _HomeState() extends State<_Home> {
onChanged: (goal) => _send(HomeEventGoalSet(goal)),
),
HomeWidget.deadlines => ReadingDeadlinesCard(placeholder: _arranging),
HomeWidget.week => SectionCard(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 12),
child: WeekOverview(
selected: selected,
today: today,
week: weekOf(selected, state.activity, state.restDays, state.now),
due: state.due,
restDays: state.restDays,
onSelect: (day) => setState(() => _selected = day),
onToggleRestDay: () => _send(HomeEventRestDayToggled(selected)),
HomeWidget.week => Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 12),
child: WeekOverview(
selected: selected,
today: today,
week: weekOf(selected, state.activity, state.restDays, state.now),
due: state.due,
restDays: state.restDays,
onSelect: (day) => setState(() => _selected = day),
onToggleRestDay: () => _send(HomeEventRestDayToggled(selected)),
),
),
),
HomeWidget.activity => SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.homeActivityTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
ActivityHeatmap(activity: state.activity, today: today),
],
HomeWidget.activity => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.homeActivityTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
ActivityHeatmap(activity: state.activity, today: today),
],
),
),
),
};
@ -180,36 +191,24 @@ class _HomeState() extends State<_Home> {
onChanged: (layout) => _send(HomeEventLayoutChanged(layout)),
)
: layout.shown.isEmpty
? GestureDetector(
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),
],
),
),
)
? _AllHidden(onArrange: _arrange)
: ListView(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
children: [
for (final widget in layout.shown)
GestureDetector(
key: ValueKey(widget),
// Taps still reach the widget's own controls.
behavior: HitTestBehavior.translucent,
onLongPress: () => setState(() => _arranging = true),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: section(widget),
),
onLongPress: _arrange,
// The reading deadlines leave no gap while they show
// nothing: they keep their own.
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
/// with a handle to drag it elsewhere and an eye to hide or show it; the
/// widgets themselves don't react meanwhile.
/// with a handle beside it to drag it elsewhere and an eye on its corner to
/// 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({
required final HomeLayout layout,
required final Widget Function(HomeWidget widget) section,
@ -229,16 +270,35 @@ class const _Arranging({
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
String label(HomeWidget widget) => switch (widget) {
HomeWidget.pomodoro => l10n.homeWidgetPomodoro,
HomeWidget.pomodoro => l10n.pomodoroTitle,
HomeWidget.goal => l10n.homeWidgetGoal,
HomeWidget.deadlines => l10n.homeWidgetDeadlines,
HomeWidget.week => l10n.homeWidgetWeek,
HomeWidget.activity => l10n.homeWidgetActivity,
};
return ReorderableListView(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
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) {
final order = [...layout.order];
final moved = order.removeAt(from);
@ -249,42 +309,53 @@ class const _Arranging({
for (final (i, widget) in layout.order.indexed)
Padding(
key: ValueKey(widget),
padding: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ReorderableDragStartListener(
index: i,
child: Tooltip(
message: l10n.homeDragToReorder,
child: const Padding(
padding: EdgeInsets.all(12),
child: Icon(LucideIcons.gripVertical),
child: IconButton(
tooltip: l10n.homeDragToReorder,
onPressed: () {},
icon: Icon(
LucideIcons.gripVertical,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Opacity(
opacity: layout.hidden.contains(widget) ? 0.4 : 1,
child: IgnorePointer(child: section(widget)),
),
),
IconButton(
tooltip: layout.hidden.contains(widget)
? l10n.homeShowWidget(label(widget))
: l10n.homeHideWidget(label(widget)),
icon: Icon(
layout.hidden.contains(widget)
? LucideIcons.eyeOff
: LucideIcons.eye,
),
onPressed: () => onChanged(
HomeLayout(
order: layout.order,
hidden: layout.hidden.contains(widget)
? ({...layout.hidden}..remove(widget))
: {...layout.hidden, widget},
),
child: Stack(
children: [
// Room at the top end for the eye on the corner.
Padding(
padding: const EdgeInsetsDirectional.only(
top: 8,
end: 8,
),
child: IgnorePointer(
child: Opacity(
opacity: layout.hidden.contains(widget) ? 0.4 : 1,
child: section(widget),
),
),
),
PositionedDirectional(
top: 0,
end: 0,
child: _EyeBadge(
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) {
final theme = Theme.of(context);
final lit = streak > 0;
return Tooltip(
message: AppLocalizations.of(context).homeStreakTooltip,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => showAdaptiveSheet<void>(
context,
builder: (context) =>
_StreakSheet(streak: streak, week: week, today: today),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.flame,
size: 20,
color: lit
? streakFlameColor
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'$streak',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
return Center(
child: Tooltip(
message: AppLocalizations.of(context).homeStreakTooltip,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => showAdaptiveSheet<void>(
context,
builder: (context) =>
_StreakSheet(streak: streak, week: week, today: today),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.flame,
size: 20,
color: lit
? streakFlameColor
: theme.colorScheme.onSurfaceVariant,
),
),
],
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
/// this week's days.
/// The streak at large: a big flame with the days inside, what keeps it
/// 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({
required final int streak,
required final List<WeekDay> week,
@ -72,162 +76,161 @@ class const _StreakSheet({
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final studiedToday = week.any(
(day) => day.state == DayState.studied && day.day == today,
);
return SheetScaffold(
title: l10n.homeStreakTooltip,
body: Column(
children: [
const SizedBox(height: 8),
Icon(
LucideIcons.flame,
size: 64,
color: streak > 0
? streakFlameColor
: theme.colorScheme.onSurfaceVariant,
),
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,
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: AlignmentDirectional.centerEnd,
child: IconButton(
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.of(context).pop(),
),
),
),
const SizedBox(height: 20),
Row(
children: [
for (final day in week) Expanded(child: _StreakDay(day: day)),
],
),
const SizedBox(height: 16),
Text(
streak == 0
? l10n.streakFirstDay
: studiedToday
? l10n.streakComeBack
: l10n.streakKeepAlive,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
],
SizedBox.square(
dimension: 96,
child: Stack(
alignment: Alignment.center,
children: [
Icon(
LucideIcons.flame,
size: 88,
color: streak > 0
? streakFlameColor
: colors.onSurfaceVariant,
),
// In the flame's bulb.
Padding(
padding: const EdgeInsets.only(top: 22),
child: Text(
'$streak',
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
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
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(
mainAxisSize: MainAxisSize.min,
children: [
mark,
const SizedBox(height: 6),
Text(
DateFormat.E(locale).format(day.day),
style: theme.textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
DateFormat('EEEEE', locale).format(day.day),
style: theme.textTheme.labelMedium?.copyWith(
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/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/widget/streak.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.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
/// 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
@ -53,7 +59,7 @@ class const WeekOverview({
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
DateFormat.MMMMEEEEd(locale).format(selected),
weekdayAndDate(locale, selected),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
@ -86,9 +92,12 @@ class const WeekOverview({
onPressed: onToggleRestDay,
),
if (selected != today)
TextButton(
onPressed: () => onSelect(today),
child: Text(l10n.weekToday),
Padding(
padding: const EdgeInsets.only(right: 4),
child: TextButton(
onPressed: () => onSelect(today),
child: Text(l10n.weekToday),
),
),
IconButton(
tooltip: l10n.weekPreviousDay,
@ -161,10 +170,7 @@ class const _Day({
),
],
);
final mark = Tooltip(
message: due > 0 ? AppLocalizations.of(context).weekDueCount(due) : '',
child: DayMarker(day: day, due: due),
);
final mark = _Marker(day: day, due: due);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
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/src/core/dependencies/person_scope.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/model/deadline_status.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
/// 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
/// on when it appears.
/// timeline. Without any it shows nothing, and leaves no gap: it keeps the
/// 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})
extends StatelessWidget {
@override
@ -28,83 +29,89 @@ class const ReadingDeadlinesCard({final bool placeholder = false, super.key})
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
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) {
return !placeholder
? const SizedBox.shrink()
: SectionCard(
child: ListTile(
leading: const Icon(LucideIcons.calendarClock),
title: Text(l10n.deadlinesTitle),
subtitle: Text(l10n.deadlinesPlaceholder),
),
);
return const SizedBox.shrink();
}
TextStyle? heading() => theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 1.2,
);
return SectionCard(
padding: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => context.push(RoutePaths.readingTimeline),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.calendarClock,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.deadlinesTitle,
style: theme.textTheme.titleMedium,
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Card(
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.push(RoutePaths.readingTimeline),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.calendarClock,
color: theme.colorScheme.primary,
),
),
Tooltip(
message: l10n.deadlinesOpenTimeline,
child: const Icon(LucideIcons.chevronRight),
),
],
),
if (deadlines.current.isNotEmpty) ...[
const SizedBox(height: 12),
Text(l10n.deadlinesCurrent.toUpperCase(), style: heading()),
for (final deadline in deadlines.current)
_Current(deadline: deadline),
],
if (deadlines.upcoming.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
l10n.deadlinesUpcoming.toUpperCase(),
style: heading(),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.deadlinesTitle,
style: theme.textTheme.titleMedium,
),
),
Tooltip(
message: l10n.deadlinesOpenTimeline,
child: const Icon(LucideIcons.chevronRight),
),
],
),
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,
),
],
),
if (deadlines.current.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
l10n.deadlinesCurrent.toUpperCase(),
style: heading(),
),
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: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/widget/section_card.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/model/pomodoro_run.dart';
@ -26,123 +25,128 @@ class const PomodoroCard({super.key}) extends StatelessWidget {
final run = state.run;
final profile = run?.profile ?? state.settings.selected;
void send(PomodoroEvent event) => context.read<PomodoroBloc>().add(event);
return SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.pomodoroTitle,
style: theme.textTheme.titleMedium,
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.pomodoroTitle,
style: theme.textTheme.titleMedium,
),
),
),
if (run == null)
PopupMenuButton<String>(
tooltip: l10n.pomodoroChooseProfile,
initialValue: profile.id,
onSelected: (id) => send(PomodoroEventProfileChosen(id)),
itemBuilder: (context) => [
for (final each in state.settings.profiles)
PopupMenuItem(
value: each.id,
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,
if (run == null)
PopupMenuButton<String>(
tooltip: l10n.pomodoroChooseProfile,
initialValue: profile.id,
onSelected: (id) => send(PomodoroEventProfileChosen(id)),
itemBuilder: (context) => [
for (final each in state.settings.profiles)
PopupMenuItem(
value: each.id,
child: Text(l10n.pomodoroProfileName(each)),
),
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
FilledButton.icon(
onPressed: () => send(const PomodoroEventResumeRequested()),
icon: const Icon(LucideIcons.play),
label: Text(l10n.pomodoroResume),
Text(
l10n.pomodoroProfileName(profile),
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
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),
IconButton(
tooltip: l10n.pomodoroProfiles,
icon: const Icon(LucideIcons.settings2),
onPressed: () => showPomodoroProfilesSheet(context),
),
],
],
),
const _TopBarSuggestion(),
],
),
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
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/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/count_stepper.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/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: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';
/// 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({
/// How many cards there are.
required final int cards,
@ -26,23 +26,39 @@ final class const StudyProgress({
/// How many learning paths there are with the cards, counted as notes
/// are.
final int paths = 0,
/// How many files (PDFs) there are with the cards, counted as notes are.
final int files = 0,
}) with Equatable {
/// The share of mature cards, from 0 to 1; null without cards.
double? get mastery => cards == 0 ? null : mature / cards;
/// This progress with [notes] notes and [paths] paths beside the cards.
StudyProgress withContent({required int notes, required int paths}) =>
StudyProgress(
cards: cards,
due: due,
reviewsDue: reviewsDue,
mature: mature,
notes: notes,
paths: paths,
);
/// This progress with [notes] notes, [paths] paths and [files] files
/// beside the cards.
StudyProgress withContent({
required int notes,
required int paths,
required int files,
}) => StudyProgress(
cards: cards,
due: due,
reviewsDue: reviewsDue,
mature: mature,
notes: notes,
paths: paths,
files: files,
);
@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

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