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:
parent
770de51bff
commit
aa69be47d1
35 changed files with 1831 additions and 806 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
71
halkyon_learn_flutter/lib/src/core/widget/count_stepper.dart
Normal file
71
halkyon_learn_flutter/lib/src/core/widget/count_stepper.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
halkyon_learn_flutter/lib/src/core/widget/field_label.dart
Normal file
47
halkyon_learn_flutter/lib/src/core/widget/field_label.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue