feat(assistant): ask Claude from Home, the Library, a studyset and a session

A device setting, off unless the person turns it on (Settings ›
Assistant), shows a field at the foot of Home and a button that unfolds
into one: above Create studyset in the Library (smaller), in the free lane
on the right of a studyset's bottom bar (floating alone from expanded
windows on), and in the card's lower right corner while studying.

A question is handed to Claude, not answered here: the app opens
claude.ai/new with the question written in, followed by what it is about
(the person's learning on the instance, a studyset by name, or the studied
card's front and back as text), cut at 12,000 characters, for the person
to read and send in Claude. A Claude plan can't drive a chat inside
another app, as Anthropic allows no third party to offer sign-in with it;
Claude is to read the studysets through a connector on the instance
instead (next). Nothing leaves the app until the person sends it there.

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-10 11:51:58 +02:00
commit f4baf053b3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
23 changed files with 1140 additions and 25 deletions

View file

@ -97,7 +97,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved. - **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore 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, 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 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. `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`, `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; `TopGradient` for a colour washing down from the top of Home (cobalt) and a studyset's screen (its colour), behind a clear app bar, its top tone held to WCAG AA for the theme's text. `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`, `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list; 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 tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list; 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 picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. - **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files. - **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
@ -154,6 +154,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device. - **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number. - **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
- **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`: an address checked with `instance.info()`, or the build's own in one tap), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use. - **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`: an address checked with `instance.info()`, or the build's own in one tap), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use.
- **Asking Claude** (decided 2026-10-10; `feature/assistant/`): a device setting, off unless the person turns it on (Settings › Assistant), shows a field at the foot of Home (`AskClaudeBar`) and a button that unfolds into one (`AskClaudeButton`): above Create studyset in the Library, in the right lane of a studyset's bottom bar (floating alone from expanded windows on), and in the card's lower right corner while studying. A question is handed to Claude, never answered here: `ClaudeHandOff` opens `https://claude.ai/new?q=…` (cut at 12,000 characters) with the question and what it is about (`AssistantTopic`: the person's learning, a studyset by name, or the studied card's text), for the person to send in Claude. A Claude plan can't drive a chat inside another app (Anthropic allows no third party to sign in with it), so Claude is to read studysets through a connector on the instance instead.
- **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. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on). - **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. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card. 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 allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on. - **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint. - **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.

View file

@ -1529,5 +1529,19 @@
"suggestSent": "Vorschlag gesendet.", "suggestSent": "Vorschlag gesendet.",
"suggestNoDerivatives": "Seine Lizenz verbietet Bearbeitungen: keine Vorschläge.", "suggestNoDerivatives": "Seine Lizenz verbietet Bearbeitungen: keine Vorschläge.",
"feedbackNeedsIdentity": "Melde dich mit GitHub an, um im Original zu melden und vorzuschlagen.", "feedbackNeedsIdentity": "Melde dich mit GitHub an, um im Original zu melden und vorzuschlagen.",
"kanjiExampleWords": "Wörter damit" "kanjiExampleWords": "Wörter damit",
"settingsAssistant": "Assistent",
"settingsAskClaude": "Claude fragen",
"settingsAskClaudeSubtitle": "Aus, bis du es einschaltest. Fügt ein Feld auf der Startseite und einen Knopf in der Bibliothek, bei einem Lernset und beim Lernen hinzu. Eine Frage öffnet sich in Claude mit dem, worum es geht, und du sendest sie dort; von hier aus wird nichts gesendet.",
"assistantHint": "Frag Claude…",
"assistantHintStudyset": "Frag Claude zu {title}…",
"assistantHintCard": "Frag Claude zu dieser Karte…",
"assistantSend": "In Claude fragen",
"assistantOpen": "Claude fragen",
"assistantClose": "Schließen",
"assistantOpenFailed": "Claude ließ sich nicht öffnen.",
"assistantPromptLearning": "{question}\n\n(Gefragt aus Halkyon Learn, wo ich mit Lernsets auf {instance} lerne. Wenn du den Connector von Halkyon Learn hast, kannst du sie dort lesen.)",
"assistantPromptStudyset": "{question}\n\n(Es geht um mein Lernset „{title}“ in Halkyon Learn auf {instance}. Wenn du den Connector von Halkyon Learn hast, kannst du es dort lesen.)",
"assistantPromptCard": "{question}\n\n(Es geht um eine Karteikarte, die ich gerade lerne, aus meinem Lernset „{title}“ in Halkyon Learn auf {instance}.\nVorderseite: {front}\nRückseite: {back})"
} }

View file

@ -1786,5 +1786,23 @@
"suggestSent": "Suggestion sent.", "suggestSent": "Suggestion sent.",
"suggestNoDerivatives": "Its licence forbids adaptations: no suggestions.", "suggestNoDerivatives": "Its licence forbids adaptations: no suggestions.",
"feedbackNeedsIdentity": "Sign in with GitHub to report to the author and suggest.", "feedbackNeedsIdentity": "Sign in with GitHub to report to the author and suggest.",
"kanjiExampleWords": "Words with it" "kanjiExampleWords": "Words with it",
"settingsAssistant": "Assistant",
"settingsAskClaude": "Ask Claude",
"settingsAskClaudeSubtitle": "Off unless you turn it on. Adds a field on Home and a button in the Library, on a studyset and while studying. A question opens in Claude with what it is about, and you send it there; nothing is sent from here.",
"assistantHint": "Ask Claude…",
"assistantHintStudyset": "Ask Claude about {title}…",
"@assistantHintStudyset": {"placeholders": {"title": {"type": "String"}}},
"assistantHintCard": "Ask Claude about this card…",
"assistantSend": "Ask in Claude",
"assistantOpen": "Ask Claude",
"assistantClose": "Close",
"assistantOpenFailed": "Claude couldn't be opened.",
"assistantPromptLearning": "{question}\n\n(Asked from Halkyon Learn, where I learn with studysets on {instance}. If you have the Halkyon Learn connector, you can read them there.)",
"@assistantPromptLearning": {"description": "What the app hands to Claude: the person's question, then where it was asked. Written as the person, to Claude.", "placeholders": {"question": {"type": "String"}, "instance": {"type": "String"}}},
"assistantPromptStudyset": "{question}\n\n(About my studyset “{title}” in Halkyon Learn on {instance}. If you have the Halkyon Learn connector, you can read it there.)",
"@assistantPromptStudyset": {"placeholders": {"question": {"type": "String"}, "title": {"type": "String"}, "instance": {"type": "String"}}},
"assistantPromptCard": "{question}\n\n(About a flashcard I am studying, in my studyset “{title}” in Halkyon Learn on {instance}.\nFront: {front}\nBack: {back})",
"@assistantPromptCard": {"placeholders": {"question": {"type": "String"}, "title": {"type": "String"}, "front": {"type": "String"}, "back": {"type": "String"}, "instance": {"type": "String"}}}
} }

View file

@ -9074,6 +9074,94 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Words with it'** /// **'Words with it'**
String get kanjiExampleWords; String get kanjiExampleWords;
/// No description provided for @settingsAssistant.
///
/// In en, this message translates to:
/// **'Assistant'**
String get settingsAssistant;
/// No description provided for @settingsAskClaude.
///
/// In en, this message translates to:
/// **'Ask Claude'**
String get settingsAskClaude;
/// No description provided for @settingsAskClaudeSubtitle.
///
/// In en, this message translates to:
/// **'Off unless you turn it on. Adds a field on Home and a button in the Library, on a studyset and while studying. A question opens in Claude with what it is about, and you send it there; nothing is sent from here.'**
String get settingsAskClaudeSubtitle;
/// No description provided for @assistantHint.
///
/// In en, this message translates to:
/// **'Ask Claude…'**
String get assistantHint;
/// No description provided for @assistantHintStudyset.
///
/// In en, this message translates to:
/// **'Ask Claude about {title}…'**
String assistantHintStudyset(String title);
/// No description provided for @assistantHintCard.
///
/// In en, this message translates to:
/// **'Ask Claude about this card…'**
String get assistantHintCard;
/// No description provided for @assistantSend.
///
/// In en, this message translates to:
/// **'Ask in Claude'**
String get assistantSend;
/// No description provided for @assistantOpen.
///
/// In en, this message translates to:
/// **'Ask Claude'**
String get assistantOpen;
/// No description provided for @assistantClose.
///
/// In en, this message translates to:
/// **'Close'**
String get assistantClose;
/// No description provided for @assistantOpenFailed.
///
/// In en, this message translates to:
/// **'Claude couldn\'t be opened.'**
String get assistantOpenFailed;
/// What the app hands to Claude: the person's question, then where it was asked. Written as the person, to Claude.
///
/// In en, this message translates to:
/// **'{question}\n\n(Asked from Halkyon Learn, where I learn with studysets on {instance}. If you have the Halkyon Learn connector, you can read them there.)'**
String assistantPromptLearning(String question, String instance);
/// No description provided for @assistantPromptStudyset.
///
/// In en, this message translates to:
/// **'{question}\n\n(About my studyset “{title}” in Halkyon Learn on {instance}. If you have the Halkyon Learn connector, you can read it there.)'**
String assistantPromptStudyset(
String question,
String title,
String instance,
);
/// No description provided for @assistantPromptCard.
///
/// In en, this message translates to:
/// **'{question}\n\n(About a flashcard I am studying, in my studyset “{title}” in Halkyon Learn on {instance}.\nFront: {front}\nBack: {back})'**
String assistantPromptCard(
String question,
String title,
String front,
String back,
String instance,
);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -5676,4 +5676,62 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get kanjiExampleWords => 'Wörter damit'; String get kanjiExampleWords => 'Wörter damit';
@override
String get settingsAssistant => 'Assistent';
@override
String get settingsAskClaude => 'Claude fragen';
@override
String get settingsAskClaudeSubtitle =>
'Aus, bis du es einschaltest. Fügt ein Feld auf der Startseite und einen Knopf in der Bibliothek, bei einem Lernset und beim Lernen hinzu. Eine Frage öffnet sich in Claude mit dem, worum es geht, und du sendest sie dort; von hier aus wird nichts gesendet.';
@override
String get assistantHint => 'Frag Claude…';
@override
String assistantHintStudyset(String title) {
return 'Frag Claude zu $title…';
}
@override
String get assistantHintCard => 'Frag Claude zu dieser Karte…';
@override
String get assistantSend => 'In Claude fragen';
@override
String get assistantOpen => 'Claude fragen';
@override
String get assistantClose => 'Schließen';
@override
String get assistantOpenFailed => 'Claude ließ sich nicht öffnen.';
@override
String assistantPromptLearning(String question, String instance) {
return '$question\n\n(Gefragt aus Halkyon Learn, wo ich mit Lernsets auf $instance lerne. Wenn du den Connector von Halkyon Learn hast, kannst du sie dort lesen.)';
}
@override
String assistantPromptStudyset(
String question,
String title,
String instance,
) {
return '$question\n\n(Es geht um mein Lernset „$title“ in Halkyon Learn auf $instance. Wenn du den Connector von Halkyon Learn hast, kannst du es dort lesen.)';
}
@override
String assistantPromptCard(
String question,
String title,
String front,
String back,
String instance,
) {
return '$question\n\n(Es geht um eine Karteikarte, die ich gerade lerne, aus meinem Lernset „$title“ in Halkyon Learn auf $instance.\nVorderseite: $front\nRückseite: $back)';
}
} }

View file

@ -5627,6 +5627,64 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get kanjiExampleWords => 'Words with it'; String get kanjiExampleWords => 'Words with it';
@override
String get settingsAssistant => 'Assistant';
@override
String get settingsAskClaude => 'Ask Claude';
@override
String get settingsAskClaudeSubtitle =>
'Off unless you turn it on. Adds a field on Home and a button in the Library, on a studyset and while studying. A question opens in Claude with what it is about, and you send it there; nothing is sent from here.';
@override
String get assistantHint => 'Ask Claude…';
@override
String assistantHintStudyset(String title) {
return 'Ask Claude about $title…';
}
@override
String get assistantHintCard => 'Ask Claude about this card…';
@override
String get assistantSend => 'Ask in Claude';
@override
String get assistantOpen => 'Ask Claude';
@override
String get assistantClose => 'Close';
@override
String get assistantOpenFailed => 'Claude couldn\'t be opened.';
@override
String assistantPromptLearning(String question, String instance) {
return '$question\n\n(Asked from Halkyon Learn, where I learn with studysets on $instance. If you have the Halkyon Learn connector, you can read them there.)';
}
@override
String assistantPromptStudyset(
String question,
String title,
String instance,
) {
return '$question\n\n(About my studyset “$title” in Halkyon Learn on $instance. If you have the Halkyon Learn connector, you can read it there.)';
}
@override
String assistantPromptCard(
String question,
String title,
String front,
String back,
String instance,
) {
return '$question\n\n(About a flashcard I am studying, in my studyset “$title” in Halkyon Learn on $instance.\nFront: $front\nBack: $back)';
}
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -24,6 +24,7 @@ import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/claude_hand_off.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
@ -142,6 +143,9 @@ class Dependencies({
/// What the system shows of the Pomodoro timer, and its alerts. /// What the system shows of the Pomodoro timer, and its alerts.
required final PomodoroPlatform pomodoro, required final PomodoroPlatform pomodoro,
/// Hands questions to Claude.
required final ClaudeHandOff claude,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
}) { }) {
/// Builds the production graph on [instance], around one Serverpod /// Builds the production graph on [instance], around one Serverpod
@ -196,6 +200,7 @@ class Dependencies({
deviceSettings: await device.deviceSettingsStore.read(), deviceSettings: await device.deviceSettingsStore.read(),
appVersion: appVersion, appVersion: appVersion,
pomodoro: device.pomodoro, pomodoro: device.pomodoro,
claude: ClaudeHandOff(),
openPerson: (person) async { openPerson: (person) async {
await previousClosed.timeout( await previousClosed.timeout(
const Duration(seconds: 10), const Duration(seconds: 10),

View file

@ -27,6 +27,9 @@ class DeviceSettingsBloc(
(event, emit) => (event, emit) =>
_apply(emit, state.withPomodoroWhileStudying(event.shown)), _apply(emit, state.withPomodoroWhileStudying(event.shown)),
); );
on<DeviceSettingsEventAssistantChosen>(
(event, emit) => _apply(emit, state.withAssistant(event.on)),
);
on<DeviceSettingsEventBrowserViewChosen>( on<DeviceSettingsEventBrowserViewChosen>(
(event, emit) => _apply( (event, emit) => _apply(
emit, emit,
@ -84,6 +87,13 @@ final class const DeviceSettingsEventPomodoroWhileStudyingChosen(
List<Object?> get props => [shown]; List<Object?> get props => [shown];
} }
/// The person turned asking Claude [on] or off.
final class const DeviceSettingsEventAssistantChosen(final bool on)
extends DeviceSettingsEvent {
@override
List<Object?> get props => [on];
}
/// The person shows the card browser of [studysetId] one card at a time /// The person shows the card browser of [studysetId] one card at a time
/// ([detailed]) or as a grid. /// ([detailed]) or as a grid.
final class const DeviceSettingsEventBrowserViewChosen( final class const DeviceSettingsEventBrowserViewChosen(

View file

@ -51,6 +51,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
if (json['detailedBrowsers'] case final List<Object?> ids) if (json['detailedBrowsers'] case final List<Object?> ids)
for (final id in ids.whereType<String>()) id, for (final id in ids.whereType<String>()) id,
}, },
assistant: json['assistant'] == true,
); );
} on FormatException catch (error) { } on FormatException catch (error) {
log('The device settings are unreadable', name: 'Settings', error: error); log('The device settings are unreadable', name: 'Settings', error: error);
@ -69,6 +70,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'citationStyle': settings.citationStyle.name, 'citationStyle': settings.citationStyle.name,
'pomodoroWhileStudying': settings.pomodoroWhileStudying, 'pomodoroWhileStudying': settings.pomodoroWhileStudying,
'detailedBrowsers': [...settings.detailedBrowsers], 'detailedBrowsers': [...settings.detailedBrowsers],
'assistant': settings.assistant,
}), }),
flush: true, flush: true,
); );

View file

@ -10,8 +10,9 @@ enum Appearance() {
/// The settings of one device, which stay on it (decided 2026-10-07): how the /// The settings of one device, which stay on it (decided 2026-10-07): how the
/// app looks, the language it speaks, the style it cites sources in, /// app looks, the language it speaks, the style it cites sources in,
/// whether studying shows the Pomodoro timer, and which studysets' card /// whether studying shows the Pomodoro timer, which studysets' card
/// browsers show one card at a time. /// browsers show one card at a time, and whether the person asks Claude
/// from the app.
/// Unlike the person's settings, they don't follow anyone to other devices, /// Unlike the person's settings, they don't follow anyone to other devices,
/// and they apply before anyone is signed in. /// and they apply before anyone is signed in.
final class const DeviceSettings({ final class const DeviceSettings({
@ -30,6 +31,11 @@ final class const DeviceSettings({
/// The studysets whose card browser shows one card at a time beside the /// The studysets whose card browser shows one card at a time beside the
/// list (detail) rather than a grid, as last left. /// list (detail) rather than a grid, as last left.
final Set<String> detailedBrowsers = const {}, final Set<String> detailedBrowsers = const {},
/// Whether the app offers to ask Claude (decided 2026-10-10): a field on
/// Home, a button in the Library, on a studyset and while studying. Off
/// unless the person turns it on.
final bool assistant = false,
}) with Equatable { }) with Equatable {
/// These settings with [appearance] instead. /// These settings with [appearance] instead.
DeviceSettings withAppearance(Appearance appearance) => DeviceSettings( DeviceSettings withAppearance(Appearance appearance) => DeviceSettings(
@ -38,6 +44,7 @@ final class const DeviceSettings({
citationStyle: citationStyle, citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying, pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailedBrowsers, detailedBrowsers: detailedBrowsers,
assistant: assistant,
); );
/// These settings with [language] instead; null for the device's. /// These settings with [language] instead; null for the device's.
@ -47,6 +54,7 @@ final class const DeviceSettings({
citationStyle: citationStyle, citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying, pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailedBrowsers, detailedBrowsers: detailedBrowsers,
assistant: assistant,
); );
/// These settings with [citationStyle] instead. /// These settings with [citationStyle] instead.
@ -57,6 +65,7 @@ final class const DeviceSettings({
citationStyle: citationStyle, citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying, pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailedBrowsers, detailedBrowsers: detailedBrowsers,
assistant: assistant,
); );
/// These settings with [shown] for the Pomodoro timer while studying. /// These settings with [shown] for the Pomodoro timer while studying.
@ -66,6 +75,7 @@ final class const DeviceSettings({
citationStyle: citationStyle, citationStyle: citationStyle,
pomodoroWhileStudying: shown, pomodoroWhileStudying: shown,
detailedBrowsers: detailedBrowsers, detailedBrowsers: detailedBrowsers,
assistant: assistant,
); );
/// These settings with the card browser of [studysetId] showing one card /// These settings with the card browser of [studysetId] showing one card
@ -79,8 +89,19 @@ final class const DeviceSettings({
detailedBrowsers: detailed detailedBrowsers: detailed
? {...detailedBrowsers, studysetId} ? {...detailedBrowsers, studysetId}
: ({...detailedBrowsers}..remove(studysetId)), : ({...detailedBrowsers}..remove(studysetId)),
assistant: assistant,
); );
/// These settings with asking Claude [on] or off.
DeviceSettings withAssistant(bool on) => DeviceSettings(
appearance: appearance,
language: language,
citationStyle: citationStyle,
pomodoroWhileStudying: pomodoroWhileStudying,
detailedBrowsers: detailedBrowsers,
assistant: on,
);
@override @override
List<Object?> get props => [ List<Object?> get props => [
appearance, appearance,
@ -88,5 +109,6 @@ final class const DeviceSettings({
citationStyle, citationStyle,
pomodoroWhileStudying, pomodoroWhileStudying,
detailedBrowsers, detailedBrowsers,
assistant,
]; ];
} }

View file

@ -0,0 +1,33 @@
import 'package:url_launcher/url_launcher.dart';
/// Hands a question to Claude: opens a new conversation at claude.ai with
/// the question written in, for the person to read and send. Claude's own
/// app opens it where it handles claude.ai's links, a browser elsewhere.
///
/// Halkyon Learn can't answer inside the app with someone's Claude plan:
/// Anthropic allows no other app to sign in with it. Claude reads the
/// person's studysets instead through the instance's connector, which the
/// person adds to Claude once (`ConnectClaudeScreen`).
class ClaudeHandOff({
final Future<bool> Function(Uri url) _open = _openOutside,
}) {
/// How long a prompt may be: claude.ai cuts what it is given at about
/// 14,000 characters, and a link that long stays well within what
/// browsers accept.
static const maxPromptLength = 12000;
/// The link that opens [prompt] in a new conversation.
static Uri urlFor(String prompt) =>
Uri.https('claude.ai', '/new', {'q': clip(prompt)});
/// [prompt], cut to [maxPromptLength] with an ellipsis where it was cut.
static String clip(String prompt) => prompt.length <= maxPromptLength
? prompt
: '${prompt.substring(0, maxPromptLength - 1)}…';
/// Opens [prompt] in Claude; false if nothing could open it.
Future<bool> ask(String prompt) => _open(urlFor(prompt));
}
Future<bool> _openOutside(Uri url) =>
launchUrl(url, mode: LaunchMode.externalApplication);

View file

@ -0,0 +1,70 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
/// What a question to the assistant is about, written into it so that the
/// assistant knows without asking.
sealed class const AssistantTopic();
/// The person's learning as a whole: asked on Home or in the Library.
final class const AssistantTopicLearning() extends AssistantTopic;
/// One studyset, asked on its screen.
final class const AssistantTopicStudyset({
/// Its title.
required final String title,
}) extends AssistantTopic;
/// The card being studied, asked during a session. Its text goes along, so
/// the assistant can answer without reading the studyset.
final class const AssistantTopicCard({
/// The title of the studyset being studied.
required final String studysetTitle,
/// The card.
required final CardContent card,
}) extends AssistantTopic;
/// The text handed to the assistant: [question], then what it is about on
/// which [instance], in the app's language.
String assistantPrompt(
AppLocalizations l10n, {
required String question,
required AssistantTopic topic,
required String instance,
}) => switch (topic) {
AssistantTopicLearning() => l10n.assistantPromptLearning(
question.trim(),
instance,
),
AssistantTopicStudyset(:final title) => l10n.assistantPromptStudyset(
question.trim(),
title,
instance,
),
AssistantTopicCard(:final studysetTitle, :final card) =>
l10n.assistantPromptCard(
question.trim(),
studysetTitle,
cardFrontText(card),
cardBackText(card),
instance,
),
};
/// What [card] asks, as plain text: its front, question, expression, or
/// what to sort.
String cardFrontText(CardContent card) => card.front.plainText.trim();
/// What [card] answers, as plain text, a line each: the back, a word's
/// reading and meanings, a kanji's readings, the right answers, the
/// explanation.
String cardBackText(CardContent card) => [
card.back.plainText,
card.reading,
card.onReadings.join('、'),
card.kunReadings.join('、'),
card.meanings.join('; '),
for (final answer in card.answers)
if (answer.correct) answer.content.plainText,
card.explanation?.plainText ?? '',
].map((text) => text.trim()).where((text) => text.isNotEmpty).join('\n');

View file

@ -0,0 +1,285 @@
import 'dart:async';
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/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Whether the person turned on asking Claude on this device (Settings ›
/// Assistant); off unless they did.
bool assistantOn(BuildContext context) =>
context.select<DeviceSettingsBloc, bool>((bloc) => bloc.state.assistant);
/// Hands [question] about [topic] to Claude, and says so if Claude couldn't
/// be opened.
Future<void> askClaude(
BuildContext context,
String question,
AssistantTopic topic,
) async {
final dependencies = DependenciesScope.of(context);
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final prompt = assistantPrompt(
l10n,
question: question,
topic: topic,
instance: dependencies.serverName,
);
if (!await dependencies.claude.ask(prompt)) {
messenger.showSnackBar(SnackBar(content: Text(l10n.assistantOpenFailed)));
}
}
/// The ring around the assistant's field and button: cyan through blue to
/// violet, palette colours, so it reads as one thing wherever it shows.
const _ring = LinearGradient(
colors: [Color(0xFF179FD9), Color(0xFF415DF0), Color(0xFF8080FF)],
);
/// How thick [_ring] is.
const _ringWidth = 2.0;
/// A field to ask Claude about [topic]: a pill in [_ring], the question,
/// and a button that hands it over. Empty again once asked.
///
/// With [onClose], a close button leads instead of the sparkles, as where
/// the field unfolded from a button.
class const AskClaudeField({
/// What questions here are about.
required final AssistantTopic topic,
/// Whether the field takes the keyboard at once.
final bool autofocus = false,
/// Folds the field away; none where it stays.
final VoidCallback? onClose,
/// Called once a question was handed over.
final VoidCallback? onAsked,
super.key,
}) extends StatefulWidget {
@override
State<AskClaudeField> createState() => _AskClaudeFieldState();
}
class _AskClaudeFieldState() extends State<AskClaudeField> {
final _question = TextEditingController();
@override
void dispose() {
_question.dispose();
super.dispose();
}
Future<void> _ask() async {
final question = _question.text;
if (question.trim().isEmpty) {
return;
}
_question.clear();
await askClaude(context, question, widget.topic);
widget.onAsked?.call();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final hint = switch (widget.topic) {
AssistantTopicLearning() => l10n.assistantHint,
AssistantTopicStudyset(:final title) => l10n.assistantHintStudyset(title),
AssistantTopicCard() => l10n.assistantHintCard,
};
return DecoratedBox(
decoration: const ShapeDecoration(
shape: StadiumBorder(),
gradient: _ring,
),
child: Padding(
padding: const EdgeInsets.all(_ringWidth),
child: Material(
shape: const StadiumBorder(),
color: theme.colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
if (widget.onClose case final close?)
IconButton(
tooltip: l10n.assistantClose,
icon: const Icon(LucideIcons.x),
onPressed: close,
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(
LucideIcons.sparkles,
color: theme.colorScheme.primary,
),
),
Expanded(
child: TextField(
controller: _question,
autofocus: widget.autofocus,
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.send,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => unawaited(_ask()),
decoration: InputDecoration(
hintText: hint,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
isDense: true,
),
),
),
ListenableBuilder(
listenable: _question,
builder: (context, _) => IconButton.filled(
tooltip: l10n.assistantSend,
icon: const Icon(LucideIcons.arrowUp),
onPressed: _question.text.trim().isEmpty
? null
: () => unawaited(_ask()),
),
),
],
),
),
),
),
);
}
}
/// Home's field at its foot, above the navigation: asks Claude about the
/// person's learning. Nothing while asking Claude is off.
class const AskClaudeBar({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (!assistantOn(context)) {
return const SizedBox.shrink();
}
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Center(
heightFactor: 1,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: const AskClaudeField(topic: AssistantTopicLearning()),
),
),
),
);
}
}
/// A round button that unfolds into [AskClaudeField], [expandedWidth] wide,
/// and folds back once asked or closed: the assistant's floating button in
/// the Library, on a studyset and while studying. [below] (the Library's
/// Create studyset) stands under it and makes way while it is unfolded.
///
/// Nothing (or only [below]) while asking Claude is off.
class const AskClaudeButton({
/// What questions here are about.
required final AssistantTopic topic,
/// How wide the field unfolds; the space it has, less a margin, when
/// null.
final double? expandedWidth,
/// Smaller, as above another floating button.
final bool small = false,
/// A floating button under it.
final Widget? below,
super.key,
}) extends StatefulWidget {
@override
State<AskClaudeButton> createState() => _AskClaudeButtonState();
}
class _AskClaudeButtonState() extends State<AskClaudeButton> {
var _expanded = false;
void _fold() => setState(() => _expanded = false);
@override
Widget build(BuildContext context) {
final below = widget.below;
if (!assistantOn(context)) {
return below ?? const SizedBox.shrink();
}
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final size = widget.small ? 44.0 : 56.0;
final button = LayoutBuilder(
builder: (context, constraints) {
final width =
widget.expandedWidth ??
(constraints.maxWidth.isFinite ? constraints.maxWidth - 32 : 360);
return _expanded
? SizedBox(
width: width,
child: AskClaudeField(
topic: widget.topic,
autofocus: true,
onClose: _fold,
onAsked: _fold,
),
)
: Tooltip(
message: l10n.assistantOpen,
child: DecoratedBox(
decoration: const ShapeDecoration(
shape: CircleBorder(),
gradient: _ring,
),
child: Padding(
padding: const EdgeInsets.all(_ringWidth),
child: Material(
elevation: 6,
shape: const CircleBorder(),
color: theme.colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => setState(() => _expanded = true),
child: SizedBox.square(
dimension: size - 2 * _ringWidth,
child: Icon(
LucideIcons.sparkles,
color: theme.colorScheme.primary,
size: widget.small ? 20 : 24,
),
),
),
),
),
),
);
},
);
if (below == null) {
return button;
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
button,
if (!_expanded) ...[const SizedBox(height: 16), below],
],
);
}
}

View file

@ -0,0 +1,42 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:material_ui/material_ui.dart';
/// Settings' Assistant card: whether this device asks Claude, off unless
/// the person turns it on (decided 2026-10-10).
class const AssistantSettingsCard({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final on = context.select<DeviceSettingsBloc, bool>(
(bloc) => bloc.state.assistant,
);
return SectionCard(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.settingsAssistant,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.settingsAskClaude),
subtitle: Text(l10n.settingsAskClaudeSubtitle),
isThreeLine: true,
value: on,
onChanged: (on) => context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventAssistantChosen(on),
),
),
],
),
);
}
}

View file

@ -10,6 +10,7 @@ import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.da
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart'; import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart'; import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/ask_claude.dart';
import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
@ -25,7 +26,8 @@ import 'package:material_ui/material_ui.dart';
/// The home tab, where the app opens: a study dashboard. The streak sits in /// The home tab, where the app opens: a study dashboard. The streak sits in
/// the app bar; below it the study goal, the week and a year of activity, /// the app bar; below it the study goal, the week and a year of activity,
/// in the order the person arranged them. Cobalt washes down from the top /// in the order the person arranged them. Cobalt washes down from the top
/// ([TopGradient]). /// ([TopGradient]). Where the person asks Claude, a field to ask sits at the
/// foot ([AskClaudeBar]).
/// ///
/// Long-pressing a widget arranges the dashboard: a handle to drag each /// Long-pressing a widget arranges the dashboard: a handle to drag each
/// widget elsewhere, an eye to hide or show it, Done to finish. The /// widget elsewhere, an eye to hide or show it, Done to finish. The
@ -100,6 +102,7 @@ class _HomeState() extends State<_Home> {
DateTime? today, DateTime? today,
) => Scaffold( ) => Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
bottomNavigationBar: _arranging ? null : const AskClaudeBar(),
appBar: _arranging appBar: _arranging
? AppBar( ? AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,

View file

@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_status_card.dart
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart'; import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart'; import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_settings_card.dart';
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/instance/widget/account_switcher_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/instance/widget/account_switcher_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_profiles_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_profiles_sheet.dart';
@ -19,8 +20,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// Settings, over the tabs: where sync stands, how this device shows the app, /// Settings, over the tabs: where sync stands, how this device shows the app,
/// the account (signing out, deleting it), what Halkyon Learn is, and at the /// whether it asks Claude, the account (signing out, deleting it), what
/// bottom which release this is. /// Halkyon Learn is, and at the bottom which release this is.
class const SettingsScreen({super.key}) extends StatelessWidget { class const SettingsScreen({super.key}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -46,6 +47,8 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
const _StudyCard(), const _StudyCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
const AssistantSettingsCard(),
const SizedBox(height: 12),
const _PrivacyCard(), const _PrivacyCard(),
const SizedBox(height: 12), const SizedBox(height: 12),
SectionCard( SectionCard(

View file

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -7,6 +9,8 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/ask_claude.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_info.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/card_info.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
@ -68,6 +72,7 @@ class const StudyScreen({
final StudySessionStateShowing showing => _Showing( final StudySessionStateShowing showing => _Showing(
showing, showing,
header: header, header: header,
title: title,
), ),
_ => Scaffold( _ => Scaffold(
body: SafeArea( body: SafeArea(
@ -318,7 +323,13 @@ class const _Header({
class const _Showing( class const _Showing(
final StudySessionStateShowing state, { final StudySessionStateShowing state, {
required final Widget header, required final Widget header,
/// The studyset's title, for questions to Claude about the card.
required final String title,
}) extends StatelessWidget { }) extends StatelessWidget {
/// The widest the field to ask Claude unfolds.
static const _fieldMaxWidth = 640.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@ -595,14 +606,39 @@ class const _Showing(
), ),
), ),
Expanded( Expanded(
child: Center( child: Stack(
child: ConstrainedBox( children: [
constraints: const BoxConstraints(maxWidth: 1100), Positioned.fill(
child: Padding( child: Center(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: ConstrainedBox(
child: body, constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: body,
),
),
),
), ),
), // Asking Claude about the card, in the card's lower
// right corner, above the ratings. Unfolded, the field
// spans the card, the keyboard pushing it up.
Positioned(
right: 28,
bottom: 24,
child: AskClaudeButton(
topic: AssistantTopicCard(
studysetTitle: title,
card: card.content,
),
small: true,
// Within the card's sides.
expandedWidth: math.min(
MediaQuery.sizeOf(context).width - 56,
_fieldMaxWidth,
),
),
),
],
), ),
), ),
if (ratedBelow) if (ratedBelow)

View file

@ -13,6 +13,8 @@ import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart'; import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/ask_claude.dart';
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/publish/bloc/published_cubit.dart'; import 'package:halkyon_learn_flutter/src/feature/publish/bloc/published_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.dart'; import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.dart';
@ -140,14 +142,22 @@ class _LibraryState() extends State<_Library> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final wide = context.windowSize.isAtLeastMedium; final wide = context.windowSize.isAtLeastMedium;
final create = wide
? null
: FloatingActionButton(
tooltip: l10n.libraryCreateStudyset,
onPressed: () => _create(context),
child: const Icon(LucideIcons.plus),
);
return ScrollHidingFab( return ScrollHidingFab(
fab: wide // Asking Claude stands above Create studyset, smaller.
? null fab: assistantOn(context)
: FloatingActionButton( ? AskClaudeButton(
tooltip: l10n.libraryCreateStudyset, topic: const AssistantTopicLearning(),
onPressed: () => _create(context), small: create != null,
child: const Icon(LucideIcons.plus), below: create,
), )
: create,
builder: (context, fab) => Scaffold( builder: (context, fab) => Scaffold(
// The cards stand out from it by their shadow. // The cards stand out from it by their shadow.
backgroundColor: theme.colorScheme.surfaceContainerLow, backgroundColor: theme.colorScheme.surfaceContainerLow,

View file

@ -26,6 +26,8 @@ import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/ask_claude.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart';
@ -276,8 +278,16 @@ class _StudysetViewState() extends State<_StudysetView> {
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ?? PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
Brand.primary; Brand.primary;
return ScrollHidingFab( return ScrollHidingFab(
// From expanded windows on, Study and Add material are in the app
// bar, and asking Claude floats alone.
fab: wide fab: wide
? null ? (assistantOn(context)
? AskClaudeButton(
topic: AssistantTopicStudyset(
title: studyset.content.title,
),
)
: null)
: _StudyBar(studyset: studyset, addMaterial: _addMaterial), : _StudyBar(studyset: studyset, addMaterial: _addMaterial),
builder: (context, fab) => TopGradient( builder: (context, fab) => TopGradient(
color: color, color: color,
@ -357,7 +367,9 @@ class _StudysetViewState() extends State<_StudysetView> {
), ),
floatingActionButton: fab, floatingActionButton: fab,
// Above the licence line, which it would cover otherwise. // Above the licence line, which it would cover otherwise.
floatingActionButtonLocation: LicenseFooter.shows(studyset.content) floatingActionButtonLocation: wide
? FloatingActionButtonLocation.endFloat
: LicenseFooter.shows(studyset.content)
? const _RaisedCenterFloat(_licenceClearance) ? const _RaisedCenterFloat(_licenceClearance)
: FloatingActionButtonLocation.centerFloat, : FloatingActionButtonLocation.centerFloat,
body: Column( body: Column(
@ -1411,7 +1423,8 @@ Future<void> _importPath(BuildContext context) async {
} }
/// Below expanded windows, at the bottom: the button that opens the topics /// Below expanded windows, at the bottom: the button that opens the topics
/// on the left, and Add material and Study centred. /// on the left, Add material and Study centred, and where the person asks
/// Claude, its button on the right, unfolding over the bar.
class const _StudyBar({ class const _StudyBar({
required final Studyset studyset, required final Studyset studyset,
required final MenuController addMaterial, required final MenuController addMaterial,
@ -1431,6 +1444,8 @@ class const _StudyBar({
width: width, width: width,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
// The unfolded field is a little taller than the bar.
clipBehavior: Clip.none,
children: [ children: [
// Kept off the topics button's lane on both sides, so the // Kept off the topics button's lane on both sides, so the
// pair sits in the bar's middle. // pair sits in the bar's middle.
@ -1500,6 +1515,16 @@ class const _StudyBar({
), ),
), ),
), ),
// Asking Claude in the lane on the right; unfolded, the field
// covers the bar.
Positioned(
right: 0,
child: AskClaudeButton(
topic: AssistantTopicStudyset(title: studyset.content.title),
small: true,
expandedWidth: width,
),
),
], ],
), ),
); );

View file

@ -88,4 +88,27 @@ void main() {
await pumpEventQueue(); await pumpEventQueue();
expect(store.writes, [after]); expect(store.writes, [after]);
}); });
test('asking Claude, turned on, keeps the rest and is kept', () async {
final store = MemoryDeviceSettingsStore();
final bloc = DeviceSettingsBloc(
store,
const DeviceSettings(appearance: Appearance.dark),
)..add(const DeviceSettingsEventAssistantChosen(true));
addTearDown(bloc.close);
const after = DeviceSettings(appearance: Appearance.dark, assistant: true);
await next(bloc, after);
await pumpEventQueue();
expect(store.writes, [after]);
});
test('a copy for another choice keeps asking Claude', () {
const before = DeviceSettings(assistant: true);
expect(before.withAppearance(Appearance.light).assistant, isTrue);
expect(before.withLanguage('de').assistant, isTrue);
expect(before.withPomodoroWhileStudying(false).assistant, isTrue);
expect(before.withBrowserView('a', detailed: true).assistant, isTrue);
});
} }

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