feat(assistant)!: no hand-off and no connector; the field searches the web (ADR 0022)

The developer decided on 23 Oct to drop the hand-off to an AI assistant
outside the app, and Claude's connector with it, in v0.19.0, the app and
the server together: build 22 raises minimumAppBuild anyway, so no app
calling the connector's endpoints uses the instance after it. An instance
is to have one way to an assistant, its operator's (ADR 0021, proposed).

The app: the field at the foot of Home, and the button in the Library, on
a studyset and while studying, search the web alone, through the engine
chosen in Settings or Android's own search, with the parts of a card the
person picks; a studyset's name and description are never offered. Gone:
the assistants a question opened in (AiAssistant, AssistantHandOff), the
switch between them and the web, Settings' choice of assistant, Connect
Claude with its route, cubit, repository and gateway, and their texts. A
settings file from before keeps the old keys until the next change of a
setting writes it anew; nothing reads them.

The server: lib/src/assistant/ (MCP, its OAuth, the five tools, connect
codes and tokens), AssistantEndpoint, the daily forgetting of the
connector's secrets (its scheduled call cancelled at start), its lines in
the deletion hook, instance.info().assistantConnectorUrl (nullable, so an
older app reads it as none), and Caddy's forwarding of /mcp, /oauth/* and
/.well-known/oauth-*. The migration drops the connector's five tables,
children first:

  DROP TABLE "assistant_token" CASCADE;       -- tokens of a connection
  DROP TABLE "assistant_grant" CASCADE;       -- OAuth codes of a connection
  DROP TABLE "assistant_connection" CASCADE;  -- a person's connected client
  DROP TABLE "assistant_connect_code" CASCADE;
  DROP TABLE "assistant_client" CASCADE;      -- OAuth clients registered

No other table references them, so CASCADE drops only their own foreign
keys. For the minute between the maintenance run and the switch, the old
server answers the connector with errors and fails to delete an account
(its hook still deletes from these tables); ADR 0022 says so.

BREAKING CHANGE: a connected Claude gets no answer from the deploy on, and
the app no longer opens a question in an AI assistant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 15:33:53 +02:00
commit ca24eb9cf9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
34 changed files with 16550 additions and 10700 deletions

View file

@ -1817,55 +1817,23 @@
"feedbackNeedsIdentity": "Melde dich an, um im Original zu melden und vorzuschlagen.",
"kanjiExampleWords": "Wörter damit",
"settingsAssistant": "Assistent",
"settingsAskAssistant": "Einen KI-Assistenten fragen oder im Web suchen",
"settingsAskAssistantSubtitle": "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 dem KI-Assistenten, den du wählst, oder als Websuche, nur mit dem Zusammenhang, den du wählst; von hier aus wird nichts gesendet.",
"assistantHint": "Frag {assistant}…",
"assistantHintStudyset": "Frag {assistant} zu {title}…",
"assistantHintCard": "Frag {assistant} zu dieser Karte…",
"assistantSend": "In {assistant} fragen",
"assistantOpen": "Fragen oder suchen",
"settingsAssistant": "Websuche",
"settingsAskAssistant": "Aus der App im Web suchen",
"settingsAskAssistantSubtitle": "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 als Websuche, nur mit den Teilen einer Karteikarte, die du wählst; von hier aus wird nichts gesendet.",
"assistantOpen": "Im Web suchen",
"assistantClose": "Schließen",
"assistantOpenFailed": "{assistant} ließ sich nicht öffnen.",
"assistantConnectTitle": "Claude verbinden",
"assistantConnectSubtitle": "Claude deine Lernsets lesen lassen",
"assistantConnectIntro": "Claude kann über den Konnektor von Halkyon Learn deine Lernsets, Karten, Notizen und deinen Lernstand auf {instance} lesen und dazu antworten. Ändern kann es nichts. Was es liest, geht an Anthropic, unter deren Datenschutzerklärung.",
"assistantStepAdd": "Öffne in Claude Einstellungen › Konnektoren und füge einen eigenen Konnektor namens Halkyon Learn mit dieser Adresse hinzu. Einmal hinzugefügt, gilt er auch in den Apps von Claude.",
"assistantStepCode": "Claude öffnet dann eine Seite, die nach einem Code fragt. Hol ihn dir hier: Er gilt einmal, zehn Minuten lang.",
"assistantShowCode": "Code anzeigen",
"assistantNewCode": "Neuer Code",
"assistantCodeValid": "Gültig bis {time}",
"assistantCopy": "Kopieren",
"assistantCopied": "Kopiert",
"assistantConnected": "Verbunden",
"assistantNoneConnected": "Noch nichts verbunden.",
"assistantConnectedOn": "verbunden am {date}",
"assistantLastRead": "zuletzt gelesen am {date}",
"assistantDisconnect": "Trennen",
"assistantDisconnectTitle": "{name} trennen?",
"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 dem KI-Assistenten, den du gewählt hast, mit dem, worum es geht.",
"assistantUnreachable": "Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.",
"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",
"assistantTargetWeb": "Web",
"assistantSwitchToWeb": "Stattdessen im Web suchen",
"assistantSwitchToAssistant": "Stattdessen {assistant} fragen",
"assistantHintWeb": "Im Web suchen…",
"assistantSendWeb": "Suchen",
"assistantSearchFailed": "Die Suche ließ sich nicht öffnen.",
"settingsSearchEngine": "Suchmaschine",
"settingsAiAssistant": "KI-Assistent",
"aiAssistantCustomHint": "https://example.org/chat?q=%s",
"searchEngineDevice": "Suche des Geräts",
"searchEngineCustom": "Eigene Adresse",
"searchEngineCustomHint": "https://example.org/search?q=%s",
@ -2071,7 +2039,7 @@
"studysetPrivateTitle": "„{title}“ privat machen?",
"studysetPrivateBody": "Alles darin wird auf deinen Geräten Ende-zu-Ende-verschlüsselt: Die Instanz kann es weder lesen noch veröffentlichen noch Claude zeigen. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
"studysetReadableTitle": "„{title}“ nicht mehr privat?",
"studysetReadableBody": "Alles darin wird entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann: Du kannst es veröffentlichen, und Claude kann es über den Connector lesen.",
"studysetReadableBody": "Alles darin wird entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann: Du kannst es veröffentlichen.",
"studysetPrivateNoKeys": "Melde dich auf diesem Gerät mit deinem Passwort an, um Lernsets privat zu machen.",
"studysetPrivateSomeLocked": "Manches darin ließ sich auf diesem Gerät nicht öffnen und blieb, wie es war.",
"publishTagsSent": "Die Namen der Tags, die mitgehen und die die Instanz erfährt: {names}",
@ -2111,7 +2079,7 @@
"studysetPrivacyPrivateTitle": "„{title}“ ist privat",
"studysetPrivacyPrivateBody": "Alles darin ist Ende-zu-Ende-verschlüsselt: Nur deine Geräte können es lesen. Die Instanz kann es weder veröffentlichen noch Claude zeigen. Nicht mehr privat, wird es entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann.",
"studysetPrivacyReadableTitle": "„{title}“ ist nicht privat",
"studysetPrivacyReadableBody": "Die Instanz kann es lesen: Du kannst es veröffentlichen, und Claude kann es über den Connector lesen. Privat wird es auf deinen Geräten Ende-zu-Ende-verschlüsselt; Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
"studysetPrivacyReadableBody": "Die Instanz kann es lesen: Du kannst es veröffentlichen. Privat wird es auf deinen Geräten Ende-zu-Ende-verschlüsselt; Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
"studysetPrivacyTopic": "Seine Privatsphäre ist die des Lernsets, in dem es liegt.",
"studysetPrivacyPublish": "Veröffentlichen …"
}

View file

@ -2125,47 +2125,12 @@
"feedbackNeedsIdentity": "Sign in to report to the author and suggest.",
"kanjiExampleWords": "Words with it",
"settingsAssistant": "Assistant",
"settingsAskAssistant": "Ask an AI assistant or search the web",
"settingsAskAssistantSubtitle": "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 the AI assistant you choose or as a web search, with only the context you choose; nothing is sent from here.",
"assistantHint": "Ask {assistant}…",
"@assistantHint": {"placeholders": {"assistant": {"type": "String"}}},
"assistantHintStudyset": "Ask {assistant} about {title}…",
"@assistantHintStudyset": {"placeholders": {"assistant": {"type": "String"}, "title": {"type": "String"}}},
"assistantHintCard": "Ask {assistant} about this card…",
"@assistantHintCard": {"placeholders": {"assistant": {"type": "String"}}},
"assistantSend": "Ask in {assistant}",
"@assistantSend": {"placeholders": {"assistant": {"type": "String"}}},
"assistantOpen": "Ask or search",
"settingsAssistant": "Web search",
"settingsAskAssistant": "Search the web from the app",
"settingsAskAssistantSubtitle": "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 as a web search, with only the parts of a card you choose; nothing is sent from here.",
"assistantOpen": "Search the web",
"assistantClose": "Close",
"assistantOpenFailed": "{assistant} couldn't be opened.",
"@assistantOpenFailed": {"placeholders": {"assistant": {"type": "String"}}},
"assistantConnectTitle": "Connect Claude",
"assistantConnectSubtitle": "Let Claude read your studysets",
"assistantConnectIntro": "Claude can read your studysets, cards, notes and progress on {instance} through Halkyon Learn's connector, and answer about them. It can't change anything. What it reads goes to Anthropic, under their privacy policy.",
"@assistantConnectIntro": {"placeholders": {"instance": {"type": "String"}}},
"assistantStepAdd": "In Claude, open Settings › Connectors and add a custom connector named Halkyon Learn, with this address. Added once, it works in Claude's apps too.",
"assistantStepCode": "Claude then opens a page asking for a code. Get one here: it works once, for ten minutes.",
"assistantShowCode": "Show a code",
"assistantNewCode": "New code",
"assistantCodeValid": "Valid until {time}",
"@assistantCodeValid": {"placeholders": {"time": {"type": "String"}}},
"assistantCopy": "Copy",
"assistantCopied": "Copied",
"assistantConnected": "Connected",
"assistantNoneConnected": "Nothing connected yet.",
"assistantConnectedOn": "connected {date}",
"@assistantConnectedOn": {"placeholders": {"date": {"type": "String"}}},
"assistantLastRead": "last read {date}",
"@assistantLastRead": {"placeholders": {"date": {"type": "String"}}},
"assistantDisconnect": "Disconnect",
"assistantDisconnectTitle": "Disconnect {name}?",
"@assistantDisconnectTitle": {"placeholders": {"name": {"type": "String"}}},
"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 the AI assistant you chose, with what they are about.",
"assistantUnreachable": "The instance can't be reached. Try again once you are online.",
"assistantRetry": "Try again",
"assistantLineFront": "Front: {text}",
"@assistantLineFront": {"description": "A line of context handed to the AI assistant after the question.", "placeholders": {"text": {"type": "String"}}},
@ -2175,23 +2140,14 @@
"@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",
"assistantTargetWeb": "Web",
"assistantSwitchToWeb": "Search the web instead",
"assistantSwitchToAssistant": "Ask {assistant} instead",
"@assistantSwitchToAssistant": {"placeholders": {"assistant": {"type": "String"}}},
"assistantHintWeb": "Search the web…",
"assistantSendWeb": "Search",
"assistantSearchFailed": "The search couldn't be opened.",
"settingsSearchEngine": "Search engine",
"settingsAiAssistant": "AI assistant",
"aiAssistantCustomHint": "https://example.org/chat?q=%s",
"searchEngineDevice": "The device's search",
"searchEngineCustom": "Your own address",
"searchEngineCustomHint": "https://example.org/search?q=%s",
@ -2437,7 +2393,7 @@
"studysetPrivateBody": "Everything in it is end-to-end encrypted on your devices: the instance can't read it, publish it or show it to Claude. Copies in the instance's backups stay readable until those expire.",
"studysetReadableTitle": "Make “{title}” non-private?",
"@studysetReadableTitle": {"placeholders": {"title": {"type": "String"}}},
"studysetReadableBody": "Everything in it is decrypted and synced again, so the instance can read it: you can publish it, and Claude can read it through the connector.",
"studysetReadableBody": "Everything in it is decrypted and synced again, so the instance can read it: you can publish it.",
"studysetPrivateNoKeys": "Sign in with your password on this device to make studysets private.",
"studysetPrivateSomeLocked": "Some of it couldn't be opened on this device and stayed as it was.",
"publishTagsSent": "The names of the tags that go with it, which the instance learns: {names}",
@ -2479,7 +2435,7 @@
"studysetPrivacyPrivateBody": "Everything in it is end-to-end encrypted: only your devices can read it. The instance can't publish it, nor show it to Claude. Making it non-private decrypts it and syncs it again, so the instance can read it.",
"studysetPrivacyReadableTitle": "“{title}” isn't private",
"@studysetPrivacyReadableTitle": {"placeholders": {"title": {"type": "String"}}},
"studysetPrivacyReadableBody": "The instance can read it: you can publish it, and Claude can read it through the connector. Making it private encrypts it end to end on your devices; copies in the instance's backups stay readable until those expire.",
"studysetPrivacyReadableBody": "The instance can read it: you can publish it. Making it private encrypts it end to end on your devices; copies in the instance's backups stay readable until those expire.",
"studysetPrivacyTopic": "Its privacy is that of the studyset it's in.",
"studysetPrivacyPublish": "Publish…"
}

View file

@ -10740,49 +10740,25 @@ abstract class AppLocalizations {
/// No description provided for @settingsAssistant.
///
/// In en, this message translates to:
/// **'Assistant'**
/// **'Web search'**
String get settingsAssistant;
/// No description provided for @settingsAskAssistant.
///
/// In en, this message translates to:
/// **'Ask an AI assistant or search the web'**
/// **'Search the web from the app'**
String get settingsAskAssistant;
/// No description provided for @settingsAskAssistantSubtitle.
///
/// 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 the AI assistant you choose or as a web search, with only the context you choose; 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 as a web search, with only the parts of a card you choose; nothing is sent from here.'**
String get settingsAskAssistantSubtitle;
/// No description provided for @assistantHint.
///
/// In en, this message translates to:
/// **'Ask {assistant}…'**
String assistantHint(String assistant);
/// No description provided for @assistantHintStudyset.
///
/// In en, this message translates to:
/// **'Ask {assistant} about {title}…'**
String assistantHintStudyset(String assistant, String title);
/// No description provided for @assistantHintCard.
///
/// In en, this message translates to:
/// **'Ask {assistant} about this card…'**
String assistantHintCard(String assistant);
/// No description provided for @assistantSend.
///
/// In en, this message translates to:
/// **'Ask in {assistant}'**
String assistantSend(String assistant);
/// No description provided for @assistantOpen.
///
/// In en, this message translates to:
/// **'Ask or search'**
/// **'Search the web'**
String get assistantOpen;
/// No description provided for @assistantClose.
@ -10791,132 +10767,6 @@ abstract class AppLocalizations {
/// **'Close'**
String get assistantClose;
/// No description provided for @assistantOpenFailed.
///
/// In en, this message translates to:
/// **'{assistant} couldn\'t be opened.'**
String assistantOpenFailed(String assistant);
/// No description provided for @assistantConnectTitle.
///
/// In en, this message translates to:
/// **'Connect Claude'**
String get assistantConnectTitle;
/// No description provided for @assistantConnectSubtitle.
///
/// In en, this message translates to:
/// **'Let Claude read your studysets'**
String get assistantConnectSubtitle;
/// No description provided for @assistantConnectIntro.
///
/// In en, this message translates to:
/// **'Claude can read your studysets, cards, notes and progress on {instance} through Halkyon Learn\'s connector, and answer about them. It can\'t change anything. What it reads goes to Anthropic, under their privacy policy.'**
String assistantConnectIntro(String instance);
/// No description provided for @assistantStepAdd.
///
/// In en, this message translates to:
/// **'In Claude, open Settings › Connectors and add a custom connector named Halkyon Learn, with this address. Added once, it works in Claude\'s apps too.'**
String get assistantStepAdd;
/// No description provided for @assistantStepCode.
///
/// In en, this message translates to:
/// **'Claude then opens a page asking for a code. Get one here: it works once, for ten minutes.'**
String get assistantStepCode;
/// No description provided for @assistantShowCode.
///
/// In en, this message translates to:
/// **'Show a code'**
String get assistantShowCode;
/// No description provided for @assistantNewCode.
///
/// In en, this message translates to:
/// **'New code'**
String get assistantNewCode;
/// No description provided for @assistantCodeValid.
///
/// In en, this message translates to:
/// **'Valid until {time}'**
String assistantCodeValid(String time);
/// No description provided for @assistantCopy.
///
/// In en, this message translates to:
/// **'Copy'**
String get assistantCopy;
/// No description provided for @assistantCopied.
///
/// In en, this message translates to:
/// **'Copied'**
String get assistantCopied;
/// No description provided for @assistantConnected.
///
/// In en, this message translates to:
/// **'Connected'**
String get assistantConnected;
/// No description provided for @assistantNoneConnected.
///
/// In en, this message translates to:
/// **'Nothing connected yet.'**
String get assistantNoneConnected;
/// No description provided for @assistantConnectedOn.
///
/// In en, this message translates to:
/// **'connected {date}'**
String assistantConnectedOn(String date);
/// No description provided for @assistantLastRead.
///
/// In en, this message translates to:
/// **'last read {date}'**
String assistantLastRead(String date);
/// No description provided for @assistantDisconnect.
///
/// In en, this message translates to:
/// **'Disconnect'**
String get assistantDisconnect;
/// No description provided for @assistantDisconnectTitle.
///
/// In en, this message translates to:
/// **'Disconnect {name}?'**
String assistantDisconnectTitle(String name);
/// No description provided for @assistantDisconnectMessage.
///
/// In en, this message translates to:
/// **'It can\'t read your studysets any more. You can connect it again with a new code.'**
String get assistantDisconnectMessage;
/// No description provided for @assistantNotOffered.
///
/// In en, this message translates to:
/// **'This instance doesn\'t offer a connector yet. Questions still open in the AI assistant you chose, with what they are about.'**
String get assistantNotOffered;
/// No description provided for @assistantUnreachable.
///
/// In en, this message translates to:
/// **'The instance can\'t be reached. Try again once you are online.'**
String get assistantUnreachable;
/// No description provided for @assistantRetry.
///
/// In en, this message translates to:
/// **'Try again'**
String get assistantRetry;
/// A line of context handed to the AI assistant after the question.
///
/// In en, this message translates to:
@ -10941,12 +10791,6 @@ abstract class AppLocalizations {
/// **'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:
@ -10959,30 +10803,6 @@ abstract class AppLocalizations {
/// **'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 @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 @assistantSwitchToAssistant.
///
/// In en, this message translates to:
/// **'Ask {assistant} instead'**
String assistantSwitchToAssistant(String assistant);
/// No description provided for @assistantHintWeb.
///
/// In en, this message translates to:
@ -11007,18 +10827,6 @@ abstract class AppLocalizations {
/// **'Search engine'**
String get settingsSearchEngine;
/// No description provided for @settingsAiAssistant.
///
/// In en, this message translates to:
/// **'AI assistant'**
String get settingsAiAssistant;
/// No description provided for @aiAssistantCustomHint.
///
/// In en, this message translates to:
/// **'https://example.org/chat?q=%s'**
String get aiAssistantCustomHint;
/// No description provided for @searchEngineDevice.
///
/// In en, this message translates to:
@ -12216,7 +12024,7 @@ abstract class AppLocalizations {
/// No description provided for @studysetReadableBody.
///
/// In en, this message translates to:
/// **'Everything in it is decrypted and synced again, so the instance can read it: you can publish it, and Claude can read it through the connector.'**
/// **'Everything in it is decrypted and synced again, so the instance can read it: you can publish it.'**
String get studysetReadableBody;
/// No description provided for @studysetPrivateNoKeys.
@ -12438,7 +12246,7 @@ abstract class AppLocalizations {
/// No description provided for @studysetPrivacyReadableBody.
///
/// In en, this message translates to:
/// **'The instance can read it: you can publish it, and Claude can read it through the connector. Making it private encrypts it end to end on your devices; copies in the instance\'s backups stay readable until those expire.'**
/// **'The instance can read it: you can publish it. Making it private encrypts it end to end on your devices; copies in the instance\'s backups stay readable until those expire.'**
String get studysetPrivacyReadableBody;
/// No description provided for @studysetPrivacyTopic.

View file

@ -6718,122 +6718,21 @@ class AppLocalizationsDe extends AppLocalizations {
String get kanjiExampleWords => 'Wörter damit';
@override
String get settingsAssistant => 'Assistent';
String get settingsAssistant => 'Websuche';
@override
String get settingsAskAssistant =>
'Einen KI-Assistenten fragen oder im Web suchen';
String get settingsAskAssistant => 'Aus der App im Web suchen';
@override
String get settingsAskAssistantSubtitle =>
'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 dem KI-Assistenten, den du wählst, oder als Websuche, nur mit dem Zusammenhang, den du wählst; 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 als Websuche, nur mit den Teilen einer Karteikarte, die du wählst; von hier aus wird nichts gesendet.';
@override
String assistantHint(String assistant) {
return 'Frag $assistant…';
}
@override
String assistantHintStudyset(String assistant, String title) {
return 'Frag $assistant zu $title…';
}
@override
String assistantHintCard(String assistant) {
return 'Frag $assistant zu dieser Karte…';
}
@override
String assistantSend(String assistant) {
return 'In $assistant fragen';
}
@override
String get assistantOpen => 'Fragen oder suchen';
String get assistantOpen => 'Im Web suchen';
@override
String get assistantClose => 'Schließen';
@override
String assistantOpenFailed(String assistant) {
return '$assistant ließ sich nicht öffnen.';
}
@override
String get assistantConnectTitle => 'Claude verbinden';
@override
String get assistantConnectSubtitle => 'Claude deine Lernsets lesen lassen';
@override
String assistantConnectIntro(String instance) {
return 'Claude kann über den Konnektor von Halkyon Learn deine Lernsets, Karten, Notizen und deinen Lernstand auf $instance lesen und dazu antworten. Ändern kann es nichts. Was es liest, geht an Anthropic, unter deren Datenschutzerklärung.';
}
@override
String get assistantStepAdd =>
'Öffne in Claude Einstellungen › Konnektoren und füge einen eigenen Konnektor namens Halkyon Learn mit dieser Adresse hinzu. Einmal hinzugefügt, gilt er auch in den Apps von Claude.';
@override
String get assistantStepCode =>
'Claude öffnet dann eine Seite, die nach einem Code fragt. Hol ihn dir hier: Er gilt einmal, zehn Minuten lang.';
@override
String get assistantShowCode => 'Code anzeigen';
@override
String get assistantNewCode => 'Neuer Code';
@override
String assistantCodeValid(String time) {
return 'Gültig bis $time';
}
@override
String get assistantCopy => 'Kopieren';
@override
String get assistantCopied => 'Kopiert';
@override
String get assistantConnected => 'Verbunden';
@override
String get assistantNoneConnected => 'Noch nichts verbunden.';
@override
String assistantConnectedOn(String date) {
return 'verbunden am $date';
}
@override
String assistantLastRead(String date) {
return 'zuletzt gelesen am $date';
}
@override
String get assistantDisconnect => 'Trennen';
@override
String assistantDisconnectTitle(String name) {
return '$name trennen?';
}
@override
String get assistantDisconnectMessage =>
'Es kann deine Lernsets nicht mehr lesen. Mit einem neuen Code kannst du es wieder verbinden.';
@override
String get assistantNotOffered =>
'Diese Instanz bietet noch keinen Konnektor an. Fragen öffnen sich trotzdem in dem KI-Assistenten, den du gewählt hast, mit dem, worum es geht.';
@override
String get assistantUnreachable =>
'Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.';
@override
String get assistantRetry => 'Erneut versuchen';
@override
String assistantLineFront(String text) {
return 'Vorderseite: $text';
@ -6854,11 +6753,6 @@ class AppLocalizationsDe extends AppLocalizations {
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';
@ -6869,20 +6763,6 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Lernset: $title';
}
@override
String get assistantContextLabel => 'Mitsenden';
@override
String get assistantTargetWeb => 'Web';
@override
String get assistantSwitchToWeb => 'Stattdessen im Web suchen';
@override
String assistantSwitchToAssistant(String assistant) {
return 'Stattdessen $assistant fragen';
}
@override
String get assistantHintWeb => 'Im Web suchen…';
@ -6895,12 +6775,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsSearchEngine => 'Suchmaschine';
@override
String get settingsAiAssistant => 'KI-Assistent';
@override
String get aiAssistantCustomHint => 'https://example.org/chat?q=%s';
@override
String get searchEngineDevice => 'Suche des Geräts';
@ -7745,7 +7619,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetReadableBody =>
'Alles darin wird entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann: Du kannst es veröffentlichen, und Claude kann es über den Connector lesen.';
'Alles darin wird entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann: Du kannst es veröffentlichen.';
@override
String get studysetPrivateNoKeys =>
@ -7877,7 +7751,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetPrivacyReadableBody =>
'Die Instanz kann es lesen: Du kannst es veröffentlichen, und Claude kann es über den Connector lesen. Privat wird es auf deinen Geräten Ende-zu-Ende-verschlüsselt; Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.';
'Die Instanz kann es lesen: Du kannst es veröffentlichen. Privat wird es auf deinen Geräten Ende-zu-Ende-verschlüsselt; Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.';
@override
String get studysetPrivacyTopic =>

View file

@ -6655,121 +6655,21 @@ class AppLocalizationsEn extends AppLocalizations {
String get kanjiExampleWords => 'Words with it';
@override
String get settingsAssistant => 'Assistant';
String get settingsAssistant => 'Web search';
@override
String get settingsAskAssistant => 'Ask an AI assistant or search the web';
String get settingsAskAssistant => 'Search the web from the app';
@override
String get settingsAskAssistantSubtitle =>
'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 the AI assistant you choose or as a web search, with only the context you choose; 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 as a web search, with only the parts of a card you choose; nothing is sent from here.';
@override
String assistantHint(String assistant) {
return 'Ask $assistant…';
}
@override
String assistantHintStudyset(String assistant, String title) {
return 'Ask $assistant about $title…';
}
@override
String assistantHintCard(String assistant) {
return 'Ask $assistant about this card…';
}
@override
String assistantSend(String assistant) {
return 'Ask in $assistant';
}
@override
String get assistantOpen => 'Ask or search';
String get assistantOpen => 'Search the web';
@override
String get assistantClose => 'Close';
@override
String assistantOpenFailed(String assistant) {
return '$assistant couldn\'t be opened.';
}
@override
String get assistantConnectTitle => 'Connect Claude';
@override
String get assistantConnectSubtitle => 'Let Claude read your studysets';
@override
String assistantConnectIntro(String instance) {
return 'Claude can read your studysets, cards, notes and progress on $instance through Halkyon Learn\'s connector, and answer about them. It can\'t change anything. What it reads goes to Anthropic, under their privacy policy.';
}
@override
String get assistantStepAdd =>
'In Claude, open Settings › Connectors and add a custom connector named Halkyon Learn, with this address. Added once, it works in Claude\'s apps too.';
@override
String get assistantStepCode =>
'Claude then opens a page asking for a code. Get one here: it works once, for ten minutes.';
@override
String get assistantShowCode => 'Show a code';
@override
String get assistantNewCode => 'New code';
@override
String assistantCodeValid(String time) {
return 'Valid until $time';
}
@override
String get assistantCopy => 'Copy';
@override
String get assistantCopied => 'Copied';
@override
String get assistantConnected => 'Connected';
@override
String get assistantNoneConnected => 'Nothing connected yet.';
@override
String assistantConnectedOn(String date) {
return 'connected $date';
}
@override
String assistantLastRead(String date) {
return 'last read $date';
}
@override
String get assistantDisconnect => 'Disconnect';
@override
String assistantDisconnectTitle(String name) {
return 'Disconnect $name?';
}
@override
String get assistantDisconnectMessage =>
'It can\'t read your studysets any more. You can connect it again with a new code.';
@override
String get assistantNotOffered =>
'This instance doesn\'t offer a connector yet. Questions still open in the AI assistant you chose, with what they are about.';
@override
String get assistantUnreachable =>
'The instance can\'t be reached. Try again once you are online.';
@override
String get assistantRetry => 'Try again';
@override
String assistantLineFront(String text) {
return 'Front: $text';
@ -6790,11 +6690,6 @@ class AppLocalizationsEn extends AppLocalizations {
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';
@ -6805,20 +6700,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Studyset: $title';
}
@override
String get assistantContextLabel => 'Send along';
@override
String get assistantTargetWeb => 'Web';
@override
String get assistantSwitchToWeb => 'Search the web instead';
@override
String assistantSwitchToAssistant(String assistant) {
return 'Ask $assistant instead';
}
@override
String get assistantHintWeb => 'Search the web…';
@ -6831,12 +6712,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsSearchEngine => 'Search engine';
@override
String get settingsAiAssistant => 'AI assistant';
@override
String get aiAssistantCustomHint => 'https://example.org/chat?q=%s';
@override
String get searchEngineDevice => 'The device\'s search';
@ -7672,7 +7547,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetReadableBody =>
'Everything in it is decrypted and synced again, so the instance can read it: you can publish it, and Claude can read it through the connector.';
'Everything in it is decrypted and synced again, so the instance can read it: you can publish it.';
@override
String get studysetPrivateNoKeys =>
@ -7804,7 +7679,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetPrivacyReadableBody =>
'The instance can read it: you can publish it, and Claude can read it through the connector. Making it private encrypts it end to end on your devices; copies in the instance\'s backups stay readable until those expire.';
'The instance can read it: you can publish it. Making it private encrypts it end to end on your devices; copies in the instance\'s backups stay readable until those expire.';
@override
String get studysetPrivacyTopic =>

View file

@ -7,7 +7,6 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/connect_claude_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart';
@ -322,13 +321,6 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
GoRoute(
path: RoutePaths.settings,
builder: (context, state) => const FramedScreen(child: SettingsScreen()),
routes: [
GoRoute(
path: 'assistant',
builder: (context, state) =>
const FramedScreen(child: ConnectClaudeScreen()),
),
],
),
],
);

View file

@ -23,9 +23,6 @@ import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_hand_off.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repository.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/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
@ -175,15 +172,9 @@ class Dependencies({
/// What the system shows of the Pomodoro timer, and its alerts.
required final PomodoroPlatform pomodoro,
/// Hands questions to the AI assistant the person chose.
required final AssistantHandOff assistantHandOff,
/// Searches the web for questions.
required final WebSearch webSearch,
/// Connects assistants to the account on the instance (ADR 0006).
required final AssistantRepository assistant,
/// The person's own Jellyfin server, to watch and learn from: the
/// device's, kept across instances.
required final JellyfinRepository jellyfin,
@ -287,9 +278,7 @@ class Dependencies({
deviceSettings: await device.deviceSettingsStore.read(),
appVersion: appVersion,
pomodoro: device.pomodoro,
assistantHandOff: AssistantHandOff(),
webSearch: WebSearch(),
assistant: AssistantRepository(ServerpodAssistantGateway(client)),
jellyfin: device.jellyfin,
jellyfinSuggestion: JellyfinSuggestion(
() async => (await client.instance.info()).jellyfinUrl,

View file

@ -30,21 +30,12 @@ class DeviceSettingsBloc(
on<DeviceSettingsEventAssistantChosen>(
(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<DeviceSettingsEventAiAssistantChosen>(
(event, emit) => _apply(
emit,
state.withAiAssistant(event.name, customUrl: event.customUrl),
),
);
on<DeviceSettingsEventPdfSidebarDragged>(
(event, emit) => _apply(emit, state.withPdfSidebarShare(event.share)),
);
@ -105,21 +96,13 @@ final class const DeviceSettingsEventPomodoroWhileStudyingChosen(
List<Object?> get props => [shown];
}
/// The person turned asking an assistant [on] or off.
/// The person turned searching the web from the app [on] or off.
final class const DeviceSettingsEventAssistantChosen(final bool on)
extends DeviceSettingsEvent {
@override
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(
@ -130,16 +113,6 @@ final class const DeviceSettingsEventSearchEngineChosen(
List<Object?> get props => [name, customUrl];
}
/// The person chose the AI assistant [name] (null for the default), at
/// [customUrl] for a custom one.
final class const DeviceSettingsEventAiAssistantChosen(
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
/// ([detailed]) or as a grid.
final class const DeviceSettingsEventBrowserViewChosen(

View file

@ -51,13 +51,9 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
if (json['detailedBrowsers'] case final List<Object?> ids)
for (final id in ids.whereType<String>()) id,
},
// A file from before ADR 0022 also holds `assistantTarget`,
// `aiAssistant` and `customAssistantUrl`, which nothing reads.
assistant: json['assistant'] == true,
// Asking Claude was the only assistant before 2026-10-14
// (`claude`).
assistantTarget: switch (json['assistantTarget']) {
'web' => AssistantTarget.web,
_ => AssistantTarget.assistant,
},
searchEngine: switch (json['searchEngine']) {
final String name => name,
_ => null,
@ -66,14 +62,6 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
final String url => url,
_ => null,
},
aiAssistant: switch (json['aiAssistant']) {
final String name => name,
_ => null,
},
customAssistantUrl: switch (json['customAssistantUrl']) {
final String url => url,
_ => null,
},
pdfSidebarShare: switch (json['pdfSidebarShare']) {
final num share when share > 0 && share < 1 => share.toDouble(),
_ => null,
@ -97,11 +85,8 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'pomodoroWhileStudying': settings.pomodoroWhileStudying,
'detailedBrowsers': [...settings.detailedBrowsers],
'assistant': settings.assistant,
'assistantTarget': settings.assistantTarget.name,
'searchEngine': ?settings.searchEngine,
'customSearchUrl': ?settings.customSearchUrl,
'aiAssistant': ?settings.aiAssistant,
'customAssistantUrl': ?settings.customAssistantUrl,
'pdfSidebarShare': ?settings.pdfSidebarShare,
}),
flush: true,

View file

@ -8,19 +8,11 @@ enum Appearance() {
dark,
}
/// Where a question asked from the app goes: to the AI assistant the
/// person chose, or into a web search; the field remembers the last choice
/// (decided 2026-10-10).
enum AssistantTarget() {
assistant,
web,
}
/// 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,
/// whether studying shows the Pomodoro timer, which studysets' card
/// browsers show one card at a time, and whether and how the person asks
/// an AI assistant or searches the web from the app.
/// browsers show one card at a time, and whether and how the person
/// searches the web from the app.
/// Unlike the person's settings, they don't follow anyone to other devices,
/// and they apply before anyone is signed in.
final class const DeviceSettings({
@ -40,22 +32,12 @@ final class const DeviceSettings({
/// list (detail) rather than a grid, as last left.
final Set<String> detailedBrowsers = const {},
/// Whether the app offers to ask an AI assistant or search the web
/// (decided 2026-10-10): a field on Home, a button in the Library, on a
/// studyset and while studying. Off unless the person turns it on.
/// Whether the app offers to search the web (decided 2026-10-10): a
/// field on Home, a button in the Library, on a studyset and while
/// studying. Off unless the person turns it on. It also asked an AI
/// assistant outside the app until ADR 0022, hence its name.
final bool assistant = false,
/// Where the field sends a question, as last chosen.
final AssistantTarget assistantTarget = AssistantTarget.assistant,
/// The AI assistant a question opens in, by its name in the assistant's
/// list; null for the default, Proton's Lumo (decided 2026-10-14).
final String? aiAssistant,
/// The address a custom assistant opens, with `%s` for the question;
/// null without.
final String? customAssistantUrl,
/// 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).
@ -93,24 +75,14 @@ final class const DeviceSettings({
: ({...detailedBrowsers}..remove(studysetId)),
);
/// These settings with asking an assistant or searching the web [on] or
/// off.
/// These settings with searching the web from the app [on] or off.
DeviceSettings withAssistant(bool on) => _copy(assistant: on);
/// These settings with questions going to [target].
DeviceSettings withAssistantTarget(AssistantTarget target) =>
_copy(assistantTarget: target);
/// 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);
/// These settings asking the assistant [name] (null for the default), at
/// [customUrl] for a custom one.
DeviceSettings withAiAssistant(String? name, {String? customUrl}) =>
_copy(aiAssistant: name, customAssistantUrl: customUrl);
/// These settings with the editor beside a PDF taking [share] of the
/// room.
DeviceSettings withPdfSidebarShare(double share) =>
@ -123,11 +95,8 @@ final class const DeviceSettings({
bool? pomodoroWhileStudying,
Set<String>? detailedBrowsers,
bool? assistant,
AssistantTarget? assistantTarget,
Object? searchEngine = _keep,
Object? customSearchUrl = _keep,
Object? aiAssistant = _keep,
Object? customAssistantUrl = _keep,
double? pdfSidebarShare,
}) => DeviceSettings(
appearance: appearance ?? this.appearance,
@ -136,19 +105,12 @@ final class const DeviceSettings({
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?,
aiAssistant: identical(aiAssistant, _keep)
? this.aiAssistant
: aiAssistant as String?,
customAssistantUrl: identical(customAssistantUrl, _keep)
? this.customAssistantUrl
: customAssistantUrl as String?,
pdfSidebarShare: pdfSidebarShare ?? this.pdfSidebarShare,
);
@ -160,11 +122,8 @@ final class const DeviceSettings({
pomodoroWhileStudying,
detailedBrowsers,
assistant,
assistantTarget,
searchEngine,
customSearchUrl,
aiAssistant,
customAssistantUrl,
pdfSidebarShare,
];
}

View file

@ -16,9 +16,6 @@ abstract final class RoutePaths() {
/// Settings, over the tabs.
static const settings = '/settings';
/// Connecting Claude to the account (Settings › Assistant), over Settings.
static const connectAssistant = '/settings/assistant';
/// The reading deadlines on a line of days, over the tabs.
static const readingTimeline = '/reading-timeline';

View file

@ -1,111 +0,0 @@
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
/// Connecting Claude to the person's account: where the instance's
/// connector is, a code to type on Claude's page, and what is connected.
class ConnectAssistantCubit(final AssistantRepository _assistant)
extends Cubit<ConnectAssistantState> {
this : super(const ConnectAssistantState());
/// Asks the instance where its connector is and what is connected.
Future<void> load() async {
emit(state.copyWith(loading: true, failed: false));
try {
final url = await _assistant.connectorUrl();
final connections = url == null
? const <ConnectedAssistant>[]
: await _assistant.connections();
_emit(
state.copyWith(
loading: false,
offered: url != null,
connectorUrl: url,
connections: connections,
),
);
} on Exception catch (error) {
log('Asking about the connector failed', name: 'Assistant', error: error);
_emit(state.copyWith(loading: false, failed: true));
}
}
/// Gets a new code; the one shown before stops working.
Future<void> newCode() async {
try {
final code = await _assistant.connectCode();
_emit(state.copyWith(code: code, failed: false));
} on Exception catch (error) {
log('Getting a code failed', name: 'Assistant', error: error);
_emit(state.copyWith(failed: true));
}
}
/// Disconnects [assistant], then lists what is still connected.
Future<void> disconnect(ConnectedAssistant assistant) async {
try {
await _assistant.disconnect(assistant.id);
_emit(state.copyWith(connections: await _assistant.connections()));
} on Exception catch (error) {
log('Disconnecting failed', name: 'Assistant', error: error);
_emit(state.copyWith(failed: true));
}
}
void _emit(ConnectAssistantState next) {
if (!isClosed) {
emit(next);
}
}
}
/// What the screen shows of connecting Claude.
final class const ConnectAssistantState({
/// Whether the instance is being asked.
final bool loading = true,
/// Whether the instance offers a connector.
final bool offered = false,
/// The connector's address, to add in Claude.
final String? connectorUrl,
/// The assistants connected.
final List<ConnectedAssistant> connections = const [],
/// The last code got; null before.
final ConnectCode? code,
/// Whether the last request to the instance failed.
final bool failed = false,
}) with Equatable {
/// This state with the given fields replaced.
ConnectAssistantState copyWith({
bool? loading,
bool? offered,
String? connectorUrl,
List<ConnectedAssistant>? connections,
ConnectCode? code,
bool? failed,
}) => ConnectAssistantState(
loading: loading ?? this.loading,
offered: offered ?? this.offered,
connectorUrl: connectorUrl ?? this.connectorUrl,
connections: connections ?? this.connections,
code: code ?? this.code,
failed: failed ?? this.failed,
);
@override
List<Object?> get props => [
loading,
offered,
connectorUrl,
connections,
code,
failed,
];
}

View file

@ -1,17 +0,0 @@
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
/// The instance's side of connecting assistants (ADR 0006).
abstract interface class AssistantGateway() {
/// Where assistants connect on this instance (its MCP endpoint); null
/// where it offers none.
Future<String?> connectorUrl();
/// A new code for connecting an assistant; the last one stops working.
Future<ConnectCode> connectCode();
/// The assistants connected to the person's account, newest first.
Future<List<ConnectedAssistant>> connections();
/// Disconnects the assistant [id]: it can't read anything from then on.
Future<void> disconnect(int id);
}

View file

@ -1,55 +0,0 @@
import 'package:halkyon_learn_flutter/src/feature/assistant/model/ai_assistant.dart';
import 'package:url_launcher/url_launcher.dart';
/// Hands a question to the AI assistant the person chose (decided
/// 2026-10-10, more than Claude since 2026-10-14): opens a new
/// conversation on its site with the question written in, for the person
/// to read and send. An assistant's own app opens it where it handles the
/// site's links, a browser elsewhere.
///
/// Halkyon Learn can't answer inside the app with someone's plan: no
/// assistant lets another app sign in with it, and API keys stay out
/// (their pricing is opaque and open to abuse). Claude reads the person's
/// studysets instead through the instance's connector, which the person
/// adds to Claude once (`ConnectClaudeScreen`).
class AssistantHandOff({
final Future<bool> Function(Uri url) _open = _openOutside,
}) {
/// The link that opens [prompt] at [assistant] ([customUrl] for a custom
/// one), cut to what it takes; Lumo's for a custom address that won't do.
static Uri urlFor(
String prompt,
AiAssistant assistant, {
String? customUrl,
}) {
final (template, max) = switch (assistant) {
AiAssistant.custom
when customUrl != null && AiAssistant.validTemplate(customUrl) =>
(customUrl, AiAssistant.custom.maxPromptLength),
AiAssistant.custom => (
AiAssistant.lumo.template!,
AiAssistant.lumo.maxPromptLength,
),
_ => (assistant.template!, assistant.maxPromptLength),
};
// Encoded as a component, so it reads alike in a query and in a
// fragment (Lumo's).
return Uri.parse(
template.replaceAll('%s', Uri.encodeComponent(clip(prompt, max))),
);
}
/// [prompt], cut to [max] with an ellipsis where it was cut.
static String clip(String prompt, int max) =>
prompt.length <= max ? prompt : '${prompt.substring(0, max - 1)}…';
/// Opens [prompt] at [assistant]; false if nothing could open it.
Future<bool> ask(
String prompt, {
required AiAssistant assistant,
String? customUrl,
}) => _open(urlFor(prompt, assistant, customUrl: customUrl));
}
Future<bool> _openOutside(Uri url) =>
launchUrl(url, mode: LaunchMode.externalApplication);

View file

@ -1,18 +0,0 @@
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
/// Connecting assistants such as Claude to the person's account on the
/// instance, so they can read their studysets (ADR 0006).
class AssistantRepository(final AssistantGateway _gateway) {
/// Where assistants connect on this instance; null where it offers none.
Future<String?> connectorUrl() => _gateway.connectorUrl();
/// A new code to type on an assistant's authorization page.
Future<ConnectCode> connectCode() => _gateway.connectCode();
/// The assistants connected, newest first.
Future<List<ConnectedAssistant>> connections() => _gateway.connections();
/// Disconnects the assistant [id].
Future<void> disconnect(int id) => _gateway.disconnect(id);
}

View file

@ -1,32 +0,0 @@
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
/// [AssistantGateway] through the Serverpod client.
class ServerpodAssistantGateway(final protocol.Client _client)
implements AssistantGateway {
@override
Future<String?> connectorUrl() async =>
(await _client.instance.info()).assistantConnectorUrl;
@override
Future<ConnectCode> connectCode() async {
final code = await _client.assistant.connectCode();
return (code: code.code, expiresAt: code.expiresAt);
}
@override
Future<List<ConnectedAssistant>> connections() async => [
for (final connection in await _client.assistant.connections())
ConnectedAssistant(
id: connection.id,
name: connection.name,
host: connection.host,
connectedAt: connection.connectedAt,
lastUsedAt: connection.lastUsedAt,
),
];
@override
Future<void> disconnect(int id) => _client.assistant.disconnect(id);
}

View file

@ -1,113 +0,0 @@
/// The AI assistants a question asked from the app can open in (decided
/// 2026-10-14), each by the address that opens a new conversation with the
/// question written in, `%s` where it goes, and how long a question it
/// takes. The app never answers itself: the person reads the question
/// there and sends it (Perplexity answers at once, as a search; Grok asks
/// first). Only assistants whose address fills in a question are offered:
/// Gemini has none, and Copilot's stopped filling it in (checked
/// 2026-10-14).
enum AiAssistant(
/// What it is called, as its maker names it; none for [custom], which
/// its address names.
final String? title,
/// Who makes it.
final String? maker,
/// The address, with `%s` for the question; none for [custom].
final String? template, {
/// The longest question it takes: what its page reads, or what a link
/// through the services in front of it may carry.
final int maxPromptLength = 8000,
}) {
/// Proton's Lumo, the default: its guest chat, which needs no account
/// (the address for one's own account loses the question when nobody is
/// signed in). In the address's fragment, which reaches no server, as
/// Lumo's page reads it (`prefill`, undocumented, checked 2026-10-14).
lumo(
'Lumo',
'Proton',
'https://lumo.proton.me/guest#prefill=%s',
maxPromptLength: 12000,
),
/// OpenAI's ChatGPT (`prompt` fills in; `q` would send).
chatGpt('ChatGPT', 'OpenAI', 'https://chatgpt.com/?prompt=%s'),
/// Anthropic's Claude, which cuts what it is given at about 14,000
/// characters.
claude(
'Claude',
'Anthropic',
'https://claude.ai/new?q=%s',
maxPromptLength: 12000,
),
/// Mistral's chat.
mistral(
'Mistral',
'Mistral AI',
'https://chat.mistral.ai/chat?q=%s',
maxPromptLength: 12000,
),
/// Perplexity, which answers at once, as its search does.
perplexity(
'Perplexity',
'Perplexity',
'https://www.perplexity.ai/search?q=%s',
),
/// DuckDuckGo's Duck.ai.
duckAi('Duck.ai', 'DuckDuckGo', 'https://duck.ai/?q=%s'),
/// xAI's Grok, which asks before sending.
grok('Grok', 'xAI', 'https://grok.com/?q=%s'),
/// Kagi Assistant, for Kagi's members (`qvalue` fills in; `q` would
/// send).
kagi(
'Kagi Assistant',
'Kagi',
'https://kagi.com/assistant?qvalue=%s',
maxPromptLength: 12000,
),
/// Hugging Face's HuggingChat (`prompt` fills in).
huggingChat(
'HuggingChat',
'Hugging Face',
'https://huggingface.co/chat/?prompt=%s',
),
/// An address of the person's own, with `%s` for the question.
custom(null, null, null);
/// What it is called in the app's hints and buttons: its [title], a
/// custom one by the site of its address ([customUrl]), Lumo's for one
/// whose address won't do (as a question then opens in Lumo).
String titleWith(String? customUrl) => switch (this) {
custom when customUrl != null && validTemplate(customUrl) =>
Uri.parse(customUrl.replaceAll('%s', 'q')).host.replaceFirst(
RegExp(r'^www\.'),
'',
),
custom => lumo.title!,
_ => title!,
};
/// The assistant stored as [name]; Lumo for none or an unknown one.
static AiAssistant byName(String? name) =>
AiAssistant.values.asNameMap()[name] ?? lumo;
/// Whether [template] is an address a custom assistant can open: 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;
}
}

View file

@ -1,11 +1,11 @@
import 'package:equatable/equatable.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';
/// What a question asked from the app can be about, and the parts of it
/// the person may send along (decided 2026-10-10): offered as chips, none
/// chosen, so only what matters is sent and no more.
/// chosen, so only what matters is sent and no more. A web search takes
/// only those [AssistantPart.searchable].
sealed class const AssistantTopic() {
/// The parts that can go along, in the order offered.
List<AssistantPart> get parts;
@ -104,40 +104,6 @@ final class const AssistantPart({
List<Object?> get props => [kind, text, number];
}
/// The text handed to the AI assistant: [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(
AppLocalizations l10n, {
required String question,
required List<AssistantPart> chosen,
required String instance,
}) {
final lines = [
for (final part in chosen)
switch (part.kind) {
AssistantPartKind.front => l10n.assistantLineFront(part.sent),
AssistantPartKind.back => l10n.assistantLineBack(part.sent),
AssistantPartKind.hint => l10n.assistantLineHint(
part.number ?? 1,
part.sent,
),
AssistantPartKind.explanation => l10n.assistantLineExplanation(
part.sent,
),
AssistantPartKind.studyset => l10n.assistantLineStudyset(
part.sent,
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) => [

View file

@ -1,27 +0,0 @@
import 'package:equatable/equatable.dart';
/// An assistant connected to the person's account on the instance (ADR
/// 0006), as Settings lists it to disconnect.
final class const ConnectedAssistant({
/// The connection's id on the instance.
required final int id,
/// What the assistant calls itself, e.g. "Claude".
required final String name,
/// Where it sent the person back to on connecting, e.g. `claude.ai`.
required final String host,
/// When the person connected it.
required final DateTime connectedAt,
/// When it last read something; null before.
final DateTime? lastUsedAt,
}) with Equatable {
@override
List<Object?> get props => [id, name, host, connectedAt, lastUsedAt];
}
/// A code for connecting an assistant, as the instance gave it: typed on
/// the assistant's authorization page, once, until `expiresAt`.
typedef ConnectCode = ({String code, DateTime expiresAt});

View file

@ -4,65 +4,41 @@ 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.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/ai_assistant.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:halkyon_ui/halkyon_ui.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.
/// Whether the person turned on searching the web from the app on this
/// device (Settings › Web search); 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]: the AI assistant the person chose, or a web search with the
/// device's chosen engine. Says so if nothing could open it.
/// Searches the web for [question] with the parts [chosen] of what it is
/// about, 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 = Dependencies.of(context);
final settings = context.read<DeviceSettingsBloc>().state;
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final assistant = AiAssistant.byName(settings.aiAssistant);
final opened = switch (target) {
AssistantTarget.assistant => await dependencies.assistantHandOff.ask(
assistantPrompt(
l10n,
question: question,
chosen: chosen,
instance: dependencies.serverName,
),
assistant: assistant,
customUrl: settings.customAssistantUrl,
),
AssistantTarget.web => await dependencies.webSearch.search(
searchQuery(question, chosen),
engine: SearchEngine.byName(settings.searchEngine),
customUrl: settings.customSearchUrl,
),
};
final opened = await Dependencies.of(context).webSearch.search(
searchQuery(question, chosen),
engine: SearchEngine.byName(settings.searchEngine),
customUrl: settings.customSearchUrl,
);
if (!opened) {
messenger.showSnackBar(
SnackBar(
content: Text(switch (target) {
AssistantTarget.assistant => l10n.assistantOpenFailed(
assistant.titleWith(settings.customAssistantUrl),
),
AssistantTarget.web => l10n.assistantSearchFailed,
}),
),
SnackBar(content: Text(l10n.assistantSearchFailed)),
);
}
}
/// The ring around the assistant's field and button: cyan through blue to
/// The ring around the search'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)],
@ -71,12 +47,10 @@ const _ring = LinearGradient(
/// How thick [_ring] is.
const _ringWidth = 2.0;
/// A field to ask about [topic], in [_ring]: the question, where it goes
/// (the AI assistant chosen in Settings 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.
/// A field to search the web about [topic], in [_ring]: the question, and
/// above it, where the topic has parts a search takes, 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.
@ -110,14 +84,14 @@ class _AssistantFieldState() extends State<AssistantField> {
super.dispose();
}
/// The parts [target] can take, in the topic's order.
List<AssistantPart> _offered(AssistantTarget target) => [
/// The parts a web search can take, in the topic's order.
List<AssistantPart> get _offered => [
for (final part in widget.topic.parts)
if (target == AssistantTarget.assistant || part.searchable) part,
if (part.searchable) part,
];
Future<void> _send(AssistantTarget target) async {
final offered = _offered(target);
Future<void> _send() async {
final offered = _offered;
final chosen = [
for (final part in offered)
if (_chosen.contains(part)) part,
@ -128,45 +102,15 @@ class _AssistantFieldState() extends State<AssistantField> {
final question = _question.text;
_question.clear();
setState(_chosen.clear);
await sendQuestion(
context,
question: question,
chosen: chosen,
target: target,
);
await sendQuestion(context, question: question, chosen: chosen);
widget.onAsked?.call();
}
void _switch(AssistantTarget target) =>
context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventAssistantTargetChosen(switch (target) {
AssistantTarget.assistant => AssistantTarget.web,
AssistantTarget.web => AssistantTarget.assistant,
}),
);
@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 assistant = context.select<DeviceSettingsBloc, String>(
(bloc) => AiAssistant.byName(
bloc.state.aiAssistant,
).titleWith(bloc.state.customAssistantUrl),
);
final offered = _offered(target);
final hint = switch ((target, widget.topic)) {
(AssistantTarget.web, _) => l10n.assistantHintWeb,
(_, AssistantTopicLearning()) => l10n.assistantHint(assistant),
(_, AssistantTopicStudyset(:final title)) => l10n.assistantHintStudyset(
assistant,
title,
),
(_, AssistantTopicCard()) => l10n.assistantHintCard(assistant),
};
final offered = _offered;
final shape = offered.isEmpty
? const StadiumBorder()
: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24));
@ -178,10 +122,16 @@ class _AssistantFieldState() extends State<AssistantField> {
icon: const Icon(LucideIcons.x),
onPressed: close,
),
_TargetSwitch(
target: target,
assistant: assistant,
onSwitch: () => _switch(target),
Padding(
padding: EdgeInsets.only(
left: widget.onClose == null ? 10 : 0,
right: 8,
),
child: Icon(
LucideIcons.globe,
size: 18,
color: theme.colorScheme.primary,
),
),
Expanded(
child: TextField(
@ -191,14 +141,11 @@ class _AssistantFieldState() extends State<AssistantField> {
autofocus: widget.autofocus,
minLines: 1,
maxLines: 4,
textInputAction: switch (target) {
AssistantTarget.assistant => TextInputAction.send,
AssistantTarget.web => TextInputAction.search,
},
textInputAction: TextInputAction.search,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => unawaited(_send(target)),
onSubmitted: (_) => unawaited(_send()),
decoration: InputDecoration(
hintText: hint,
hintText: l10n.assistantHintWeb,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
@ -210,18 +157,12 @@ class _AssistantFieldState() extends State<AssistantField> {
ListenableBuilder(
listenable: _question,
builder: (context, _) => IconButton.filled(
tooltip: switch (target) {
AssistantTarget.assistant => l10n.assistantSend(assistant),
AssistantTarget.web => l10n.assistantSendWeb,
},
icon: Icon(switch (target) {
AssistantTarget.assistant => LucideIcons.arrowUp,
AssistantTarget.web => LucideIcons.search,
}),
tooltip: l10n.assistantSendWeb,
icon: const Icon(LucideIcons.search),
onPressed:
_question.text.trim().isEmpty && !offered.any(_chosen.contains)
? null
: () => unawaited(_send(target)),
: () => unawaited(_send()),
),
),
],
@ -265,43 +206,6 @@ class _AssistantFieldState() extends State<AssistantField> {
}
}
/// Where a question goes, at the field's start: the AI assistant chosen,
/// named [assistant], or the web; a tap switches, and the field remembers
/// it.
class const _TargetSwitch({
required final AssistantTarget target,
required final String assistant,
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.assistant => l10n.assistantSwitchToWeb,
AssistantTarget.web => l10n.assistantSwitchToAssistant(assistant),
},
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.assistant => LucideIcons.sparkles,
AssistantTarget.web => LucideIcons.globe,
}, size: 18),
label: Text(switch (target) {
AssistantTarget.assistant => assistant,
AssistantTarget.web => l10n.assistantTargetWeb,
}),
),
);
}
}
/// The parts that can go along, as chips flowing onto as many lines as
/// they need, each showing as much of its text as its line holds; the whole
/// of it on hovering or a long press.
@ -374,8 +278,8 @@ class const _Parts({
}
}
/// Home's field at its foot, above the navigation: asks about the person's
/// learning. Nothing while asking is off.
/// Home's field at its foot, above the navigation: searches the web about
/// the person's learning. Nothing while searching from the app is off.
class const AssistantBar({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -399,13 +303,13 @@ class const AssistantBar({super.key}) extends StatelessWidget {
}
/// A round button that unfolds into [AssistantField], [expandedWidth] wide,
/// and folds back once sent or closed: the assistant's floating button in
/// and folds back once sent or closed: the web search's floating button in
/// the Library, on a studyset and while studying, at the lower left
/// (decided 2026-10-16, as Vaia has it). [below] (a studyset's bar) stands
/// under it, [end] (the Library's Create studyset) at the other end of its
/// row; either makes way while it is unfolded.
///
/// Nothing (or only [below]) while asking is off.
/// Nothing (or only [below]) while searching from the app is off.
class const AssistantButton({
/// What questions here are about.
required final AssistantTopic topic,
@ -477,7 +381,7 @@ class _AssistantButtonState() extends State<AssistantButton> {
child: SizedBox.square(
dimension: size - 2 * _ringWidth,
child: Icon(
LucideIcons.sparkles,
LucideIcons.search,
color: theme.colorScheme.primary,
size: widget.small ? 20 : 24,
),

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