feat(assistant): search the web too, and choose what goes along
The assistant's field now has a switch at its start: a question goes to Claude or into a web search, and the field remembers which. A web search opens the chosen engine's results: on Android the device's own web search by default (ACTION_WEB_SEARCH, answered by its default browser or search app; Brave Search where nothing answers), elsewhere Brave Search by default; Settings › Assistant › Search engine offers Ecosia, DuckDuckGo, Startpage, Qwant, Google, Bing, or one's own address with %s where the question goes. Above the field, chips offer what the question is about: a card's front, back, each hint and its explanation, the studyset's name and description. None is chosen, so only what the person picks goes along, each part cut at 600 characters; Claude gets the question and a compact line per chosen part, a web search the question and the chosen text, never the studyset's name. A chosen chip alone is enough to send. The old sentences around every question (where it was asked, the connector) are gone: they cost tokens for nothing unless the studyset is chosen. On a studyset's screen the button now stands above the bottom bar, as in the Library: unfolded inside the bar, its chips lay outside the bar's bounds, where taps don't land. 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:
parent
5f8fade41c
commit
96c2dc1be8
27 changed files with 1883 additions and 799 deletions
|
|
@ -154,7 +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 reads studysets through the instance's connector instead (ADR 0006): `halkyon_learn_server/lib/src/assistant/` serves MCP (`/mcp`, Streamable HTTP, JSON answers, no sessions; five read-only tools in `connector_tools.dart`) and its OAuth (`/.well-known/oauth-*`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`) on Serverpod's web server, which production runs on 8082 for these paths alone (Caddy forwards them; `connectorOf` takes the public address from `webServer`). People connect with a code from Settings › Assistant › Connect Claude (`AssistantEndpoint.connectCode`, eight Crockford characters, ten minutes, once), typed on the authorization page; codes and tokens are kept as SHA-256 hashes. The handlers take plain requests (`ConnectorRequest`), so `test/integration/assistant/` drives the whole flow without a web server; the Relic routes only translate.
|
- **Asking Claude or searching the web** (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 (`AssistantBar`) and a button that unfolds into one (`AssistantButton`): above Create studyset in the Library, above a studyset's bottom bar (floating alone from expanded windows on), and in the card's lower right corner while studying. A switch at the field's start sends to Claude or to a web search and is remembered (`assistantTarget`). Above the field, chips offer the parts of what it is about (`AssistantTopic.parts`: a card's front, back, each hint, its explanation, the studyset's name and description), none chosen, so only what the person picks goes along, each cut at 600 characters; a web search takes the question and the chosen text, never the studyset's name or description. Nothing is answered here: `ClaudeHandOff` opens `https://claude.ai/new?q=…` (cut at 12,000 characters) with the question and a compact line per chosen part, and `WebSearch` opens the chosen engine's results (`SearchEngine`: Brave Search by default on desktops, Ecosia, DuckDuckGo, Startpage, Qwant, Google, Bing, or the person's own address with `%s`), or on Android hands the question to the device's own web search (`ACTION_WEB_SEARCH` in `MainActivity.kt`, Brave Search where nothing answers). Halkyon apps use no LLM API keys: their pricing is opaque and open to abuse, and the person decides each time what an LLM sees. A Claude plan can't drive a chat inside another app (Anthropic allows no third party to sign in with it), so Claude reads studysets through the instance's connector instead (ADR 0006): `halkyon_learn_server/lib/src/assistant/` serves MCP (`/mcp`, Streamable HTTP, JSON answers, no sessions; five read-only tools in `connector_tools.dart`) and its OAuth (`/.well-known/oauth-*`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`) on Serverpod's web server, which production runs on 8082 for these paths alone (Caddy forwards them; `connectorOf` takes the public address from `webServer`). People connect with a code from Settings › Assistant › Connect Claude (`AssistantEndpoint.connectCode`, eight Crockford characters, ten minutes, once), typed on the authorization page; codes and tokens are kept as SHA-256 hashes. The handlers take plain requests (`ConnectorRequest`), so `test/integration/assistant/` drives the whole flow without a web server; the Relic routes only translate.
|
||||||
- **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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package app.halkyon.learn
|
package app.halkyon.learn
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.SearchManager
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
|
@ -17,6 +19,24 @@ class MainActivity : FlutterActivity() {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
PomodoroService.appBack()
|
PomodoroService.appBack()
|
||||||
|
// A question searched with the device's own web search (its default
|
||||||
|
// browser or search app answers ACTION_WEB_SEARCH); false where none does,
|
||||||
|
// and the app opens a search engine's address instead.
|
||||||
|
MethodChannel(messenger, "app.halkyon.learn/search").setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"webSearch" -> {
|
||||||
|
val search = Intent(Intent.ACTION_WEB_SEARCH)
|
||||||
|
.putExtra(SearchManager.QUERY, call.arguments as String)
|
||||||
|
try {
|
||||||
|
startActivity(search)
|
||||||
|
result.success(true)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
result.success(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
// The Pomodoro timer's side of the system (PomodoroService).
|
// The Pomodoro timer's side of the system (PomodoroService).
|
||||||
MethodChannel(messenger, "app.halkyon.learn/pomodoro").setMethodCallHandler { call, result ->
|
MethodChannel(messenger, "app.halkyon.learn/pomodoro").setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
|
|
||||||
|
|
@ -1532,18 +1532,15 @@
|
||||||
"kanjiExampleWords": "Wörter damit",
|
"kanjiExampleWords": "Wörter damit",
|
||||||
|
|
||||||
"settingsAssistant": "Assistent",
|
"settingsAssistant": "Assistent",
|
||||||
"settingsAskClaude": "Claude fragen",
|
"settingsAskClaude": "Claude fragen oder im Web suchen",
|
||||||
"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.",
|
"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 oder als Websuche, nur mit dem Zusammenhang, den du wählst; von hier aus wird nichts gesendet.",
|
||||||
"assistantHint": "Frag Claude…",
|
"assistantHint": "Frag Claude…",
|
||||||
"assistantHintStudyset": "Frag Claude zu {title}…",
|
"assistantHintStudyset": "Frag Claude zu {title}…",
|
||||||
"assistantHintCard": "Frag Claude zu dieser Karte…",
|
"assistantHintCard": "Frag Claude zu dieser Karte…",
|
||||||
"assistantSend": "In Claude fragen",
|
"assistantSend": "In Claude fragen",
|
||||||
"assistantOpen": "Claude fragen",
|
"assistantOpen": "Fragen oder suchen",
|
||||||
"assistantClose": "Schließen",
|
"assistantClose": "Schließen",
|
||||||
"assistantOpenFailed": "Claude ließ sich nicht öffnen.",
|
"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})",
|
|
||||||
|
|
||||||
"assistantConnectTitle": "Claude verbinden",
|
"assistantConnectTitle": "Claude verbinden",
|
||||||
"assistantConnectSubtitle": "Claude deine Lernsets lesen lassen",
|
"assistantConnectSubtitle": "Claude deine Lernsets lesen lassen",
|
||||||
|
|
@ -1564,5 +1561,27 @@
|
||||||
"assistantDisconnectMessage": "Es kann deine Lernsets nicht mehr lesen. Mit einem neuen Code kannst du es wieder verbinden.",
|
"assistantDisconnectMessage": "Es kann deine Lernsets nicht mehr lesen. Mit einem neuen Code kannst du es wieder verbinden.",
|
||||||
"assistantNotOffered": "Diese Instanz bietet noch keinen Konnektor an. Fragen öffnen sich trotzdem in Claude, mit dem, worum es geht.",
|
"assistantNotOffered": "Diese Instanz bietet noch keinen Konnektor an. Fragen öffnen sich trotzdem in Claude, mit dem, worum es geht.",
|
||||||
"assistantUnreachable": "Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.",
|
"assistantUnreachable": "Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.",
|
||||||
"assistantRetry": "Erneut versuchen"
|
"assistantRetry": "Erneut versuchen",
|
||||||
|
|
||||||
|
"assistantLineFront": "Vorderseite: {text}",
|
||||||
|
"assistantLineBack": "Rückseite: {text}",
|
||||||
|
"assistantLineHint": "Hinweis {number}: {text}",
|
||||||
|
"assistantLineExplanation": "Erklärung: {text}",
|
||||||
|
"assistantLineStudyset": "Lernset: „{title}“ in Halkyon Learn auf {instance}",
|
||||||
|
"assistantLineDescription": "Beschreibung: {text}",
|
||||||
|
"assistantChipStudyset": "Lernset: {title}",
|
||||||
|
"assistantContextLabel": "Mitsenden",
|
||||||
|
"assistantTargetClaude": "Claude",
|
||||||
|
"assistantTargetWeb": "Web",
|
||||||
|
"assistantSwitchToWeb": "Stattdessen im Web suchen",
|
||||||
|
"assistantSwitchToClaude": "Stattdessen Claude fragen",
|
||||||
|
"assistantHintWeb": "Im Web suchen…",
|
||||||
|
"assistantSendWeb": "Suchen",
|
||||||
|
"assistantSearchFailed": "Die Suche ließ sich nicht öffnen.",
|
||||||
|
"settingsSearchEngine": "Suchmaschine",
|
||||||
|
"searchEngineDevice": "Suche des Geräts",
|
||||||
|
"searchEngineCustom": "Eigene Adresse",
|
||||||
|
"searchEngineCustomHint": "https://example.org/search?q=%s",
|
||||||
|
"searchEngineCustomInvalid": "Eine http- oder https-Adresse mit %s, wo die Frage hingehört.",
|
||||||
|
"searchEngineCustomSave": "Verwenden"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1789,22 +1789,16 @@
|
||||||
"kanjiExampleWords": "Words with it",
|
"kanjiExampleWords": "Words with it",
|
||||||
|
|
||||||
"settingsAssistant": "Assistant",
|
"settingsAssistant": "Assistant",
|
||||||
"settingsAskClaude": "Ask Claude",
|
"settingsAskClaude": "Ask Claude or search the web",
|
||||||
"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.",
|
"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 or as a web search, with only the context you choose; nothing is sent from here.",
|
||||||
"assistantHint": "Ask Claude…",
|
"assistantHint": "Ask Claude…",
|
||||||
"assistantHintStudyset": "Ask Claude about {title}…",
|
"assistantHintStudyset": "Ask Claude about {title}…",
|
||||||
"@assistantHintStudyset": {"placeholders": {"title": {"type": "String"}}},
|
"@assistantHintStudyset": {"placeholders": {"title": {"type": "String"}}},
|
||||||
"assistantHintCard": "Ask Claude about this card…",
|
"assistantHintCard": "Ask Claude about this card…",
|
||||||
"assistantSend": "Ask in Claude",
|
"assistantSend": "Ask in Claude",
|
||||||
"assistantOpen": "Ask Claude",
|
"assistantOpen": "Ask or search",
|
||||||
"assistantClose": "Close",
|
"assistantClose": "Close",
|
||||||
"assistantOpenFailed": "Claude couldn't be opened.",
|
"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"}}},
|
|
||||||
|
|
||||||
"assistantConnectTitle": "Connect Claude",
|
"assistantConnectTitle": "Connect Claude",
|
||||||
"assistantConnectSubtitle": "Let Claude read your studysets",
|
"assistantConnectSubtitle": "Let Claude read your studysets",
|
||||||
|
|
@ -1830,5 +1824,34 @@
|
||||||
"assistantDisconnectMessage": "It can't read your studysets any more. You can connect it again with a new code.",
|
"assistantDisconnectMessage": "It can't read your studysets any more. You can connect it again with a new code.",
|
||||||
"assistantNotOffered": "This instance doesn't offer a connector yet. Questions still open in Claude with what they are about.",
|
"assistantNotOffered": "This instance doesn't offer a connector yet. Questions still open in Claude with what they are about.",
|
||||||
"assistantUnreachable": "The instance can't be reached. Try again once you are online.",
|
"assistantUnreachable": "The instance can't be reached. Try again once you are online.",
|
||||||
"assistantRetry": "Try again"
|
"assistantRetry": "Try again",
|
||||||
|
|
||||||
|
"assistantLineFront": "Front: {text}",
|
||||||
|
"@assistantLineFront": {"description": "A line of context handed to Claude after the question.", "placeholders": {"text": {"type": "String"}}},
|
||||||
|
"assistantLineBack": "Back: {text}",
|
||||||
|
"@assistantLineBack": {"placeholders": {"text": {"type": "String"}}},
|
||||||
|
"assistantLineHint": "Hint {number}: {text}",
|
||||||
|
"@assistantLineHint": {"placeholders": {"number": {"type": "int"}, "text": {"type": "String"}}},
|
||||||
|
"assistantLineExplanation": "Explanation: {text}",
|
||||||
|
"@assistantLineExplanation": {"placeholders": {"text": {"type": "String"}}},
|
||||||
|
"assistantLineStudyset": "Studyset: “{title}” in Halkyon Learn on {instance}",
|
||||||
|
"@assistantLineStudyset": {"placeholders": {"title": {"type": "String"}, "instance": {"type": "String"}}},
|
||||||
|
"assistantLineDescription": "Description: {text}",
|
||||||
|
"@assistantLineDescription": {"placeholders": {"text": {"type": "String"}}},
|
||||||
|
"assistantChipStudyset": "Studyset: {title}",
|
||||||
|
"@assistantChipStudyset": {"placeholders": {"title": {"type": "String"}}},
|
||||||
|
"assistantContextLabel": "Send along",
|
||||||
|
"assistantTargetClaude": "Claude",
|
||||||
|
"assistantTargetWeb": "Web",
|
||||||
|
"assistantSwitchToWeb": "Search the web instead",
|
||||||
|
"assistantSwitchToClaude": "Ask Claude instead",
|
||||||
|
"assistantHintWeb": "Search the web…",
|
||||||
|
"assistantSendWeb": "Search",
|
||||||
|
"assistantSearchFailed": "The search couldn't be opened.",
|
||||||
|
"settingsSearchEngine": "Search engine",
|
||||||
|
"searchEngineDevice": "The device's search",
|
||||||
|
"searchEngineCustom": "Your own address",
|
||||||
|
"searchEngineCustomHint": "https://example.org/search?q=%s",
|
||||||
|
"searchEngineCustomInvalid": "An http or https address with %s where the question goes.",
|
||||||
|
"searchEngineCustomSave": "Use"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9084,13 +9084,13 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @settingsAskClaude.
|
/// No description provided for @settingsAskClaude.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Ask Claude'**
|
/// **'Ask Claude or search the web'**
|
||||||
String get settingsAskClaude;
|
String get settingsAskClaude;
|
||||||
|
|
||||||
/// No description provided for @settingsAskClaudeSubtitle.
|
/// No description provided for @settingsAskClaudeSubtitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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.'**
|
/// **'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 or as a web search, with only the context you choose; nothing is sent from here.'**
|
||||||
String get settingsAskClaudeSubtitle;
|
String get settingsAskClaudeSubtitle;
|
||||||
|
|
||||||
/// No description provided for @assistantHint.
|
/// No description provided for @assistantHint.
|
||||||
|
|
@ -9120,7 +9120,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @assistantOpen.
|
/// No description provided for @assistantOpen.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Ask Claude'**
|
/// **'Ask or search'**
|
||||||
String get assistantOpen;
|
String get assistantOpen;
|
||||||
|
|
||||||
/// No description provided for @assistantClose.
|
/// No description provided for @assistantClose.
|
||||||
|
|
@ -9135,34 +9135,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Claude couldn\'t be opened.'**
|
/// **'Claude couldn\'t be opened.'**
|
||||||
String get assistantOpenFailed;
|
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// No description provided for @assistantConnectTitle.
|
/// No description provided for @assistantConnectTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -9282,6 +9254,132 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Try again'**
|
/// **'Try again'**
|
||||||
String get assistantRetry;
|
String get assistantRetry;
|
||||||
|
|
||||||
|
/// A line of context handed to Claude after the question.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Front: {text}'**
|
||||||
|
String assistantLineFront(String text);
|
||||||
|
|
||||||
|
/// No description provided for @assistantLineBack.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Back: {text}'**
|
||||||
|
String assistantLineBack(String text);
|
||||||
|
|
||||||
|
/// No description provided for @assistantLineHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hint {number}: {text}'**
|
||||||
|
String assistantLineHint(int number, String text);
|
||||||
|
|
||||||
|
/// No description provided for @assistantLineExplanation.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Explanation: {text}'**
|
||||||
|
String assistantLineExplanation(String text);
|
||||||
|
|
||||||
|
/// No description provided for @assistantLineStudyset.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Studyset: “{title}” in Halkyon Learn on {instance}'**
|
||||||
|
String assistantLineStudyset(String title, String instance);
|
||||||
|
|
||||||
|
/// No description provided for @assistantLineDescription.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Description: {text}'**
|
||||||
|
String assistantLineDescription(String text);
|
||||||
|
|
||||||
|
/// No description provided for @assistantChipStudyset.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Studyset: {title}'**
|
||||||
|
String assistantChipStudyset(String title);
|
||||||
|
|
||||||
|
/// No description provided for @assistantContextLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Send along'**
|
||||||
|
String get assistantContextLabel;
|
||||||
|
|
||||||
|
/// No description provided for @assistantTargetClaude.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Claude'**
|
||||||
|
String get assistantTargetClaude;
|
||||||
|
|
||||||
|
/// No description provided for @assistantTargetWeb.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Web'**
|
||||||
|
String get assistantTargetWeb;
|
||||||
|
|
||||||
|
/// No description provided for @assistantSwitchToWeb.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search the web instead'**
|
||||||
|
String get assistantSwitchToWeb;
|
||||||
|
|
||||||
|
/// No description provided for @assistantSwitchToClaude.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Ask Claude instead'**
|
||||||
|
String get assistantSwitchToClaude;
|
||||||
|
|
||||||
|
/// No description provided for @assistantHintWeb.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search the web…'**
|
||||||
|
String get assistantHintWeb;
|
||||||
|
|
||||||
|
/// No description provided for @assistantSendWeb.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search'**
|
||||||
|
String get assistantSendWeb;
|
||||||
|
|
||||||
|
/// No description provided for @assistantSearchFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The search couldn\'t be opened.'**
|
||||||
|
String get assistantSearchFailed;
|
||||||
|
|
||||||
|
/// No description provided for @settingsSearchEngine.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search engine'**
|
||||||
|
String get settingsSearchEngine;
|
||||||
|
|
||||||
|
/// No description provided for @searchEngineDevice.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The device\'s search'**
|
||||||
|
String get searchEngineDevice;
|
||||||
|
|
||||||
|
/// No description provided for @searchEngineCustom.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your own address'**
|
||||||
|
String get searchEngineCustom;
|
||||||
|
|
||||||
|
/// No description provided for @searchEngineCustomHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'https://example.org/search?q=%s'**
|
||||||
|
String get searchEngineCustomHint;
|
||||||
|
|
||||||
|
/// No description provided for @searchEngineCustomInvalid.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'An http or https address with %s where the question goes.'**
|
||||||
|
String get searchEngineCustomInvalid;
|
||||||
|
|
||||||
|
/// No description provided for @searchEngineCustomSave.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Use'**
|
||||||
|
String get searchEngineCustomSave;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -5681,11 +5681,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get settingsAssistant => 'Assistent';
|
String get settingsAssistant => 'Assistent';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAskClaude => 'Claude fragen';
|
String get settingsAskClaude => 'Claude fragen oder im Web suchen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAskClaudeSubtitle =>
|
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.';
|
'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 oder als Websuche, nur mit dem Zusammenhang, den du wählst; von hier aus wird nichts gesendet.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get assistantHint => 'Frag Claude…';
|
String get assistantHint => 'Frag Claude…';
|
||||||
|
|
@ -5702,7 +5702,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get assistantSend => 'In Claude fragen';
|
String get assistantSend => 'In Claude fragen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get assistantOpen => 'Claude fragen';
|
String get assistantOpen => 'Fragen oder suchen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get assistantClose => 'Schließen';
|
String get assistantClose => 'Schließen';
|
||||||
|
|
@ -5710,31 +5710,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get assistantOpenFailed => 'Claude ließ sich nicht öffnen.';
|
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)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get assistantConnectTitle => 'Claude verbinden';
|
String get assistantConnectTitle => 'Claude verbinden';
|
||||||
|
|
||||||
|
|
@ -5809,4 +5784,82 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get assistantRetry => 'Erneut versuchen';
|
String get assistantRetry => 'Erneut versuchen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineFront(String text) {
|
||||||
|
return 'Vorderseite: $text';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineBack(String text) {
|
||||||
|
return 'Rückseite: $text';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineHint(int number, String text) {
|
||||||
|
return 'Hinweis $number: $text';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineExplanation(String text) {
|
||||||
|
return 'Erklärung: $text';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineStudyset(String title, String instance) {
|
||||||
|
return 'Lernset: „$title“ in Halkyon Learn auf $instance';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineDescription(String text) {
|
||||||
|
return 'Beschreibung: $text';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantChipStudyset(String title) {
|
||||||
|
return 'Lernset: $title';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantContextLabel => 'Mitsenden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantTargetClaude => 'Claude';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantTargetWeb => 'Web';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantSwitchToWeb => 'Stattdessen im Web suchen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantSwitchToClaude => 'Stattdessen Claude fragen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantHintWeb => 'Im Web suchen…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantSendWeb => 'Suchen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantSearchFailed => 'Die Suche ließ sich nicht öffnen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSearchEngine => 'Suchmaschine';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEngineDevice => 'Suche des Geräts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEngineCustom => 'Eigene Adresse';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEngineCustomHint => 'https://example.org/search?q=%s';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEngineCustomInvalid =>
|
||||||
|
'Eine http- oder https-Adresse mit %s, wo die Frage hingehört.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEngineCustomSave => 'Verwenden';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5632,11 +5632,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get settingsAssistant => 'Assistant';
|
String get settingsAssistant => 'Assistant';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAskClaude => 'Ask Claude';
|
String get settingsAskClaude => 'Ask Claude or search the web';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAskClaudeSubtitle =>
|
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.';
|
'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 or as a web search, with only the context you choose; nothing is sent from here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get assistantHint => 'Ask Claude…';
|
String get assistantHint => 'Ask Claude…';
|
||||||
|
|
@ -5653,7 +5653,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get assistantSend => 'Ask in Claude';
|
String get assistantSend => 'Ask in Claude';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get assistantOpen => 'Ask Claude';
|
String get assistantOpen => 'Ask or search';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get assistantClose => 'Close';
|
String get assistantClose => 'Close';
|
||||||
|
|
@ -5661,31 +5661,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get assistantOpenFailed => 'Claude couldn\'t be opened.';
|
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)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get assistantConnectTitle => 'Connect Claude';
|
String get assistantConnectTitle => 'Connect Claude';
|
||||||
|
|
||||||
|
|
@ -5760,6 +5735,84 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get assistantRetry => 'Try again';
|
String get assistantRetry => 'Try again';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineFront(String text) {
|
||||||
|
return 'Front: $text';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineBack(String text) {
|
||||||
|
return 'Back: $text';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineHint(int number, String text) {
|
||||||
|
return 'Hint $number: $text';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineExplanation(String text) {
|
||||||
|
return 'Explanation: $text';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineStudyset(String title, String instance) {
|
||||||
|
return 'Studyset: “$title” in Halkyon Learn on $instance';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantLineDescription(String text) {
|
||||||
|
return 'Description: $text';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String assistantChipStudyset(String title) {
|
||||||
|
return 'Studyset: $title';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantContextLabel => 'Send along';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantTargetClaude => 'Claude';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantTargetWeb => 'Web';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantSwitchToWeb => 'Search the web instead';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantSwitchToClaude => 'Ask Claude instead';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantHintWeb => 'Search the web…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantSendWeb => 'Search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get assistantSearchFailed => 'The search couldn\'t be opened.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSearchEngine => 'Search engine';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEngineDevice => 'The device\'s search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEngineCustom => 'Your own address';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEngineCustomHint => 'https://example.org/search?q=%s';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEngineCustomInvalid =>
|
||||||
|
'An http or https address with %s where the question goes.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEngineCustomSave => 'Use';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.
|
||||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/claude_hand_off.dart';
|
import 'package:halkyon_learn_flutter/src/feature/assistant/data/claude_hand_off.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/serverpod_assistant_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/assistant/data/serverpod_assistant_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.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';
|
||||||
|
|
@ -149,6 +150,9 @@ class Dependencies({
|
||||||
/// Hands questions to Claude.
|
/// Hands questions to Claude.
|
||||||
required final ClaudeHandOff claude,
|
required final ClaudeHandOff claude,
|
||||||
|
|
||||||
|
/// Searches the web for questions.
|
||||||
|
required final WebSearch webSearch,
|
||||||
|
|
||||||
/// Connects assistants to the account on the instance (ADR 0006).
|
/// Connects assistants to the account on the instance (ADR 0006).
|
||||||
required final AssistantRepository assistant,
|
required final AssistantRepository assistant,
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
|
|
@ -206,6 +210,7 @@ class Dependencies({
|
||||||
appVersion: appVersion,
|
appVersion: appVersion,
|
||||||
pomodoro: device.pomodoro,
|
pomodoro: device.pomodoro,
|
||||||
claude: ClaudeHandOff(),
|
claude: ClaudeHandOff(),
|
||||||
|
webSearch: WebSearch(),
|
||||||
assistant: AssistantRepository(ServerpodAssistantGateway(client)),
|
assistant: AssistantRepository(ServerpodAssistantGateway(client)),
|
||||||
openPerson: (person) async {
|
openPerson: (person) async {
|
||||||
await previousClosed.timeout(
|
await previousClosed.timeout(
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,15 @@ class DeviceSettingsBloc(
|
||||||
on<DeviceSettingsEventAssistantChosen>(
|
on<DeviceSettingsEventAssistantChosen>(
|
||||||
(event, emit) => _apply(emit, state.withAssistant(event.on)),
|
(event, emit) => _apply(emit, state.withAssistant(event.on)),
|
||||||
);
|
);
|
||||||
|
on<DeviceSettingsEventAssistantTargetChosen>(
|
||||||
|
(event, emit) => _apply(emit, state.withAssistantTarget(event.target)),
|
||||||
|
);
|
||||||
|
on<DeviceSettingsEventSearchEngineChosen>(
|
||||||
|
(event, emit) => _apply(
|
||||||
|
emit,
|
||||||
|
state.withSearchEngine(event.name, customUrl: event.customUrl),
|
||||||
|
),
|
||||||
|
);
|
||||||
on<DeviceSettingsEventBrowserViewChosen>(
|
on<DeviceSettingsEventBrowserViewChosen>(
|
||||||
(event, emit) => _apply(
|
(event, emit) => _apply(
|
||||||
emit,
|
emit,
|
||||||
|
|
@ -94,6 +103,24 @@ final class const DeviceSettingsEventAssistantChosen(final bool on)
|
||||||
List<Object?> get props => [on];
|
List<Object?> get props => [on];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The person sent a question to [target], which the field remembers.
|
||||||
|
final class const DeviceSettingsEventAssistantTargetChosen(
|
||||||
|
final AssistantTarget target,
|
||||||
|
) extends DeviceSettingsEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [target];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person chose the search engine [name] (null for the platform's
|
||||||
|
/// default), at [customUrl] for a custom one.
|
||||||
|
final class const DeviceSettingsEventSearchEngineChosen(
|
||||||
|
final String? name, {
|
||||||
|
final String? customUrl,
|
||||||
|
}) extends DeviceSettingsEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [name, customUrl];
|
||||||
|
}
|
||||||
|
|
||||||
/// 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(
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,17 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
|
||||||
for (final id in ids.whereType<String>()) id,
|
for (final id in ids.whereType<String>()) id,
|
||||||
},
|
},
|
||||||
assistant: json['assistant'] == true,
|
assistant: json['assistant'] == true,
|
||||||
|
assistantTarget:
|
||||||
|
AssistantTarget.values.asNameMap()[json['assistantTarget']] ??
|
||||||
|
AssistantTarget.claude,
|
||||||
|
searchEngine: switch (json['searchEngine']) {
|
||||||
|
final String name => name,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
customSearchUrl: switch (json['customSearchUrl']) {
|
||||||
|
final String url => url,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} 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);
|
||||||
|
|
@ -71,6 +82,9 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
|
||||||
'pomodoroWhileStudying': settings.pomodoroWhileStudying,
|
'pomodoroWhileStudying': settings.pomodoroWhileStudying,
|
||||||
'detailedBrowsers': [...settings.detailedBrowsers],
|
'detailedBrowsers': [...settings.detailedBrowsers],
|
||||||
'assistant': settings.assistant,
|
'assistant': settings.assistant,
|
||||||
|
'assistantTarget': settings.assistantTarget.name,
|
||||||
|
'searchEngine': ?settings.searchEngine,
|
||||||
|
'customSearchUrl': ?settings.customSearchUrl,
|
||||||
}),
|
}),
|
||||||
flush: true,
|
flush: true,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,18 @@ enum Appearance() {
|
||||||
dark,
|
dark,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a question asked from the app goes: to Claude, or into a web
|
||||||
|
/// search; the field remembers the last choice (decided 2026-10-10).
|
||||||
|
enum AssistantTarget() {
|
||||||
|
claude,
|
||||||
|
web,
|
||||||
|
}
|
||||||
|
|
||||||
/// 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, which studysets' card
|
/// whether studying shows the Pomodoro timer, which studysets' card
|
||||||
/// browsers show one card at a time, and whether the person asks Claude
|
/// browsers show one card at a time, and whether and how the person asks
|
||||||
/// from the app.
|
/// Claude or searches the web 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({
|
||||||
|
|
@ -32,74 +39,83 @@ final class const DeviceSettings({
|
||||||
/// 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
|
/// Whether the app offers to ask Claude or search the web (decided
|
||||||
/// Home, a button in the Library, on a studyset and while studying. Off
|
/// 2026-10-10): a field on Home, a button in the Library, on a studyset
|
||||||
/// unless the person turns it on.
|
/// and while studying. Off unless the person turns it on.
|
||||||
final bool assistant = false,
|
final bool assistant = false,
|
||||||
|
|
||||||
|
/// Where the field sends a question, as last chosen.
|
||||||
|
final AssistantTarget assistantTarget = AssistantTarget.claude,
|
||||||
|
|
||||||
|
/// The search engine a web search uses, by its name in the assistant's
|
||||||
|
/// list; null for the platform's default (the device's own search on
|
||||||
|
/// Android, Brave Search elsewhere).
|
||||||
|
final String? searchEngine,
|
||||||
|
|
||||||
|
/// The address a custom search engine searches, with `%s` for the
|
||||||
|
/// question; null without.
|
||||||
|
final String? customSearchUrl,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// These settings with [appearance] instead.
|
/// These settings with [appearance] instead.
|
||||||
DeviceSettings withAppearance(Appearance appearance) => DeviceSettings(
|
DeviceSettings withAppearance(Appearance appearance) =>
|
||||||
appearance: appearance,
|
_copy(appearance: appearance);
|
||||||
language: language,
|
|
||||||
citationStyle: citationStyle,
|
|
||||||
pomodoroWhileStudying: pomodoroWhileStudying,
|
|
||||||
detailedBrowsers: detailedBrowsers,
|
|
||||||
assistant: assistant,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// These settings with [language] instead; null for the device's.
|
/// These settings with [language] instead; null for the device's.
|
||||||
DeviceSettings withLanguage(String? language) => DeviceSettings(
|
DeviceSettings withLanguage(String? language) => _copy(language: language);
|
||||||
appearance: appearance,
|
|
||||||
language: language,
|
|
||||||
citationStyle: citationStyle,
|
|
||||||
pomodoroWhileStudying: pomodoroWhileStudying,
|
|
||||||
detailedBrowsers: detailedBrowsers,
|
|
||||||
assistant: assistant,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// These settings with [citationStyle] instead.
|
/// These settings with [citationStyle] instead.
|
||||||
DeviceSettings withCitationStyle(CitationStyle citationStyle) =>
|
DeviceSettings withCitationStyle(CitationStyle citationStyle) =>
|
||||||
DeviceSettings(
|
_copy(citationStyle: citationStyle);
|
||||||
appearance: appearance,
|
|
||||||
language: language,
|
|
||||||
citationStyle: citationStyle,
|
|
||||||
pomodoroWhileStudying: pomodoroWhileStudying,
|
|
||||||
detailedBrowsers: detailedBrowsers,
|
|
||||||
assistant: assistant,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// These settings with [shown] for the Pomodoro timer while studying.
|
/// These settings with [shown] for the Pomodoro timer while studying.
|
||||||
DeviceSettings withPomodoroWhileStudying(bool shown) => DeviceSettings(
|
DeviceSettings withPomodoroWhileStudying(bool shown) =>
|
||||||
appearance: appearance,
|
_copy(pomodoroWhileStudying: shown);
|
||||||
language: language,
|
|
||||||
citationStyle: citationStyle,
|
|
||||||
pomodoroWhileStudying: shown,
|
|
||||||
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
|
||||||
/// at a time ([detailed]) or a grid.
|
/// at a time ([detailed]) or a grid.
|
||||||
DeviceSettings withBrowserView(String studysetId, {required bool detailed}) =>
|
DeviceSettings withBrowserView(String studysetId, {required bool detailed}) =>
|
||||||
DeviceSettings(
|
_copy(
|
||||||
appearance: appearance,
|
|
||||||
language: language,
|
|
||||||
citationStyle: citationStyle,
|
|
||||||
pomodoroWhileStudying: pomodoroWhileStudying,
|
|
||||||
detailedBrowsers: detailed
|
detailedBrowsers: detailed
|
||||||
? {...detailedBrowsers, studysetId}
|
? {...detailedBrowsers, studysetId}
|
||||||
: ({...detailedBrowsers}..remove(studysetId)),
|
: ({...detailedBrowsers}..remove(studysetId)),
|
||||||
assistant: assistant,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// These settings with asking Claude [on] or off.
|
/// These settings with asking Claude or searching the web [on] or off.
|
||||||
DeviceSettings withAssistant(bool on) => DeviceSettings(
|
DeviceSettings withAssistant(bool on) => _copy(assistant: on);
|
||||||
appearance: appearance,
|
|
||||||
language: language,
|
/// These settings with questions going to [target].
|
||||||
citationStyle: citationStyle,
|
DeviceSettings withAssistantTarget(AssistantTarget target) =>
|
||||||
pomodoroWhileStudying: pomodoroWhileStudying,
|
_copy(assistantTarget: target);
|
||||||
detailedBrowsers: detailedBrowsers,
|
|
||||||
assistant: on,
|
/// These settings searching with the engine [name] (null for the
|
||||||
|
/// platform's default), at [customUrl] for a custom one.
|
||||||
|
DeviceSettings withSearchEngine(String? name, {String? customUrl}) =>
|
||||||
|
_copy(searchEngine: name, customSearchUrl: customUrl);
|
||||||
|
|
||||||
|
DeviceSettings _copy({
|
||||||
|
Appearance? appearance,
|
||||||
|
Object? language = _keep,
|
||||||
|
CitationStyle? citationStyle,
|
||||||
|
bool? pomodoroWhileStudying,
|
||||||
|
Set<String>? detailedBrowsers,
|
||||||
|
bool? assistant,
|
||||||
|
AssistantTarget? assistantTarget,
|
||||||
|
Object? searchEngine = _keep,
|
||||||
|
Object? customSearchUrl = _keep,
|
||||||
|
}) => DeviceSettings(
|
||||||
|
appearance: appearance ?? this.appearance,
|
||||||
|
language: identical(language, _keep) ? this.language : language as String?,
|
||||||
|
citationStyle: citationStyle ?? this.citationStyle,
|
||||||
|
pomodoroWhileStudying: pomodoroWhileStudying ?? this.pomodoroWhileStudying,
|
||||||
|
detailedBrowsers: detailedBrowsers ?? this.detailedBrowsers,
|
||||||
|
assistant: assistant ?? this.assistant,
|
||||||
|
assistantTarget: assistantTarget ?? this.assistantTarget,
|
||||||
|
searchEngine: identical(searchEngine, _keep)
|
||||||
|
? this.searchEngine
|
||||||
|
: searchEngine as String?,
|
||||||
|
customSearchUrl: identical(customSearchUrl, _keep)
|
||||||
|
? this.customSearchUrl
|
||||||
|
: customSearchUrl as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -110,5 +126,11 @@ final class const DeviceSettings({
|
||||||
pomodoroWhileStudying,
|
pomodoroWhileStudying,
|
||||||
detailedBrowsers,
|
detailedBrowsers,
|
||||||
assistant,
|
assistant,
|
||||||
|
assistantTarget,
|
||||||
|
searchEngine,
|
||||||
|
customSearchUrl,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What `DeviceSettings._copy` keeps as it is.
|
||||||
|
const _keep = Object();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/model/search_engine.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
/// Searches the web for a question asked from the app (decided 2026-10-10):
|
||||||
|
/// with the device's own web search on Android where chosen, else by opening
|
||||||
|
/// the chosen engine's results in the browser. Nothing goes anywhere but to
|
||||||
|
/// the engine, and only what the person sends.
|
||||||
|
class WebSearch({
|
||||||
|
final Future<bool> Function(Uri url) _open = _openOutside,
|
||||||
|
final Future<bool> Function(String query) _deviceSearch = _searchOnDevice,
|
||||||
|
}) {
|
||||||
|
/// How long a query may be: search engines cut longer ones anyway, and a
|
||||||
|
/// link stays well within what browsers accept.
|
||||||
|
static const maxQueryLength = 1500;
|
||||||
|
|
||||||
|
/// The results of [query] at [engine] ([customUrl] for a custom one);
|
||||||
|
/// Brave Search's for [SearchEngine.device], or for a custom address
|
||||||
|
/// that won't do.
|
||||||
|
static Uri urlFor(
|
||||||
|
String query,
|
||||||
|
SearchEngine engine, {
|
||||||
|
String? customUrl,
|
||||||
|
}) {
|
||||||
|
final template = switch (engine) {
|
||||||
|
SearchEngine.custom
|
||||||
|
when customUrl != null && SearchEngine.validTemplate(customUrl) =>
|
||||||
|
customUrl,
|
||||||
|
_ => engine.template ?? SearchEngine.brave.template!,
|
||||||
|
};
|
||||||
|
return Uri.parse(
|
||||||
|
template.replaceAll('%s', Uri.encodeQueryComponent(clip(query))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [query], cut to [maxQueryLength].
|
||||||
|
static String clip(String query) => query.length <= maxQueryLength
|
||||||
|
? query
|
||||||
|
: query.substring(0, maxQueryLength);
|
||||||
|
|
||||||
|
/// Searches the web for [query] with [engine]; false if nothing could
|
||||||
|
/// open it. The device's search falls back to Brave Search where nothing
|
||||||
|
/// on the device answers.
|
||||||
|
Future<bool> search(
|
||||||
|
String query, {
|
||||||
|
required SearchEngine engine,
|
||||||
|
String? customUrl,
|
||||||
|
}) async {
|
||||||
|
if (engine == SearchEngine.device && await _deviceSearch(clip(query))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return await _open(urlFor(query, engine, customUrl: customUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _openOutside(Uri url) =>
|
||||||
|
launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
|
|
||||||
|
/// Hands [query] to Android's web search (`ACTION_WEB_SEARCH`, in
|
||||||
|
/// `MainActivity.kt`); false elsewhere or where no app answers it.
|
||||||
|
Future<bool> _searchOnDevice(String query) async {
|
||||||
|
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await const MethodChannel(
|
||||||
|
'app.halkyon.learn/search',
|
||||||
|
).invokeMethod<bool>('webSearch', query) ??
|
||||||
|
false;
|
||||||
|
} on PlatformException {
|
||||||
|
return false;
|
||||||
|
} on MissingPluginException {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,157 @@
|
||||||
|
import 'package:equatable/equatable.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/content/rich_content.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';
|
||||||
|
|
||||||
/// What a question to the assistant is about, written into it so that the
|
/// What a question asked from the app can be about, and the parts of it
|
||||||
/// assistant knows without asking.
|
/// the person may send along (decided 2026-10-10): offered as chips, none
|
||||||
sealed class const AssistantTopic();
|
/// chosen, so only what matters is sent and no more.
|
||||||
|
sealed class const AssistantTopic() {
|
||||||
|
/// The parts that can go along, in the order offered.
|
||||||
|
List<AssistantPart> get parts;
|
||||||
|
}
|
||||||
|
|
||||||
/// The person's learning as a whole: asked on Home or in the Library.
|
/// The person's learning as a whole: asked on Home or in the Library.
|
||||||
final class const AssistantTopicLearning() extends AssistantTopic;
|
/// Nothing to send along.
|
||||||
|
final class const AssistantTopicLearning() extends AssistantTopic {
|
||||||
|
@override
|
||||||
|
List<AssistantPart> get parts => const [];
|
||||||
|
}
|
||||||
|
|
||||||
/// One studyset, asked on its screen.
|
/// One studyset, asked on its screen: its name, and its description where
|
||||||
|
/// it has one.
|
||||||
final class const AssistantTopicStudyset({
|
final class const AssistantTopicStudyset({
|
||||||
/// Its title.
|
/// Its title.
|
||||||
required final String title,
|
required final String title,
|
||||||
}) extends AssistantTopic;
|
|
||||||
|
|
||||||
/// The card being studied, asked during a session. Its text goes along, so
|
/// Its description.
|
||||||
/// the assistant can answer without reading the studyset.
|
final RichContent? description,
|
||||||
|
}) extends AssistantTopic {
|
||||||
|
@override
|
||||||
|
List<AssistantPart> get parts => [
|
||||||
|
AssistantPart(kind: AssistantPartKind.studyset, text: title),
|
||||||
|
if (description?.plainText.trim() case final text? when text.isNotEmpty)
|
||||||
|
AssistantPart(kind: AssistantPartKind.description, text: text),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card being studied, asked during a session: its front, its back,
|
||||||
|
/// each hint, its explanation, and the studyset's name.
|
||||||
final class const AssistantTopicCard({
|
final class const AssistantTopicCard({
|
||||||
/// The title of the studyset being studied.
|
/// The title of the studyset being studied.
|
||||||
required final String studysetTitle,
|
required final String studysetTitle,
|
||||||
|
|
||||||
/// The card.
|
/// The card.
|
||||||
required final CardContent card,
|
required final CardContent card,
|
||||||
}) extends AssistantTopic;
|
}) extends AssistantTopic {
|
||||||
|
@override
|
||||||
|
List<AssistantPart> get parts => [
|
||||||
|
if (cardFrontText(card) case final front when front.isNotEmpty)
|
||||||
|
AssistantPart(kind: AssistantPartKind.front, text: front),
|
||||||
|
if (cardBackText(card) case final back when back.isNotEmpty)
|
||||||
|
AssistantPart(kind: AssistantPartKind.back, text: back),
|
||||||
|
for (final (index, hint) in card.hints.indexed)
|
||||||
|
if (hint.plainText.trim() case final text when text.isNotEmpty)
|
||||||
|
AssistantPart(
|
||||||
|
kind: AssistantPartKind.hint,
|
||||||
|
text: text,
|
||||||
|
number: index + 1,
|
||||||
|
),
|
||||||
|
if (card.explanation?.plainText.trim() case final text?
|
||||||
|
when text.isNotEmpty)
|
||||||
|
AssistantPart(kind: AssistantPartKind.explanation, text: text),
|
||||||
|
AssistantPart(kind: AssistantPartKind.studyset, text: studysetTitle),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// The text handed to the assistant: [question], then what it is about on
|
/// What a part of a topic is.
|
||||||
/// which [instance], in the app's language.
|
enum AssistantPartKind() {
|
||||||
|
front,
|
||||||
|
back,
|
||||||
|
hint,
|
||||||
|
explanation,
|
||||||
|
studyset,
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A part of a topic that can go along with a question: its [kind] and its
|
||||||
|
/// [text], cut to [maxLength].
|
||||||
|
final class const AssistantPart({
|
||||||
|
/// What it is.
|
||||||
|
required final AssistantPartKind kind,
|
||||||
|
|
||||||
|
/// Its text, as sent.
|
||||||
|
required final String text,
|
||||||
|
|
||||||
|
/// A hint's number, from 1.
|
||||||
|
final int? number,
|
||||||
|
}) with Equatable {
|
||||||
|
/// How long a part's text may be when sent: enough for a sentence or
|
||||||
|
/// two, not a chapter.
|
||||||
|
static const maxLength = 600;
|
||||||
|
|
||||||
|
/// Whether a web search takes it: a card's text does, the studyset's name
|
||||||
|
/// and description don't (they would only narrow the results wrongly).
|
||||||
|
bool get searchable =>
|
||||||
|
kind != AssistantPartKind.studyset &&
|
||||||
|
kind != AssistantPartKind.description;
|
||||||
|
|
||||||
|
/// [text] as sent, cut to [maxLength].
|
||||||
|
String get sent =>
|
||||||
|
text.length <= maxLength ? text : '${text.substring(0, maxLength - 1)}…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [kind, text, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text handed to Claude: [question], then each part [chosen], a line
|
||||||
|
/// each, as compact as it reads; the studyset by name and [instance], so a
|
||||||
|
/// connected Claude knows where to look. The question alone without any.
|
||||||
String assistantPrompt(
|
String assistantPrompt(
|
||||||
AppLocalizations l10n, {
|
AppLocalizations l10n, {
|
||||||
required String question,
|
required String question,
|
||||||
required AssistantTopic topic,
|
required List<AssistantPart> chosen,
|
||||||
required String instance,
|
required String instance,
|
||||||
}) => switch (topic) {
|
}) {
|
||||||
AssistantTopicLearning() => l10n.assistantPromptLearning(
|
final lines = [
|
||||||
question.trim(),
|
for (final part in chosen)
|
||||||
instance,
|
switch (part.kind) {
|
||||||
),
|
AssistantPartKind.front => l10n.assistantLineFront(part.sent),
|
||||||
AssistantTopicStudyset(:final title) => l10n.assistantPromptStudyset(
|
AssistantPartKind.back => l10n.assistantLineBack(part.sent),
|
||||||
question.trim(),
|
AssistantPartKind.hint => l10n.assistantLineHint(
|
||||||
title,
|
part.number ?? 1,
|
||||||
instance,
|
part.sent,
|
||||||
),
|
),
|
||||||
AssistantTopicCard(:final studysetTitle, :final card) =>
|
AssistantPartKind.explanation => l10n.assistantLineExplanation(
|
||||||
l10n.assistantPromptCard(
|
part.sent,
|
||||||
question.trim(),
|
),
|
||||||
studysetTitle,
|
AssistantPartKind.studyset => l10n.assistantLineStudyset(
|
||||||
cardFrontText(card),
|
part.sent,
|
||||||
cardBackText(card),
|
instance,
|
||||||
instance,
|
),
|
||||||
),
|
AssistantPartKind.description => l10n.assistantLineDescription(
|
||||||
};
|
part.sent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return [question.trim(), if (lines.isNotEmpty) lines.join('\n')].join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a web search looks for: [question], then the text of each part
|
||||||
|
/// [chosen] that a search takes.
|
||||||
|
String searchQuery(String question, List<AssistantPart> chosen) => [
|
||||||
|
question.trim(),
|
||||||
|
for (final part in chosen)
|
||||||
|
if (part.searchable) part.sent,
|
||||||
|
].where((text) => text.isNotEmpty).join(' ');
|
||||||
|
|
||||||
/// What [card] asks, as plain text: its front, question, expression, or
|
/// What [card] asks, as plain text: its front, question, expression, or
|
||||||
/// what to sort.
|
/// what to sort.
|
||||||
String cardFrontText(CardContent card) => card.front.plainText.trim();
|
String cardFrontText(CardContent card) => card.front.plainText.trim();
|
||||||
|
|
||||||
/// What [card] answers, as plain text, a line each: the back, a word's
|
/// 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
|
/// reading and meanings, a kanji's readings, the right answers. The hints
|
||||||
/// explanation.
|
/// and the explanation are parts of their own.
|
||||||
String cardBackText(CardContent card) => [
|
String cardBackText(CardContent card) => [
|
||||||
card.back.plainText,
|
card.back.plainText,
|
||||||
card.reading,
|
card.reading,
|
||||||
|
|
@ -66,5 +160,4 @@ String cardBackText(CardContent card) => [
|
||||||
card.meanings.join('; '),
|
card.meanings.join('; '),
|
||||||
for (final answer in card.answers)
|
for (final answer in card.answers)
|
||||||
if (answer.correct) answer.content.plainText,
|
if (answer.correct) answer.content.plainText,
|
||||||
card.explanation?.plainText ?? '',
|
|
||||||
].map((text) => text.trim()).where((text) => text.isNotEmpty).join('\n');
|
].map((text) => text.trim()).where((text) => text.isNotEmpty).join('\n');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// The search engines a web search from the app can use (decided
|
||||||
|
/// 2026-10-10), by the address they search with `%s` for the question.
|
||||||
|
///
|
||||||
|
/// [device] hands the question to the device's own web search (Android's,
|
||||||
|
/// which its default browser or search app answers); no desktop has one an
|
||||||
|
/// app could use, so there [brave] is the default.
|
||||||
|
enum SearchEngine(
|
||||||
|
/// The address searched, with `%s` for the question; none for [device]
|
||||||
|
/// and [custom].
|
||||||
|
final String? template,
|
||||||
|
) {
|
||||||
|
device(null),
|
||||||
|
brave('https://search.brave.com/search?q=%s'),
|
||||||
|
ecosia('https://www.ecosia.org/search?q=%s'),
|
||||||
|
duckDuckGo('https://duckduckgo.com/?q=%s'),
|
||||||
|
startpage('https://www.startpage.com/do/search?query=%s'),
|
||||||
|
qwant('https://www.qwant.com/?q=%s'),
|
||||||
|
google('https://www.google.com/search?q=%s'),
|
||||||
|
bing('https://www.bing.com/search?q=%s'),
|
||||||
|
custom(null);
|
||||||
|
|
||||||
|
/// The engine stored as [name]; the platform's default for none or an
|
||||||
|
/// unknown one, and for [device] where there is no device search.
|
||||||
|
static SearchEngine byName(String? name) {
|
||||||
|
final named = SearchEngine.values.asNameMap()[name];
|
||||||
|
return named == null || !named.availableHere ? platformDefault : named;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device's own search on Android, Brave Search elsewhere.
|
||||||
|
static SearchEngine get platformDefault =>
|
||||||
|
defaultTargetPlatform == TargetPlatform.android ? device : brave;
|
||||||
|
|
||||||
|
/// Whether this platform offers it: [device] only on Android.
|
||||||
|
bool get availableHere =>
|
||||||
|
this != device || defaultTargetPlatform == TargetPlatform.android;
|
||||||
|
|
||||||
|
/// Whether [template] is an address a custom engine can search: http or
|
||||||
|
/// https, with `%s` for the question.
|
||||||
|
static bool validTemplate(String template) {
|
||||||
|
final uri = Uri.tryParse(template.replaceAll('%s', 'q'));
|
||||||
|
return template.contains('%s') &&
|
||||||
|
uri != null &&
|
||||||
|
(uri.scheme == 'https' || uri.scheme == 'http') &&
|
||||||
|
uri.host.isNotEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,285 +0,0 @@
|
||||||
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],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,460 @@
|
||||||
|
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/core/device_settings/model/device_settings.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/assistant/model/search_engine.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Whether the person turned on asking from the app on this device
|
||||||
|
/// (Settings › Assistant); off unless they did.
|
||||||
|
bool assistantOn(BuildContext context) =>
|
||||||
|
context.select<DeviceSettingsBloc, bool>((bloc) => bloc.state.assistant);
|
||||||
|
|
||||||
|
/// Sends [question], with the parts [chosen] of what it is about, to
|
||||||
|
/// [target]: Claude, or a web search with the device's chosen engine. Says
|
||||||
|
/// so if nothing could open it.
|
||||||
|
Future<void> sendQuestion(
|
||||||
|
BuildContext context, {
|
||||||
|
required String question,
|
||||||
|
required List<AssistantPart> chosen,
|
||||||
|
required AssistantTarget target,
|
||||||
|
}) async {
|
||||||
|
final dependencies = DependenciesScope.of(context);
|
||||||
|
final settings = context.read<DeviceSettingsBloc>().state;
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final opened = switch (target) {
|
||||||
|
AssistantTarget.claude => await dependencies.claude.ask(
|
||||||
|
assistantPrompt(
|
||||||
|
l10n,
|
||||||
|
question: question,
|
||||||
|
chosen: chosen,
|
||||||
|
instance: dependencies.serverName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AssistantTarget.web => await dependencies.webSearch.search(
|
||||||
|
searchQuery(question, chosen),
|
||||||
|
engine: SearchEngine.byName(settings.searchEngine),
|
||||||
|
customUrl: settings.customSearchUrl,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (!opened) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(switch (target) {
|
||||||
|
AssistantTarget.claude => l10n.assistantOpenFailed,
|
||||||
|
AssistantTarget.web => l10n.assistantSearchFailed,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 about [topic], in [_ring]: the question, where it goes
|
||||||
|
/// (Claude or the web, switched at the field's start and remembered), and
|
||||||
|
/// above it, where the topic has parts, a chip for each to send along;
|
||||||
|
/// none is chosen, so only what matters goes (decided 2026-10-10). Empty
|
||||||
|
/// again once sent.
|
||||||
|
///
|
||||||
|
/// With [onClose], a close button leads, as where the field unfolded from
|
||||||
|
/// a button.
|
||||||
|
class const AssistantField({
|
||||||
|
/// 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 sent.
|
||||||
|
final VoidCallback? onAsked,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<AssistantField> createState() => _AssistantFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssistantFieldState() extends State<AssistantField> {
|
||||||
|
final _question = TextEditingController();
|
||||||
|
|
||||||
|
/// The parts chosen to go along.
|
||||||
|
final _chosen = <AssistantPart>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_question.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parts [target] can take, in the topic's order.
|
||||||
|
List<AssistantPart> _offered(AssistantTarget target) => [
|
||||||
|
for (final part in widget.topic.parts)
|
||||||
|
if (target == AssistantTarget.claude || part.searchable) part,
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> _send(AssistantTarget target) async {
|
||||||
|
final offered = _offered(target);
|
||||||
|
final chosen = [
|
||||||
|
for (final part in offered)
|
||||||
|
if (_chosen.contains(part)) part,
|
||||||
|
];
|
||||||
|
if (_question.text.trim().isEmpty && chosen.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final question = _question.text;
|
||||||
|
_question.clear();
|
||||||
|
setState(_chosen.clear);
|
||||||
|
await sendQuestion(
|
||||||
|
context,
|
||||||
|
question: question,
|
||||||
|
chosen: chosen,
|
||||||
|
target: target,
|
||||||
|
);
|
||||||
|
widget.onAsked?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _switch(AssistantTarget target) =>
|
||||||
|
context.read<DeviceSettingsBloc>().add(
|
||||||
|
DeviceSettingsEventAssistantTargetChosen(switch (target) {
|
||||||
|
AssistantTarget.claude => AssistantTarget.web,
|
||||||
|
AssistantTarget.web => AssistantTarget.claude,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final target = context.select<DeviceSettingsBloc, AssistantTarget>(
|
||||||
|
(bloc) => bloc.state.assistantTarget,
|
||||||
|
);
|
||||||
|
final offered = _offered(target);
|
||||||
|
final hint = switch ((target, widget.topic)) {
|
||||||
|
(AssistantTarget.web, _) => l10n.assistantHintWeb,
|
||||||
|
(_, AssistantTopicLearning()) => l10n.assistantHint,
|
||||||
|
(_, AssistantTopicStudyset(:final title)) => l10n.assistantHintStudyset(
|
||||||
|
title,
|
||||||
|
),
|
||||||
|
(_, AssistantTopicCard()) => l10n.assistantHintCard,
|
||||||
|
};
|
||||||
|
final shape = offered.isEmpty
|
||||||
|
? const StadiumBorder()
|
||||||
|
: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24));
|
||||||
|
final row = Row(
|
||||||
|
children: [
|
||||||
|
if (widget.onClose case final close?)
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.assistantClose,
|
||||||
|
icon: const Icon(LucideIcons.x),
|
||||||
|
onPressed: close,
|
||||||
|
),
|
||||||
|
_TargetSwitch(target: target, onSwitch: () => _switch(target)),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _question,
|
||||||
|
autofocus: widget.autofocus,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 4,
|
||||||
|
textInputAction: switch (target) {
|
||||||
|
AssistantTarget.claude => TextInputAction.send,
|
||||||
|
AssistantTarget.web => TextInputAction.search,
|
||||||
|
},
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
onSubmitted: (_) => unawaited(_send(target)),
|
||||||
|
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: switch (target) {
|
||||||
|
AssistantTarget.claude => l10n.assistantSend,
|
||||||
|
AssistantTarget.web => l10n.assistantSendWeb,
|
||||||
|
},
|
||||||
|
icon: Icon(switch (target) {
|
||||||
|
AssistantTarget.claude => LucideIcons.arrowUp,
|
||||||
|
AssistantTarget.web => LucideIcons.search,
|
||||||
|
}),
|
||||||
|
onPressed:
|
||||||
|
_question.text.trim().isEmpty && !offered.any(_chosen.contains)
|
||||||
|
? null
|
||||||
|
: () => unawaited(_send(target)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: ShapeDecoration(shape: shape, gradient: _ring),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(_ringWidth),
|
||||||
|
child: Material(
|
||||||
|
shape: shape,
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: offered.isEmpty
|
||||||
|
? row
|
||||||
|
: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_Parts(
|
||||||
|
parts: offered,
|
||||||
|
chosen: _chosen,
|
||||||
|
onToggle: (part) => setState(
|
||||||
|
() => _chosen.contains(part)
|
||||||
|
? _chosen.remove(part)
|
||||||
|
: _chosen.add(part),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
row,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a question goes, at the field's start: Claude or the web; a tap
|
||||||
|
/// switches, and the field remembers it.
|
||||||
|
class const _TargetSwitch({
|
||||||
|
required final AssistantTarget target,
|
||||||
|
required final VoidCallback onSwitch,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Tooltip(
|
||||||
|
message: switch (target) {
|
||||||
|
AssistantTarget.claude => l10n.assistantSwitchToWeb,
|
||||||
|
AssistantTarget.web => l10n.assistantSwitchToClaude,
|
||||||
|
},
|
||||||
|
child: TextButton.icon(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
minimumSize: const Size(0, 40),
|
||||||
|
foregroundColor: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
onPressed: onSwitch,
|
||||||
|
icon: Icon(switch (target) {
|
||||||
|
AssistantTarget.claude => LucideIcons.sparkles,
|
||||||
|
AssistantTarget.web => LucideIcons.globe,
|
||||||
|
}, size: 18),
|
||||||
|
label: Text(switch (target) {
|
||||||
|
AssistantTarget.claude => l10n.assistantTargetClaude,
|
||||||
|
AssistantTarget.web => l10n.assistantTargetWeb,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parts that can go along, as chips in a row that scrolls sideways,
|
||||||
|
/// each with the start of its text.
|
||||||
|
class const _Parts({
|
||||||
|
required final List<AssistantPart> parts,
|
||||||
|
required final Set<AssistantPart> chosen,
|
||||||
|
required final ValueChanged<AssistantPart> onToggle,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
/// How much of a part's text its chip shows.
|
||||||
|
static const _preview = 24;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
String preview(String text) {
|
||||||
|
final line = text.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
return line.length <= _preview
|
||||||
|
? line
|
||||||
|
: '${line.substring(0, _preview - 1)}…';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final part in parts) ...[
|
||||||
|
FilterChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
label: Text(switch (part.kind) {
|
||||||
|
AssistantPartKind.front => l10n.assistantLineFront(
|
||||||
|
preview(part.text),
|
||||||
|
),
|
||||||
|
AssistantPartKind.back => l10n.assistantLineBack(
|
||||||
|
preview(part.text),
|
||||||
|
),
|
||||||
|
AssistantPartKind.hint => l10n.assistantLineHint(
|
||||||
|
part.number ?? 1,
|
||||||
|
preview(part.text),
|
||||||
|
),
|
||||||
|
AssistantPartKind.explanation =>
|
||||||
|
l10n.assistantLineExplanation(preview(part.text)),
|
||||||
|
AssistantPartKind.studyset => l10n.assistantChipStudyset(
|
||||||
|
preview(part.text),
|
||||||
|
),
|
||||||
|
AssistantPartKind.description =>
|
||||||
|
l10n.assistantLineDescription(preview(part.text)),
|
||||||
|
}),
|
||||||
|
selected: chosen.contains(part),
|
||||||
|
onSelected: (_) => onToggle(part),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Home's field at its foot, above the navigation: asks about the person's
|
||||||
|
/// learning. Nothing while asking is off.
|
||||||
|
class const AssistantBar({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 AssistantField(topic: AssistantTopicLearning()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round button that unfolds into [AssistantField], [expandedWidth] wide,
|
||||||
|
/// and folds back once sent 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 is off.
|
||||||
|
class const AssistantButton({
|
||||||
|
/// 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<AssistantButton> createState() => _AssistantButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssistantButtonState() extends State<AssistantButton> {
|
||||||
|
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: AssistantField(
|
||||||
|
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],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,29 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.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/assistant/model/search_engine.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';
|
||||||
|
|
||||||
/// Settings' Assistant card: whether this device asks Claude, off unless
|
/// Settings' Assistant card: whether this device asks Claude or searches
|
||||||
/// the person turns it on (decided 2026-10-10), and once on, connecting
|
/// the web from the app, off unless the person turns it on (decided
|
||||||
/// Claude to read the person's studysets (ADR 0006).
|
/// 2026-10-10); once on, which search engine a web search uses, and
|
||||||
|
/// connecting Claude to read the person's studysets (ADR 0006).
|
||||||
class const AssistantSettingsCard({super.key}) extends StatelessWidget {
|
class const AssistantSettingsCard({super.key}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final on = context.select<DeviceSettingsBloc, bool>(
|
final settings = context.watch<DeviceSettingsBloc>().state;
|
||||||
(bloc) => bloc.state.assistant,
|
final on = settings.assistant;
|
||||||
);
|
final engine = SearchEngine.byName(settings.searchEngine);
|
||||||
return SectionCard(
|
return SectionCard(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -39,7 +45,19 @@ class const AssistantSettingsCard({super.key}) extends StatelessWidget {
|
||||||
DeviceSettingsEventAssistantChosen(on),
|
DeviceSettingsEventAssistantChosen(on),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (on)
|
if (on) ...[
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(LucideIcons.globe),
|
||||||
|
title: Text(l10n.settingsSearchEngine),
|
||||||
|
subtitle: Text(
|
||||||
|
engine == SearchEngine.custom
|
||||||
|
? settings.customSearchUrl ?? l10n.searchEngineCustom
|
||||||
|
: searchEngineName(l10n, engine),
|
||||||
|
),
|
||||||
|
trailing: const Icon(LucideIcons.chevronRight),
|
||||||
|
onTap: () => unawaited(_chooseEngine(context, settings)),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(LucideIcons.plugZap),
|
leading: const Icon(LucideIcons.plugZap),
|
||||||
|
|
@ -48,8 +66,128 @@ class const AssistantSettingsCard({super.key}) extends StatelessWidget {
|
||||||
trailing: const Icon(LucideIcons.chevronRight),
|
trailing: const Icon(LucideIcons.chevronRight),
|
||||||
onTap: () => context.push(RoutePaths.connectAssistant),
|
onTap: () => context.push(RoutePaths.connectAssistant),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _chooseEngine(
|
||||||
|
BuildContext context,
|
||||||
|
DeviceSettings settings,
|
||||||
|
) async {
|
||||||
|
final bloc = context.read<DeviceSettingsBloc>();
|
||||||
|
final chosen = await showAdaptiveSheet<(SearchEngine, String?)>(
|
||||||
|
context,
|
||||||
|
builder: (context) => _EngineSheet(
|
||||||
|
engine: SearchEngine.byName(settings.searchEngine),
|
||||||
|
customUrl: settings.customSearchUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (chosen case (final engine, final customUrl)) {
|
||||||
|
bloc.add(
|
||||||
|
DeviceSettingsEventSearchEngineChosen(
|
||||||
|
engine == SearchEngine.platformDefault ? null : engine.name,
|
||||||
|
customUrl: customUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [engine] is called in the list.
|
||||||
|
String searchEngineName(AppLocalizations l10n, SearchEngine engine) =>
|
||||||
|
switch (engine) {
|
||||||
|
SearchEngine.device => l10n.searchEngineDevice,
|
||||||
|
SearchEngine.brave => 'Brave Search',
|
||||||
|
SearchEngine.ecosia => 'Ecosia',
|
||||||
|
SearchEngine.duckDuckGo => 'DuckDuckGo',
|
||||||
|
SearchEngine.startpage => 'Startpage',
|
||||||
|
SearchEngine.qwant => 'Qwant',
|
||||||
|
SearchEngine.google => 'Google',
|
||||||
|
SearchEngine.bing => 'Bing',
|
||||||
|
SearchEngine.custom => l10n.searchEngineCustom,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The engines this platform offers, the chosen one marked; your own
|
||||||
|
/// address takes one with `%s` where the question goes. Closes with the
|
||||||
|
/// choice.
|
||||||
|
class const _EngineSheet({
|
||||||
|
required final SearchEngine engine,
|
||||||
|
required final String? customUrl,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_EngineSheet> createState() => _EngineSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EngineSheetState() extends State<_EngineSheet> {
|
||||||
|
late var _engine = widget.engine;
|
||||||
|
late final _url = TextEditingController(text: widget.customUrl ?? '');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_url.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _choose(SearchEngine engine) {
|
||||||
|
if (engine == SearchEngine.custom) {
|
||||||
|
setState(() => _engine = engine);
|
||||||
|
} else {
|
||||||
|
Navigator.of(context).pop((engine, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final valid = SearchEngine.validTemplate(_url.text.trim());
|
||||||
|
return SheetScaffold(
|
||||||
|
title: l10n.settingsSearchEngine,
|
||||||
|
body: RadioGroup<SearchEngine>(
|
||||||
|
groupValue: _engine,
|
||||||
|
onChanged: (engine) => _choose(engine!),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (final engine in SearchEngine.values)
|
||||||
|
if (engine.availableHere)
|
||||||
|
RadioListTile<SearchEngine>(
|
||||||
|
value: engine,
|
||||||
|
title: Text(searchEngineName(l10n, engine)),
|
||||||
|
),
|
||||||
|
if (_engine == SearchEngine.custom)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: TextField(
|
||||||
|
controller: _url,
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: l10n.searchEngineCustomHint,
|
||||||
|
errorText: _url.text.trim().isEmpty || valid
|
||||||
|
? null
|
||||||
|
: l10n.searchEngineCustomInvalid,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
footer: _engine == SearchEngine.custom
|
||||||
|
? Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: valid
|
||||||
|
? () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop((SearchEngine.custom, _url.text.trim()))
|
||||||
|
: null,
|
||||||
|
child: Text(l10n.searchEngineCustomSave),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +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/assistant/widget/assistant_field.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';
|
||||||
|
|
@ -27,7 +27,7 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// 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]). Where the person asks Claude, a field to ask sits at the
|
/// ([TopGradient]). Where the person asks Claude, a field to ask sits at the
|
||||||
/// foot ([AskClaudeBar]).
|
/// foot ([AssistantBar]).
|
||||||
///
|
///
|
||||||
/// 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
|
||||||
|
|
@ -103,7 +103,7 @@ class _HomeState() extends State<_Home> {
|
||||||
DateTime? today,
|
DateTime? today,
|
||||||
) => Scaffold(
|
) => Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
bottomNavigationBar: _arranging ? null : const AskClaudeBar(),
|
bottomNavigationBar: _arranging ? null : const AssistantBar(),
|
||||||
appBar: _arranging
|
appBar: _arranging
|
||||||
? AppBar(
|
? AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.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/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/model/assistant_topic.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/ask_claude.dart';
|
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.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';
|
||||||
|
|
@ -647,7 +647,7 @@ class const _Showing(
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 28,
|
right: 28,
|
||||||
bottom: 24,
|
bottom: 24,
|
||||||
child: AskClaudeButton(
|
child: AssistantButton(
|
||||||
topic: AssistantTopicCard(
|
topic: AssistantTopicCard(
|
||||||
studysetTitle: title,
|
studysetTitle: title,
|
||||||
card: card.content,
|
card: card.content,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ 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/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/model/assistant_topic.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/ask_claude.dart';
|
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.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';
|
||||||
|
|
@ -154,7 +154,7 @@ class _LibraryState() extends State<_Library> {
|
||||||
return ScrollHidingFab(
|
return ScrollHidingFab(
|
||||||
// Asking Claude stands above Create studyset, smaller.
|
// Asking Claude stands above Create studyset, smaller.
|
||||||
fab: assistantOn(context)
|
fab: assistantOn(context)
|
||||||
? AskClaudeButton(
|
? AssistantButton(
|
||||||
topic: const AssistantTopicLearning(),
|
topic: const AssistantTopicLearning(),
|
||||||
small: create != null,
|
small: create != null,
|
||||||
below: create,
|
below: create,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue