feat(profile): statistics beside the profile, a review counting a minute at most

The Profile tab has two tabs, Profile and Statistics, the latter for
anonymous people too: the time studied in all, per day and the most in
one day, and by days, weeks or months the study time and the ratings as
bars, a tap on a bar saying what it holds. The app draws the charts
itself, as the Progress part's sparkline, rather than taking a chart
package.

A review's time was however long its card was shown: one left on the
screen over lunch counted as an hour of study. It now counts at most a
minute, as Anki does by default, here and in a studyset's Progress; the
stored durations stay as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 18:56:40 +02:00
commit b46dfe22b1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 1506 additions and 43 deletions

View file

@ -98,7 +98,8 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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 (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), 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 **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. 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 lists what is published (see *Explore and copies*). - **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 (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), 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 **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. 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 lists what is published (see *Explore and copies*).
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `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. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. 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`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), 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; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `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. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. 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`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its flashcards, notes and files, a section each, the first three with Show all), Progress (only once anything in it was studied: how much is learnt, what is due today, the study time this week from the reviews' durations, `StudysetActivityCubit`) and Study plan (the paths finished, Next up, the paths). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`). - **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its flashcards, notes and files, a section each, the first three with Show all), Progress (only once anything in it was studied: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`) and Study plan (the paths finished, Next up, the paths). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse in the flashcards' section, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache). - **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse in the flashcards' section, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked. - **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked.
- **Every text field has a limit** (decided 2026-10-11): `TextLimits` (`core/widget/text_limits.dart`) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, a message or an address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names. - **Every text field has a limit** (decided 2026-10-11): `TextLimits` (`core/widget/text_limits.dart`) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, a message or an address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names.

View file

@ -419,6 +419,25 @@
"exploreEmpty": "Lernsets, die andere teilen, erscheinen hier.", "exploreEmpty": "Lernsets, die andere teilen, erscheinen hier.",
"profileTitle": "Profil", "profileTitle": "Profil",
"profileTabProfile": "Profil",
"profileTabStatistics": "Statistik",
"statisticsDays": "Tage",
"statisticsWeeks": "Wochen",
"statisticsMonths": "Monate",
"statisticsTotal": "Insgesamt",
"statisticsPerDay": "Pro Tag",
"statisticsRecord": "Tagesrekord",
"statisticsStudyTime": "Lernzeit",
"statisticsMinutesInfo": "In Minuten, jede Karte höchstens eine Minute gezählt",
"statisticsHoursInfo": "In Stunden, jede Karte höchstens eine Minute gezählt",
"statisticsRatings": "Bewertungen",
"statisticsRatingsInfo": "Wie du die gelernten Karteikarten bewertet hast",
"statisticsRated": "{count, plural, =0{Keine Karte bewertet} =1{1 Karte bewertet} other{{count} Karten bewertet}}",
"@statisticsRated": {"placeholders": {"count": {"type": "int"}}},
"statisticsToday": "Heute",
"statisticsWeekOf": "Woche ab {date}",
"@statisticsWeekOf": {"placeholders": {"date": {"type": "String"}}},
"statisticsNothingYet": "Noch nichts gelernt. Deine Lernzeit und Bewertungen erscheinen hier, sobald du Karteikarten lernst.",
"profileSettingsTooltip": "Einstellungen", "profileSettingsTooltip": "Einstellungen",
"profileIdentified": "Dein Fortschritt wird in deinem Konto gespeichert.", "profileIdentified": "Dein Fortschritt wird in deinem Konto gespeichert.",
"profileAnonymous": "Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.", "profileAnonymous": "Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.",
@ -1670,7 +1689,7 @@
"progressToReviewInfo": "Schon gelernte Karten, die heute wieder fällig sind.", "progressToReviewInfo": "Schon gelernte Karten, die heute wieder fällig sind.",
"progressDueToday": "{count, plural, =1{Karte heute fällig} other{Karten heute fällig}}", "progressDueToday": "{count, plural, =1{Karte heute fällig} other{Karten heute fällig}}",
"progressStudyTime": "Lernzeit", "progressStudyTime": "Lernzeit",
"progressStudyTimeInfo": "Wie lange du die Karteikarten dieses Lernsets in den letzten sieben Tagen auf diesem Gerät gelernt hast.", "progressStudyTimeInfo": "Wie lange du die Karteikarten dieses Lernsets in den letzten sieben Tagen gelernt hast, jede Karte höchstens eine Minute gezählt.",
"progressThisWeek": "diese Woche", "progressThisWeek": "diese Woche",
"progressMinutes": "{minutes} Min.", "progressMinutes": "{minutes} Min.",
"progressHoursMinutes": "{hours} Std. {minutes} Min.", "progressHoursMinutes": "{hours} Std. {minutes} Min.",

View file

@ -470,6 +470,33 @@
"exploreEmpty": "Studysets that others share will appear here.", "exploreEmpty": "Studysets that others share will appear here.",
"profileTitle": "Profile", "profileTitle": "Profile",
"profileTabProfile": "Profile",
"profileTabStatistics": "Statistics",
"statisticsDays": "Days",
"statisticsWeeks": "Weeks",
"statisticsMonths": "Months",
"statisticsTotal": "In total",
"statisticsPerDay": "Per day",
"statisticsRecord": "Daily record",
"statisticsStudyTime": "Study time",
"statisticsMinutesInfo": "In minutes, each card counted for at most a minute",
"statisticsHoursInfo": "In hours, each card counted for at most a minute",
"statisticsRatings": "Ratings",
"statisticsRatingsInfo": "How you rated the flashcards you studied",
"statisticsRated": "{count, plural, =0{No cards rated} =1{1 card rated} other{{count} cards rated}}",
"@statisticsRated": {
"placeholders": {
"count": {"type": "int"}
}
},
"statisticsToday": "Today",
"statisticsWeekOf": "Week of {date}",
"@statisticsWeekOf": {
"placeholders": {
"date": {"type": "String"}
}
},
"statisticsNothingYet": "Nothing studied yet. Your study time and ratings show here once you study flashcards.",
"profileSettingsTooltip": "Settings", "profileSettingsTooltip": "Settings",
"profileIdentified": "Your progress is kept with your account.", "profileIdentified": "Your progress is kept with your account.",
"profileAnonymous": "Progress is saved on this device. Sign in to keep it.", "profileAnonymous": "Progress is saved on this device. Sign in to keep it.",
@ -1962,7 +1989,7 @@
"progressDueToday": "{count, plural, =1{card due today} other{cards due today}}", "progressDueToday": "{count, plural, =1{card due today} other{cards due today}}",
"@progressDueToday": {"placeholders": {"count": {"type": "int"}}}, "@progressDueToday": {"placeholders": {"count": {"type": "int"}}},
"progressStudyTime": "Study time", "progressStudyTime": "Study time",
"progressStudyTimeInfo": "How long you studied this studyset's flashcards on this device in the last seven days.", "progressStudyTimeInfo": "How long you studied this studyset's flashcards in the last seven days, each card counted for at most a minute.",
"progressThisWeek": "this week", "progressThisWeek": "this week",
"progressMinutes": "{minutes} min", "progressMinutes": "{minutes} min",
"@progressMinutes": {"placeholders": {"minutes": {"type": "int"}}}, "@progressMinutes": {"placeholders": {"minutes": {"type": "int"}}},

View file

@ -2536,6 +2536,108 @@ abstract class AppLocalizations {
/// **'Profile'** /// **'Profile'**
String get profileTitle; String get profileTitle;
/// No description provided for @profileTabProfile.
///
/// In en, this message translates to:
/// **'Profile'**
String get profileTabProfile;
/// No description provided for @profileTabStatistics.
///
/// In en, this message translates to:
/// **'Statistics'**
String get profileTabStatistics;
/// No description provided for @statisticsDays.
///
/// In en, this message translates to:
/// **'Days'**
String get statisticsDays;
/// No description provided for @statisticsWeeks.
///
/// In en, this message translates to:
/// **'Weeks'**
String get statisticsWeeks;
/// No description provided for @statisticsMonths.
///
/// In en, this message translates to:
/// **'Months'**
String get statisticsMonths;
/// No description provided for @statisticsTotal.
///
/// In en, this message translates to:
/// **'In total'**
String get statisticsTotal;
/// No description provided for @statisticsPerDay.
///
/// In en, this message translates to:
/// **'Per day'**
String get statisticsPerDay;
/// No description provided for @statisticsRecord.
///
/// In en, this message translates to:
/// **'Daily record'**
String get statisticsRecord;
/// No description provided for @statisticsStudyTime.
///
/// In en, this message translates to:
/// **'Study time'**
String get statisticsStudyTime;
/// No description provided for @statisticsMinutesInfo.
///
/// In en, this message translates to:
/// **'In minutes, each card counted for at most a minute'**
String get statisticsMinutesInfo;
/// No description provided for @statisticsHoursInfo.
///
/// In en, this message translates to:
/// **'In hours, each card counted for at most a minute'**
String get statisticsHoursInfo;
/// No description provided for @statisticsRatings.
///
/// In en, this message translates to:
/// **'Ratings'**
String get statisticsRatings;
/// No description provided for @statisticsRatingsInfo.
///
/// In en, this message translates to:
/// **'How you rated the flashcards you studied'**
String get statisticsRatingsInfo;
/// No description provided for @statisticsRated.
///
/// In en, this message translates to:
/// **'{count, plural, =0{No cards rated} =1{1 card rated} other{{count} cards rated}}'**
String statisticsRated(int count);
/// No description provided for @statisticsToday.
///
/// In en, this message translates to:
/// **'Today'**
String get statisticsToday;
/// No description provided for @statisticsWeekOf.
///
/// In en, this message translates to:
/// **'Week of {date}'**
String statisticsWeekOf(String date);
/// No description provided for @statisticsNothingYet.
///
/// In en, this message translates to:
/// **'Nothing studied yet. Your study time and ratings show here once you study flashcards.'**
String get statisticsNothingYet;
/// No description provided for @profileSettingsTooltip. /// No description provided for @profileSettingsTooltip.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -9882,7 +9984,7 @@ abstract class AppLocalizations {
/// No description provided for @progressStudyTimeInfo. /// No description provided for @progressStudyTimeInfo.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'How long you studied this studyset\'s flashcards on this device in the last seven days.'** /// **'How long you studied this studyset\'s flashcards in the last seven days, each card counted for at most a minute.'**
String get progressStudyTimeInfo; String get progressStudyTimeInfo;
/// No description provided for @progressThisWeek. /// No description provided for @progressThisWeek.

View file

@ -1558,6 +1558,72 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get profileTitle => 'Profil'; String get profileTitle => 'Profil';
@override
String get profileTabProfile => 'Profil';
@override
String get profileTabStatistics => 'Statistik';
@override
String get statisticsDays => 'Tage';
@override
String get statisticsWeeks => 'Wochen';
@override
String get statisticsMonths => 'Monate';
@override
String get statisticsTotal => 'Insgesamt';
@override
String get statisticsPerDay => 'Pro Tag';
@override
String get statisticsRecord => 'Tagesrekord';
@override
String get statisticsStudyTime => 'Lernzeit';
@override
String get statisticsMinutesInfo =>
'In Minuten, jede Karte höchstens eine Minute gezählt';
@override
String get statisticsHoursInfo =>
'In Stunden, jede Karte höchstens eine Minute gezählt';
@override
String get statisticsRatings => 'Bewertungen';
@override
String get statisticsRatingsInfo =>
'Wie du die gelernten Karteikarten bewertet hast';
@override
String statisticsRated(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karten bewertet',
one: '1 Karte bewertet',
zero: 'Keine Karte bewertet',
);
return '$_temp0';
}
@override
String get statisticsToday => 'Heute';
@override
String statisticsWeekOf(String date) {
return 'Woche ab $date';
}
@override
String get statisticsNothingYet =>
'Noch nichts gelernt. Deine Lernzeit und Bewertungen erscheinen hier, sobald du Karteikarten lernst.';
@override @override
String get profileSettingsTooltip => 'Einstellungen'; String get profileSettingsTooltip => 'Einstellungen';
@ -6222,7 +6288,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get progressStudyTimeInfo => String get progressStudyTimeInfo =>
'Wie lange du die Karteikarten dieses Lernsets in den letzten sieben Tagen auf diesem Gerät gelernt hast.'; 'Wie lange du die Karteikarten dieses Lernsets in den letzten sieben Tagen gelernt hast, jede Karte höchstens eine Minute gezählt.';
@override @override
String get progressThisWeek => 'diese Woche'; String get progressThisWeek => 'diese Woche';

View file

@ -1545,6 +1545,72 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get profileTitle => 'Profile'; String get profileTitle => 'Profile';
@override
String get profileTabProfile => 'Profile';
@override
String get profileTabStatistics => 'Statistics';
@override
String get statisticsDays => 'Days';
@override
String get statisticsWeeks => 'Weeks';
@override
String get statisticsMonths => 'Months';
@override
String get statisticsTotal => 'In total';
@override
String get statisticsPerDay => 'Per day';
@override
String get statisticsRecord => 'Daily record';
@override
String get statisticsStudyTime => 'Study time';
@override
String get statisticsMinutesInfo =>
'In minutes, each card counted for at most a minute';
@override
String get statisticsHoursInfo =>
'In hours, each card counted for at most a minute';
@override
String get statisticsRatings => 'Ratings';
@override
String get statisticsRatingsInfo =>
'How you rated the flashcards you studied';
@override
String statisticsRated(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count cards rated',
one: '1 card rated',
zero: 'No cards rated',
);
return '$_temp0';
}
@override
String get statisticsToday => 'Today';
@override
String statisticsWeekOf(String date) {
return 'Week of $date';
}
@override
String get statisticsNothingYet =>
'Nothing studied yet. Your study time and ratings show here once you study flashcards.';
@override @override
String get profileSettingsTooltip => 'Settings'; String get profileSettingsTooltip => 'Settings';
@ -6166,7 +6232,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get progressStudyTimeInfo => String get progressStudyTimeInfo =>
'How long you studied this studyset\'s flashcards on this device in the last seven days.'; 'How long you studied this studyset\'s flashcards in the last seven days, each card counted for at most a minute.';
@override @override
String get progressThisWeek => 'this week'; String get progressThisWeek => 'this week';

View file

@ -0,0 +1,10 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
/// [time] as the app says how long someone studied: minutes, rounded, and
/// from an hour on hours and minutes ("45 min", "2 h 5 min").
String studyTimeText(AppLocalizations l10n, Duration time) {
final minutes = (time.inSeconds / 60).round();
return minutes < 60
? l10n.progressMinutes(minutes)
: l10n.progressHoursMinutes(minutes ~/ 60, minutes % 60);
}

View file

@ -13,11 +13,14 @@ import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/inbox_section.dart'; import 'package:halkyon_learn_flutter/src/feature/feedback/widget/inbox_section.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/widget/public_profile_section.dart'; import 'package:halkyon_learn_flutter/src/feature/profile/widget/public_profile_section.dart';
import 'package:halkyon_learn_flutter/src/feature/statistics/widget/statistics_view.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// The profile tab: what the account means, and signing in with GitHub to /// The profile tab, in two tabs as Vaia has it (decided 2026-10-12): the
/// keep an anonymous one. Settings are behind the gear. /// profile (what the account means, and signing in with GitHub to keep an
/// anonymous one) and the person's statistics, which anonymous people have
/// too. Settings are behind the gear.
class const ProfileScreen({super.key}) extends StatelessWidget { class const ProfileScreen({super.key}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -30,24 +33,41 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
child: TopGradient( child: TopGradient(
color: Brand.primary, color: Brand.primary,
background: Theme.of(context).colorScheme.surface, background: Theme.of(context).colorScheme.surface,
child: Scaffold( child: DefaultTabController(
backgroundColor: Colors.transparent, length: 2,
appBar: AppBar( child: Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context), appBar: AppBar(
title: Text(l10n.profileTitle), backgroundColor: Colors.transparent,
actions: [ systemOverlayStyle: TopGradient.overlayStyle(context),
IconButton( title: Text(l10n.profileTitle),
icon: const SyncTroubleBadge( actions: [
child: Icon(LucideIcons.settings), IconButton(
icon: const SyncTroubleBadge(
child: Icon(LucideIcons.settings),
),
tooltip: l10n.profileSettingsTooltip,
onPressed: () => context.push(RoutePaths.settings),
), ),
tooltip: l10n.profileSettingsTooltip, const SizedBox(width: 4),
onPressed: () => context.push(RoutePaths.settings), ],
bottom: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
dividerColor: Colors.transparent,
tabs: [
Tab(text: l10n.profileTabProfile),
Tab(text: l10n.profileTabStatistics),
],
), ),
const SizedBox(width: 4), ),
], body: const TabBarView(
children: [
_CancelWhenLeft(child: _Account()),
StatisticsView(),
],
),
), ),
body: const _CancelWhenLeft(child: _Account()),
), ),
), ),
); );

View file

@ -0,0 +1,108 @@
import 'dart:async';
import 'dart:developer';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/statistics/model/study_statistics.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
/// The person's statistics as the Profile tab shows them: by the [span]
/// chosen; null while being read.
final class const StatisticsState({
/// The statistics; null until read.
final StudyStatistics? statistics,
/// What a bar stands for.
final StatisticsSpan span = StatisticsSpan.days,
}) with Equatable {
@override
List<Object?> get props => [statistics, span];
}
/// Works the person's statistics out from all their reviews (every device's,
/// as synced), again once reviews or studysets stop changing for a moment,
/// as Home's dashboard does. What waits in Recently deleted counts nowhere,
/// as if it were gone, until it is restored.
class StatisticsCubit(
final ReviewRepository _reviews,
final CardRepository _cards,
final StudysetRepository _studysets,
) extends Cubit<StatisticsState> {
this : super(const StatisticsState()) {
_subscriptions.addAll([
_reviews.changes.listen(_changed),
_studysets.watchAll().listen(_changed, onError: (_) {}),
]);
}
/// How long changes must pause before the statistics are worked out
/// anew, as a study session rates card after card.
static const settleTime = Duration(milliseconds: 300);
final _subscriptions = <StreamSubscription<Object?>>[];
Timer? _settling;
/// The reviews counted, as last read.
List<Review>? _counted;
/// Shows the bars by [span].
void show(StatisticsSpan span) {
final counted = _counted;
emit(
StatisticsState(
statistics: counted == null ? null : _of(counted, span),
span: span,
),
);
}
void _changed(Object? _) {
_settling?.cancel();
_settling = Timer(settleTime, () => unawaited(_read()));
}
Future<void> _read() async {
try {
final everything = await _studysets.withDeleted();
final kept = {for (final studyset in notDeleted(everything)) studyset.id};
final gone = <String>{
for (final studyset in everything)
if (!kept.contains(studyset.id))
for (final card in await _cards.cardsOf(studyset.id)) card.id,
};
final counted = [
for (final review in await _reviews.all())
if (!gone.contains(review.cardId)) review,
];
if (isClosed) {
return;
}
_counted = counted;
show(state.span);
} on Object catch (error) {
// The next change reads again.
log(
'Working out the statistics failed',
name: 'Statistics',
error: error,
);
}
}
StudyStatistics _of(List<Review> reviews, StatisticsSpan span) =>
studyStatistics(reviews, now: clock.now(), span: span);
@override
Future<void> close() async {
_settling?.cancel();
for (final subscription in _subscriptions) {
await subscription.cancel();
}
await super.close();
}
}

View file

@ -0,0 +1,129 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
/// What a bar of the statistics stands for: a day, a week (from Monday) or
/// a month.
enum StatisticsSpan() {
days,
weeks,
months;
/// How many bars it shows: ten days or weeks, a year of months.
int get shown => this == months ? 12 : 10;
/// The local dates the bars shown on [today] start on, the oldest first,
/// the last one's period holding [today].
List<DateTime> startsOn(DateTime today) => switch (this) {
days => [for (var i = shown - 1; i >= 0; i--) addDays(today, -i)],
weeks => [
for (var i = shown - 1; i >= 0; i--) addDays(mondayOf(today), -7 * i),
],
months => [
for (var i = shown - 1; i >= 0; i--)
DateTime(today.year, today.month - i),
],
};
}
/// A bar of the statistics: the day, week or month from [start], the time
/// studied in it, and how its cards were rated.
final class const StatisticsPeriod({
/// The local date it starts on.
required final DateTime start,
/// The time studied, each review counted at most [Review.maxStudyTime].
required final Duration time,
/// How many reviews had each rating.
required final Map<Rating, int> ratings,
}) with Equatable {
/// How many reviews it holds.
int get reviews => ratings.values.fold(0, (sum, count) => sum + count);
@override
List<Object?> get props => [start, time, ratings];
}
/// The person's statistics (decided 2026-10-12, as Vaia shows them): the
/// time studied in all, per day since the first day studied, and the most
/// in one day; and for each day, week or month shown its time and ratings.
/// Every rating counts, crammed ones too; a fresh start isn't studying.
final class const StudyStatistics({
/// The time studied in all.
required final Duration total,
/// The time studied per day on average, every day from the first day
/// studied to today counted, studied or not.
required final Duration perDay,
/// The most time studied in one day.
required final Duration record,
/// The days, weeks or months shown, the oldest first.
required final List<StatisticsPeriod> periods,
/// Whether anything was studied yet: a card rated, however quickly.
required final bool studied,
}) with Equatable {
@override
List<Object?> get props => [total, perDay, record, periods, studied];
}
/// The statistics of [reviews] as of [now], the bars by [span]; days are
/// the device's local days.
StudyStatistics studyStatistics(
Iterable<Review> reviews, {
required DateTime now,
required StatisticsSpan span,
}) {
final today = localDay(now);
final starts = span.startsOn(today);
final end = addDays(today, 1);
final times = List.filled(starts.length, Duration.zero);
final ratings = [for (final _ in starts) <Rating, int>{}];
final byDay = <DateTime, Duration>{};
var total = Duration.zero;
DateTime? first;
for (final review in reviews) {
// A fresh start of a card's progress is no studying.
if (!review.mode.rates) {
continue;
}
final day = localDay(review.reviewedAt);
final time = review.studyTime;
total += time;
byDay[day] = (byDay[day] ?? Duration.zero) + time;
if (first == null || day.isBefore(first)) {
first = day;
}
if (day.isBefore(starts.first) || !day.isBefore(end)) {
continue;
}
final index = starts.lastIndexWhere((start) => !day.isBefore(start));
times[index] += time;
ratings[index][review.rating] = (ratings[index][review.rating] ?? 0) + 1;
}
// Rounded: a day with a clock change is 23 or 25 hours long.
final days = first == null
? 0
: (today.difference(first).inHours / 24).round() + 1;
return StudyStatistics(
total: total,
perDay: days == 0 ? Duration.zero : total ~/ days,
record: byDay.values.fold(
Duration.zero,
(most, time) => time > most ? time : most,
),
periods: [
for (final (index, start) in starts.indexed)
StatisticsPeriod(
start: start,
time: times[index],
ratings: ratings[index],
),
],
studied: first != null,
);
}

View file

@ -0,0 +1,213 @@
import 'dart:math' as math;
import 'package:material_ui/material_ui.dart';
/// One bar's segments, the lowest first: each a value and its colour.
typedef BarStack = List<({double value, Color color})>;
/// A bar chart drawn by the app (no chart package: the app draws its few
/// charts itself, as the Progress part's sparkline): a bar per [labels]
/// entry, each stacked of its [stacks] segments, against a scale of round
/// steps whose values [scaleLabel] writes, with lines across. A tap or a
/// click picks a bar ([onPicked]); the bar [picked] stands out, the others
/// fade a little.
class const BarChart({
/// What each bar stands for, below it.
required final List<String> labels,
/// Each bar's segments.
required final List<BarStack> stacks,
/// How a step of the scale is written.
required final String Function(double value) scaleLabel,
/// The bar picked, if any.
required final int? picked,
/// Picks a bar.
required final ValueChanged<int> onPicked,
final double height = 180,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme.labelSmall!.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final painter = _BarPainter(
labels: labels,
stacks: stacks,
scaleLabel: scaleLabel,
picked: picked,
style: style,
lines: theme.colorScheme.outlineVariant,
direction: Directionality.of(context),
);
// As wide as it may be, as tall as given: no LayoutBuilder, which
// couldn't say its height to a row evening out its cards'.
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
if (context.size case final size?) {
if (painter.barAt(details.localPosition, size) case final bar?) {
onPicked(bar);
}
}
},
child: SizedBox(
width: double.infinity,
height: height,
child: CustomPaint(painter: painter),
),
);
}
}
class _BarPainter({
required final List<String> labels,
required final List<BarStack> stacks,
required final String Function(double value) scaleLabel,
required final int? picked,
required final TextStyle style,
required final Color lines,
required final TextDirection direction,
}) extends CustomPainter {
/// Room below the bars for their labels.
static const _labelRoom = 20.0;
/// The gap between the scale's labels and the bars.
static const _gap = 8.0;
/// The round steps the scale is made of, and how many it has.
({double step, int count}) get _scale {
final most = stacks.fold<double>(
0,
(most, stack) => math.max(
most,
stack.fold<double>(0, (sum, segment) => sum + segment.value),
),
);
if (most <= 0) {
return (step: 1, count: 4);
}
// A step of 1, 2 or 5 times a power of ten, for about four steps.
final rough = most / 4;
final power = math
.pow(10, (math.log(rough) / math.ln10).floor())
.toDouble();
final step = [
1,
2,
5,
10,
].map((each) => each * power).firstWhere((each) => each >= rough);
return (step: step, count: (most / step).ceil());
}
TextPainter _text(String text) => TextPainter(
text: TextSpan(text: text, style: style),
textDirection: direction,
maxLines: 1,
)..layout();
/// Where the bars lie: from the end of the scale's labels to the edge.
Rect _plot(Size size) {
final (:step, :count) = _scale;
final widest = [
for (var i = 0; i <= count; i++) _text(scaleLabel(step * i)).width,
].fold<double>(0, math.max);
return Rect.fromLTRB(
widest + _gap,
style.fontSize! / 2,
size.width,
size.height - _labelRoom,
);
}
/// The bar at [point], if any: anywhere in its column.
int? barAt(Offset point, Size size) {
final plot = _plot(size);
if (stacks.isEmpty || point.dx < plot.left || point.dx > plot.right) {
return null;
}
final slot = plot.width / stacks.length;
return ((point.dx - plot.left) / slot).floor().clamp(0, stacks.length - 1);
}
@override
void paint(Canvas canvas, Size size) {
final (:step, :count) = _scale;
final plot = _plot(size);
final top = step * count;
double y(double value) => plot.bottom - value / top * plot.height;
final line = Paint()
..color = lines
..strokeWidth = 1;
for (var i = 0; i <= count; i++) {
final at = y(step * i);
canvas.drawLine(Offset(plot.left, at), Offset(plot.right, at), line);
final label = _text(scaleLabel(step * i));
label.paint(
canvas,
Offset(plot.left - _gap - label.width, at - label.height / 2),
);
}
final slot = plot.width / stacks.length;
final width = math.min<double>(slot * 0.5, 28);
// As many labels as fit, from the newest back.
final labelWidths = [for (final label in labels) _text(label).width];
final widest = labelWidths.fold<double>(0, math.max);
final every = math.max(1, ((widest + 8) / slot).ceil());
for (final (index, stack) in stacks.indexed) {
final middle = plot.left + slot * (index + 0.5);
final faded = picked != null && picked != index;
var base = 0.0;
final segments = [
for (final segment in stack)
if (segment.value > 0) segment,
];
for (final (i, segment) in segments.indexed) {
final rect = Rect.fromLTRB(
middle - width / 2,
y(base + segment.value),
middle + width / 2,
y(base),
);
base += segment.value;
final radius = Radius.circular(math.min(4, width / 2));
canvas.drawRRect(
i == segments.length - 1
? RRect.fromRectAndCorners(
rect,
topLeft: radius,
topRight: radius,
)
: RRect.fromRectAndCorners(rect),
Paint()
..color = faded
? segment.color.withValues(alpha: 0.45)
: segment.color,
);
}
if ((stacks.length - 1 - index) % every == 0) {
final label = _text(labels[index]);
label.paint(
canvas,
Offset(middle - label.width / 2, plot.bottom + 6),
);
}
}
}
@override
// The bars are made anew with every build, so new lists mean new bars.
bool shouldRepaint(_BarPainter old) =>
!identical(old.stacks, stacks) ||
!identical(old.labels, labels) ||
old.picked != picked ||
old.style != style ||
old.lines != lines;
}

View file

@ -0,0 +1,419 @@
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/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/study_time.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/statistics/bloc/statistics_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/statistics/model/study_statistics.dart';
import 'package:halkyon_learn_flutter/src/feature/statistics/widget/bar_chart.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/rating_row.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The person's statistics, the Profile tab's second tab (decided
/// 2026-10-12, as Vaia shows them): by days, weeks or months; the time
/// studied in all, per day and the most in one day; and the study time
/// and the ratings of each day, week or month shown, side by side from
/// expanded windows on. A tap on a bar says what it holds.
class const StatisticsView({super.key}) extends StatelessWidget {
/// From how wide the two charts stand side by side.
static const sideBySideWidth = 840.0;
/// How wide the statistics grow at most, centred beyond.
static const maxWidth = 1200.0;
@override
Widget build(BuildContext context) {
final person = PersonScope.of(context);
return BlocProvider(
create: (context) =>
StatisticsCubit(person.reviews, person.cards, person.studysets),
child: BlocBuilder<StatisticsCubit, StatisticsState>(
builder: (context, state) => switch (state.statistics) {
null => const Center(child: AppLoadingIndicator()),
final statistics => _Statistics(
// Each span starts with its newest bar picked.
key: ValueKey(state.span),
statistics: statistics,
span: state.span,
),
},
),
);
}
}
class const _Statistics({
required final StudyStatistics statistics,
required final StatisticsSpan span,
super.key,
}) extends StatefulWidget {
@override
State<_Statistics> createState() => _StatisticsState();
}
class _StatisticsState() extends State<_Statistics> {
/// The bar picked: the newest at first.
late int _picked = widget.statistics.periods.length - 1;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final statistics = widget.statistics;
final span = widget.span;
final charts = [
_StudyTimeChart(
statistics: statistics,
span: span,
picked: _picked,
onPicked: (index) => setState(() => _picked = index),
),
_RatingsChart(
statistics: statistics,
span: span,
picked: _picked,
onPicked: (index) => setState(() => _picked = index),
),
];
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: StatisticsView.maxWidth),
child: LayoutBuilder(
builder: (context, constraints) => ListView(
// Room at the end for the navigation bar floating over the foot.
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.paddingOf(context).bottom,
),
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: SegmentedButton<StatisticsSpan>(
segments: [
ButtonSegment(
value: StatisticsSpan.days,
label: Text(l10n.statisticsDays),
),
ButtonSegment(
value: StatisticsSpan.weeks,
label: Text(l10n.statisticsWeeks),
),
ButtonSegment(
value: StatisticsSpan.months,
label: Text(l10n.statisticsMonths),
),
],
selected: {span},
showSelectedIcon: false,
onSelectionChanged: (chosen) =>
context.read<StatisticsCubit>().show(chosen.single),
),
),
const SizedBox(height: 16),
_Totals(statistics: statistics),
const SizedBox(height: 16),
if (!statistics.studied)
_Nothing(text: l10n.statisticsNothingYet)
else if (constraints.maxWidth >= StatisticsView.sideBySideWidth)
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: charts.first),
const SizedBox(width: 16),
Expanded(child: charts.last),
],
),
)
else ...[
charts.first,
const SizedBox(height: 16),
charts.last,
],
],
),
),
),
);
}
}
/// The time studied in all, per day, and the most in one day, side by side
/// with lines between.
class const _Totals({required final StudyStatistics statistics})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
Widget total(String label, Duration time) => Expanded(
child: _Total(label: label, value: studyTimeText(l10n, time)),
);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: IntrinsicHeight(
child: Row(
children: [
total(l10n.statisticsTotal, statistics.total),
const VerticalDivider(width: 1),
total(l10n.statisticsPerDay, statistics.perDay),
const VerticalDivider(width: 1),
total(l10n.statisticsRecord, statistics.record),
],
),
),
),
);
}
}
class const _Total({required final String label, required final String value})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
maxLines: 1,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 4),
Text(
label,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
/// A chart's card: its title, what it shows, what the bar picked holds, and
/// below the chart itself.
class const _ChartCard({
required final String title,
required final String info,
required final String picked,
required final Widget chart,
final Widget? legend,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 2),
Text(info, style: muted),
const SizedBox(height: 12),
Text(picked, style: theme.textTheme.labelLarge),
if (legend case final legend?) ...[
const SizedBox(height: 8),
legend,
],
const SizedBox(height: 12),
chart,
],
),
),
);
}
}
class const _StudyTimeChart({
required final StudyStatistics statistics,
required final StatisticsSpan span,
required final int picked,
required final ValueChanged<int> onPicked,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final color = Theme.of(context).colorScheme.primary;
final periods = statistics.periods;
// Minutes up to two hours a bar, hours beyond.
final inHours = periods.any((p) => p.time >= const Duration(hours: 2));
double valueOf(Duration time) =>
inHours ? time.inSeconds / 3600 : time.inSeconds / 60;
final number = NumberFormat.decimalPattern(locale);
return _ChartCard(
title: l10n.statisticsStudyTime,
info: inHours ? l10n.statisticsHoursInfo : l10n.statisticsMinutesInfo,
picked:
'${periodName(l10n, locale, span, periods[picked].start)} · '
'${studyTimeText(l10n, periods[picked].time)}',
chart: BarChart(
labels: [
for (final period in periods)
barLabel(l10n, locale, span, period.start),
],
stacks: [
for (final period in periods)
[(value: valueOf(period.time), color: color)],
],
scaleLabel: number.format,
picked: picked,
onPicked: onPicked,
),
);
}
}
class const _RatingsChart({
required final StudyStatistics statistics,
required final StatisticsSpan span,
required final int picked,
required final ValueChanged<int> onPicked,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final periods = statistics.periods;
final shown = periods[picked];
return _ChartCard(
title: l10n.statisticsRatings,
info: l10n.statisticsRatingsInfo,
picked:
'${periodName(l10n, locale, span, shown.start)} · '
'${l10n.statisticsRated(shown.reviews)}',
legend: Wrap(
spacing: 12,
runSpacing: 4,
children: [
for (final rating in Rating.values.reversed)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: ratingColor(context, rating),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
'${ratingLabel(l10n, rating)} ${shown.ratings[rating] ?? 0}',
style: theme.textTheme.bodySmall,
),
],
),
],
),
chart: BarChart(
labels: [
for (final period in periods)
barLabel(l10n, locale, span, period.start),
],
stacks: [
for (final period in periods)
[
for (final rating in Rating.values)
(
value: (period.ratings[rating] ?? 0).toDouble(),
color: ratingColor(context, rating),
),
],
],
scaleLabel: NumberFormat.decimalPattern(locale).format,
picked: picked,
onPicked: onPicked,
),
);
}
}
/// What shows while nothing was studied yet.
class const _Nothing({required final String text}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16),
child: Column(
children: [
Icon(
LucideIcons.chartColumn,
size: 40,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
text,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
/// The day, week or month from [start] by name: "Today", "Mon, 6 Oct",
/// "Week of 6 Oct", "October 2026".
String periodName(
AppLocalizations l10n,
String locale,
StatisticsSpan span,
DateTime start,
) => switch (span) {
StatisticsSpan.days when start == localDay(clock.now()) =>
l10n.statisticsToday,
StatisticsSpan.days => DateFormat.MMMEd(locale).format(start),
StatisticsSpan.weeks => l10n.statisticsWeekOf(
DateFormat.MMMd(locale).format(start),
),
StatisticsSpan.months => DateFormat.yMMMM(locale).format(start),
};
/// The day, week or month from [start] as a bar's label: "Today", "Mon",
/// "6 Oct", "Oct".
String barLabel(
AppLocalizations l10n,
String locale,
StatisticsSpan span,
DateTime start,
) => switch (span) {
StatisticsSpan.days when start == localDay(clock.now()) =>
l10n.statisticsToday,
StatisticsSpan.days => DateFormat.E(locale).format(start),
StatisticsSpan.weeks => DateFormat.MMMd(locale).format(start),
StatisticsSpan.months => DateFormat.MMM(locale).format(start),
};

View file

@ -22,6 +22,15 @@ final class const Review({
/// How the card was studied, or a fresh start of its progress. /// How the card was studied, or a fresh start of its progress.
final StudyMode mode = StudyMode.review, final StudyMode mode = StudyMode.review,
}) with Equatable { }) with Equatable {
/// The most a review counts as studying (decided 2026-10-12, as Anki
/// does by default): a card left on the screen over lunch isn't an hour
/// of study. Stored durations stay as they were.
static const maxStudyTime = Duration(seconds: 60);
/// How long it counts as studying: its [duration], at most
/// [maxStudyTime].
Duration get studyTime => duration > maxStudyTime ? maxStudyTime : duration;
@override @override
List<Object?> get props => [id, cardId, rating, reviewedAt, duration, mode]; List<Object?> get props => [id, cardId, rating, reviewedAt, duration, mode];
} }

View file

@ -48,7 +48,7 @@ class StudysetActivityCubit(
today.difference(DateTime(at.year, at.month, at.day)).inHours / 24; today.difference(DateTime(at.year, at.month, at.day)).inHours / 24;
final index = 6 - daysAgo.round(); final index = 6 - daysAgo.round();
if (index >= 0 && index < 7) { if (index >= 0 && index < 7) {
week[index] += review.duration; week[index] += review.studyTime;
} }
} }
if (!isClosed) { if (!isClosed) {

View file

@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/study_time.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart'; import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart'; import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
@ -562,7 +563,7 @@ class const ProgressPart({
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(_duration(l10n, studied), style: big, maxLines: 1), Text(studyTimeText(l10n, studied), style: big, maxLines: 1),
Row( Row(
children: [ children: [
Expanded(child: Text(l10n.progressThisWeek, style: muted)), Expanded(child: Text(l10n.progressThisWeek, style: muted)),
@ -662,13 +663,6 @@ class const ProgressPart({
_foot, _foot,
]); ]);
} }
static String _duration(AppLocalizations l10n, Duration duration) {
final minutes = (duration.inSeconds / 60).round();
return minutes < 60
? l10n.progressMinutes(minutes)
: l10n.progressHoursMinutes(minutes ~/ 60, minutes % 60);
}
} }
/// A box of the progress: its [title] with what it means ([info]) behind /// A box of the progress: its [title] with what it means ([info]) behind

View file

@ -0,0 +1,108 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/statistics/model/study_statistics.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
void main() {
// Monday, 12 October 2026, in the afternoon, local time.
final now = DateTime(2026, 10, 12, 15);
var ids = 0;
Review review(
DateTime at, {
Duration duration = const Duration(seconds: 20),
Rating rating = Rating.good,
StudyMode mode = StudyMode.review,
}) => Review(
id: 'review-${ids++}',
cardId: 'card',
rating: rating,
reviewedAt: at.toUtc(),
duration: duration,
mode: mode,
);
test('counts at most a minute a card, cramming too, a fresh start '
'not', () {
final statistics = studyStatistics(
[
review(now, duration: const Duration(minutes: 45)),
review(now, duration: const Duration(seconds: 30)),
review(now, mode: StudyMode.cram),
review(
now,
duration: const Duration(minutes: 5),
mode: StudyMode.reset,
),
],
now: now,
span: StatisticsSpan.days,
);
expect(statistics.total, const Duration(seconds: 110));
expect(statistics.periods.last.time, const Duration(seconds: 110));
expect(statistics.periods.last.reviews, 3);
expect(statistics.studied, true);
});
test('per day counts every day from the first studied to today; the '
'record is the most in one day', () {
final statistics = studyStatistics(
[
// Three days ago, 2 minutes; today, 1.
for (var i = 0; i < 2; i++)
review(
now.subtract(const Duration(days: 3)),
duration: const Duration(minutes: 1),
),
review(now, duration: const Duration(minutes: 1)),
],
now: now,
span: StatisticsSpan.days,
);
expect(statistics.total, const Duration(minutes: 3));
// Four days: three ago, two, one, today.
expect(statistics.perDay, const Duration(seconds: 45));
expect(statistics.record, const Duration(minutes: 2));
});
test('ten days, today last; ten weeks from Monday; twelve months', () {
final days = studyStatistics([], now: now, span: StatisticsSpan.days);
expect(days.periods, hasLength(10));
expect(days.periods.first.start, DateTime(2026, 10, 3));
expect(days.periods.last.start, DateTime(2026, 10, 12));
expect(days.studied, false);
final weeks = studyStatistics([], now: now, span: StatisticsSpan.weeks);
expect(weeks.periods, hasLength(10));
expect(weeks.periods.last.start, DateTime(2026, 10, 12));
expect(weeks.periods.first.start, DateTime(2026, 8, 10));
final months = studyStatistics([], now: now, span: StatisticsSpan.months);
expect(months.periods, hasLength(12));
expect(months.periods.first.start, DateTime(2025, 11));
expect(months.periods.last.start, DateTime(2026, 10));
});
test("each period holds its reviews' time and ratings; older ones count "
'in the totals only', () {
final statistics = studyStatistics(
[
review(DateTime(2026, 10, 6, 9), rating: Rating.again),
review(DateTime(2026, 10, 11, 23), rating: Rating.easy),
review(DateTime(2026, 10, 12, 8), rating: Rating.easy),
review(DateTime(2026, 7, 1, 8)),
],
now: now,
span: StatisticsSpan.weeks,
);
final [...earlier, lastWeek, thisWeek] = statistics.periods;
expect(thisWeek.ratings, {Rating.easy: 1});
expect(lastWeek.ratings, {Rating.again: 1, Rating.easy: 1});
expect(lastWeek.time, const Duration(seconds: 40));
expect(earlier.every((period) => period.reviews == 0), true);
expect(statistics.total, const Duration(seconds: 80));
});
}

View file

@ -0,0 +1,169 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/statistics/widget/bar_chart.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cardGateway;
late FakeReviewGateway reviewGateway;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
cardGateway = FakeCardGateway();
reviewGateway = FakeReviewGateway();
});
/// A card of Japanese, rated with each of [ratings]: when, how, and how
/// long it took.
Future<void> studied(
List<({Duration ago, Rating rating, Duration took})> ratings,
) async {
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
final reviews = ReviewRepository(reviewGateway);
addTearDown(reviews.dispose);
final card = await cards.create(
'japanese',
CardContent(front: RichContent.plain('猫'), back: RichContent.empty()),
);
for (final (:ago, :rating, :took) in ratings) {
await reviews.record(
cardId: card,
rating: rating,
reviewedAt: clock.now().toUtc().subtract(ago),
duration: took,
);
}
}
Future<void> showStatistics(WidgetTester tester, {double width = 400}) async {
tester.view
..physicalSize = Size(width, 1600)
..devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
reviewGateway: reviewGateway,
),
);
await openTab(tester, 'Profile');
await tester.tap(find.widgetWithText(Tab, 'Statistics'));
await tester.pumpAndSettle();
// Worked out once the reviews stop changing for a moment.
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
testWidgets('the Profile tab has the statistics beside the profile: the '
'time in all, per day and the most in a day, each card counted for '
'at most a minute, and the ratings', (tester) async {
// Today 2 min (one card left on the screen for ten), yesterday 30 s.
await studied([
(
ago: Duration.zero,
rating: Rating.easy,
took: const Duration(minutes: 10),
),
(
ago: Duration.zero,
rating: Rating.good,
took: const Duration(seconds: 60),
),
(
ago: const Duration(days: 1),
rating: Rating.again,
took: const Duration(seconds: 30),
),
]);
await showStatistics(tester);
expect(find.text('In total'), findsOneWidget);
// 2 min 30 s; 1 min 15 s a day over two days; 2 min today.
expect(find.text('3 min'), findsOneWidget);
expect(find.text('1 min'), findsOneWidget);
expect(find.text('Today · 2 min'), findsOneWidget);
expect(find.text('Today · 2 cards rated'), findsOneWidget);
expect(find.text('Perfect 1'), findsOneWidget);
expect(find.text('Bad 0'), findsOneWidget);
expect(find.byType(BarChart), findsNWidgets(2));
});
testWidgets('a tap on a bar says what it holds; Weeks shows weeks', (
tester,
) async {
await studied([
(
ago: const Duration(days: 1),
rating: Rating.again,
took: const Duration(seconds: 30),
),
]);
await showStatistics(tester);
expect(find.text('Today · No cards rated'), findsOneWidget);
// Yesterday's bar: the second from the end.
final chart = tester.getRect(find.byType(BarChart).last);
await tester.tapAt(
Offset(chart.right - (chart.width - 24) / 10 * 1.5, chart.center.dy),
);
await tester.pumpAndSettle();
expect(find.textContaining(' · 1 card rated'), findsOneWidget);
expect(find.text('Bad 1'), findsOneWidget);
await tester.tap(find.text('Weeks'));
await tester.pumpAndSettle();
expect(find.textContaining('Week of '), findsNWidgets(2));
});
testWidgets('from expanded windows on, the two charts stand side by side, '
'as tall as each other', (tester) async {
await studied([
(
ago: Duration.zero,
rating: Rating.good,
took: const Duration(seconds: 20),
),
]);
await showStatistics(tester, width: 1200);
final cards = find.ancestor(
of: find.byType(BarChart),
matching: find.byType(Card),
);
final [first, second] = [
for (final element in cards.evaluate())
tester.getRect(find.byElementPredicate((e) => e == element)),
];
expect(first.top, second.top);
expect(first.height, second.height);
expect(first.right, lessThan(second.left));
});
testWidgets('says so while nothing was studied', (tester) async {
await showStatistics(tester);
expect(find.textContaining('Nothing studied yet'), findsOneWidget);
expect(find.byType(BarChart), findsNothing);
});
}

View file

@ -47,7 +47,7 @@ void main() {
}); });
test('sums each day of the last seven, today last, across the studyset ' test('sums each day of the last seven, today last, across the studyset '
'and what is inside it; a fresh start is no studying', () async { 'and what is inside it, a card at most a minute; a fresh start is no studying', () async {
final now = DateTime(2026, 10, 12, 15); final now = DateTime(2026, 10, 12, 15);
final own = await card('japanese'); final own = await card('japanese');
final inTopic = await card('grammar'); final inTopic = await card('grammar');
@ -55,21 +55,23 @@ void main() {
Future<void> review( Future<void> review(
String cardId, String cardId,
DateTime at, DateTime at,
int minutes, { int seconds, {
StudyMode mode = StudyMode.review, StudyMode mode = StudyMode.review,
}) => reviews.record( }) => reviews.record(
cardId: cardId, cardId: cardId,
rating: Rating.good, rating: Rating.good,
reviewedAt: at, reviewedAt: at,
duration: Duration(minutes: minutes), duration: Duration(seconds: seconds),
mode: mode, mode: mode,
); );
await review(own, now, 2); await review(own, now, 20);
await review(inTopic, now.subtract(const Duration(hours: 2)), 1); await review(inTopic, now.subtract(const Duration(hours: 2)), 10);
await review(own, now.subtract(const Duration(days: 1)), 3); await review(own, now.subtract(const Duration(days: 1)), 30);
await review(own, now.subtract(const Duration(days: 6)), 4); // Ten minutes on one card count as one.
await review(own, now.subtract(const Duration(days: 1)), 600);
await review(own, now.subtract(const Duration(days: 6)), 40);
// A week ago and more, another studyset's, a fresh start: none counts. // A week ago and more, another studyset's, a fresh start: none counts.
await review(own, now.subtract(const Duration(days: 7)), 5); await review(own, now.subtract(const Duration(days: 7)), 50);
await review(elsewhere, now, 6); await review(elsewhere, now, 6);
await review(own, now, 7, mode: StudyMode.reset); await review(own, now, 7, mode: StudyMode.reset);
@ -80,13 +82,13 @@ void main() {
expect(activity.studied, isTrue); expect(activity.studied, isTrue);
expect(activity.week, [ expect(activity.week, [
const Duration(minutes: 4), const Duration(seconds: 40),
Duration.zero, Duration.zero,
Duration.zero, Duration.zero,
Duration.zero, Duration.zero,
Duration.zero, Duration.zero,
const Duration(minutes: 3), const Duration(seconds: 90),
const Duration(minutes: 3), const Duration(seconds: 30),
]); ]);
}); });

View file

@ -1707,7 +1707,8 @@ void main() {
expect(find.text('Learned'), findsOneWidget); expect(find.text('Learned'), findsOneWidget);
expect(find.text('0 of 1 card'), findsOneWidget); expect(find.text('0 of 1 card'), findsOneWidget);
expect(find.text('To review'), findsOneWidget); expect(find.text('To review'), findsOneWidget);
expect(find.text('2 min'), findsOneWidget); // Two minutes on the card count as one.
expect(find.text('1 min'), findsOneWidget);
}); });
testWidgets('choosing another part ticks, as the navigation bar does; ' testWidgets('choosing another part ticks, as the navigation bar does; '