feat(learn): the assistant answers in the field

The field asks an assistant the instance offers as well as searching the
web (ADR 0021): who answers is picked at its start, the one asked last
first (askWith, a device setting), an assistant not agreed to yet asking
for agreement first. Asking an assistant, every part of the topic may go
along, and one chip leads them with how far it may look (D49), only what
is chosen until the person widens it to the studyset, its tree or the
library, private studysets only where switched on (D50, D8). The
conversation shows in a sheet over the screen (D47), half the height and
dragged up to the full: each question with what went along, the lookups,
the answer as Markdown once whole, follow-ups, Stop, and Try again for a
question that got no answer; a device that can't seal says it keeps
nothing (D52). The field lists the conversations kept (D48), those about
where the person is first, to open again or delete.

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-25 20:30:17 +01:00
commit 806ffae202
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 2312 additions and 72 deletions

View file

@ -1817,6 +1817,45 @@
"assistantHintWeb": "Im Web suchen…",
"assistantSendWeb": "Suchen",
"assistantSearchFailed": "Die Suche ließ sich nicht öffnen.",
"assistantChoose": "Wer antwortet",
"assistantWebSearch": "Websuche",
"assistantChooseAgree": "Erst lesen und zustimmen",
"assistantChooseOwn": "Mit deinem Schlüssel",
"assistantHintAsk": "{name} fragen…",
"assistantSendAsk": "Fragen",
"assistantScope": "Was er nachschlagen darf",
"assistantScopeTicked": "Nur was ich auswähle",
"assistantScopeStudyset": "Dieses Lernset",
"assistantScopeTree": "Ganzer Baum",
"assistantScopeLibrary": "Bibliothek",
"assistantScopeTickedLong": "Nur was ich oben auswähle",
"assistantScopeStudysetLong": "Dieses Lernset mit seinen Themen",
"assistantScopeTreeLong": "Sein ganzer Baum, von oben",
"assistantScopeLibraryLong": "Meine ganze Bibliothek",
"assistantScopePrivate": "Private Lernsets einbeziehen",
"assistantScopeWithPrivate": "{scope}, auch private",
"assistantHistory": "Unterhaltungen",
"assistantHistoryEmpty": "Noch keine Unterhaltungen",
"assistantHistoryEmptyHint": "Frag einen Assistenten im Feld unten.",
"assistantHistoryDelete": "Unterhaltung löschen",
"assistantHistoryDeleteMessage": "„{title}“ verschwindet von allen deinen Geräten.",
"assistantHistoryDeleteConfirm": "Löschen",
"assistantGone": "Ein nicht mehr angebotener Assistent",
"assistantGoneNote": "Dieser Assistent wird dir hier nicht mehr angeboten, also lässt sich die Unterhaltung nicht fortsetzen.",
"assistantNotKept": "Nicht gespeichert: Dieses Gerät kann Unterhaltungen nicht verschlüsseln, also ist diese weg, sobald du sie schließt.",
"assistantFollowUp": "Weiter fragen…",
"assistantStop": "Anhalten",
"assistantAnswering": "Antwortet…",
"assistantStopped": "Vor der Antwort angehalten.",
"assistantStoppedEarly": "Er hat zu oft nachgeschlagen und ohne Antwort aufgehört.",
"assistantNoAnswer": "Keine Antwort.",
"assistantCopyAnswer": "Antwort kopieren",
"assistantCopied": "Antwort kopiert",
"assistantLookedUpStudysets": "{count, plural, =0{Keine Lernsets} =1{1 Lernset} other{{count} Lernsets}} aufgelistet",
"assistantLookedUpCards": "Karten nach „{query}“ durchsucht: {count, plural, =0{keine gefunden} =1{1 gefunden} other{{count} gefunden}}",
"assistantLookedUpNotes": "Notizen nach „{query}“ durchsucht: {count, plural, =0{keine gefunden} =1{1 gefunden} other{{count} gefunden}}",
"assistantLookedUpNote": "Eine Notiz gelesen",
"assistantLookedUpNoNote": "Eine Notiz gesucht, keine gefunden",
"settingsSearchEngine": "Suchmaschine",
"searchEngineDevice": "Suche des Geräts",
"searchEngineCustom": "Eigene Adresse",

View file

@ -2131,6 +2131,53 @@
"assistantHintWeb": "Search the web…",
"assistantSendWeb": "Search",
"assistantSearchFailed": "The search couldn't be opened.",
"assistantChoose": "Who answers",
"assistantWebSearch": "Web search",
"assistantChooseAgree": "Read and agree first",
"@assistantChooseAgree": {"description": "Under an assistant in the field's menu that the person hasn't agreed to yet."},
"assistantChooseOwn": "With your key",
"assistantHintAsk": "Ask {name}…",
"@assistantHintAsk": {"placeholders": {"name": {"type": "String"}}},
"assistantSendAsk": "Ask",
"assistantScope": "What it may look up",
"@assistantScope": {"description": "Tooltip of the scope chip: how far an assistant may look beyond the parts chosen."},
"assistantScopeTicked": "Only what I choose",
"assistantScopeStudyset": "This studyset",
"assistantScopeTree": "Its tree",
"assistantScopeLibrary": "Library",
"assistantScopeTickedLong": "Only what I choose above",
"assistantScopeStudysetLong": "This studyset with its topics",
"assistantScopeTreeLong": "Its whole tree, from the top",
"assistantScopeLibraryLong": "My whole library",
"assistantScopePrivate": "Include private studysets",
"assistantScopeWithPrivate": "{scope}, private ones too",
"@assistantScopeWithPrivate": {"placeholders": {"scope": {"type": "String"}}},
"assistantHistory": "Conversations",
"assistantHistoryEmpty": "No conversations yet",
"assistantHistoryEmptyHint": "Ask an assistant in the field below.",
"assistantHistoryDelete": "Delete conversation",
"assistantHistoryDeleteMessage": "“{title}” goes from all your devices.",
"@assistantHistoryDeleteMessage": {"placeholders": {"title": {"type": "String"}}},
"assistantHistoryDeleteConfirm": "Delete",
"assistantGone": "An assistant no longer offered",
"assistantGoneNote": "This assistant is no longer offered to you here, so the conversation can't go on.",
"assistantNotKept": "Not kept: this device can't encrypt conversations, so this one is gone once you close it.",
"assistantFollowUp": "Ask more…",
"assistantStop": "Stop",
"assistantAnswering": "Answering…",
"assistantStopped": "Stopped before it answered.",
"assistantStoppedEarly": "It looked things up too often and stopped without an answer.",
"assistantNoAnswer": "No answer.",
"assistantCopyAnswer": "Copy the answer",
"assistantCopied": "Answer copied",
"assistantLookedUpStudysets": "Listed {count, plural, =0{no studysets} =1{1 studyset} other{{count} studysets}}",
"@assistantLookedUpStudysets": {"placeholders": {"count": {"type": "int"}}},
"assistantLookedUpCards": "Looked for “{query}” in cards: {count, plural, =0{none found} =1{1 found} other{{count} found}}",
"@assistantLookedUpCards": {"placeholders": {"query": {"type": "String"}, "count": {"type": "int"}}},
"assistantLookedUpNotes": "Looked for “{query}” in notes: {count, plural, =0{none found} =1{1 found} other{{count} found}}",
"@assistantLookedUpNotes": {"placeholders": {"query": {"type": "String"}, "count": {"type": "int"}}},
"assistantLookedUpNote": "Read a note",
"assistantLookedUpNoNote": "Looked for a note, found none",
"settingsSearchEngine": "Search engine",
"searchEngineDevice": "The device's search",
"searchEngineCustom": "Your own address",

View file

@ -10719,6 +10719,240 @@ abstract class AppLocalizations {
/// **'The search couldn\'t be opened.'**
String get assistantSearchFailed;
/// No description provided for @assistantChoose.
///
/// In en, this message translates to:
/// **'Who answers'**
String get assistantChoose;
/// No description provided for @assistantWebSearch.
///
/// In en, this message translates to:
/// **'Web search'**
String get assistantWebSearch;
/// Under an assistant in the field's menu that the person hasn't agreed to yet.
///
/// In en, this message translates to:
/// **'Read and agree first'**
String get assistantChooseAgree;
/// No description provided for @assistantChooseOwn.
///
/// In en, this message translates to:
/// **'With your key'**
String get assistantChooseOwn;
/// No description provided for @assistantHintAsk.
///
/// In en, this message translates to:
/// **'Ask {name}…'**
String assistantHintAsk(String name);
/// No description provided for @assistantSendAsk.
///
/// In en, this message translates to:
/// **'Ask'**
String get assistantSendAsk;
/// Tooltip of the scope chip: how far an assistant may look beyond the parts chosen.
///
/// In en, this message translates to:
/// **'What it may look up'**
String get assistantScope;
/// No description provided for @assistantScopeTicked.
///
/// In en, this message translates to:
/// **'Only what I choose'**
String get assistantScopeTicked;
/// No description provided for @assistantScopeStudyset.
///
/// In en, this message translates to:
/// **'This studyset'**
String get assistantScopeStudyset;
/// No description provided for @assistantScopeTree.
///
/// In en, this message translates to:
/// **'Its tree'**
String get assistantScopeTree;
/// No description provided for @assistantScopeLibrary.
///
/// In en, this message translates to:
/// **'Library'**
String get assistantScopeLibrary;
/// No description provided for @assistantScopeTickedLong.
///
/// In en, this message translates to:
/// **'Only what I choose above'**
String get assistantScopeTickedLong;
/// No description provided for @assistantScopeStudysetLong.
///
/// In en, this message translates to:
/// **'This studyset with its topics'**
String get assistantScopeStudysetLong;
/// No description provided for @assistantScopeTreeLong.
///
/// In en, this message translates to:
/// **'Its whole tree, from the top'**
String get assistantScopeTreeLong;
/// No description provided for @assistantScopeLibraryLong.
///
/// In en, this message translates to:
/// **'My whole library'**
String get assistantScopeLibraryLong;
/// No description provided for @assistantScopePrivate.
///
/// In en, this message translates to:
/// **'Include private studysets'**
String get assistantScopePrivate;
/// No description provided for @assistantScopeWithPrivate.
///
/// In en, this message translates to:
/// **'{scope}, private ones too'**
String assistantScopeWithPrivate(String scope);
/// No description provided for @assistantHistory.
///
/// In en, this message translates to:
/// **'Conversations'**
String get assistantHistory;
/// No description provided for @assistantHistoryEmpty.
///
/// In en, this message translates to:
/// **'No conversations yet'**
String get assistantHistoryEmpty;
/// No description provided for @assistantHistoryEmptyHint.
///
/// In en, this message translates to:
/// **'Ask an assistant in the field below.'**
String get assistantHistoryEmptyHint;
/// No description provided for @assistantHistoryDelete.
///
/// In en, this message translates to:
/// **'Delete conversation'**
String get assistantHistoryDelete;
/// No description provided for @assistantHistoryDeleteMessage.
///
/// In en, this message translates to:
/// **'“{title}” goes from all your devices.'**
String assistantHistoryDeleteMessage(String title);
/// No description provided for @assistantHistoryDeleteConfirm.
///
/// In en, this message translates to:
/// **'Delete'**
String get assistantHistoryDeleteConfirm;
/// No description provided for @assistantGone.
///
/// In en, this message translates to:
/// **'An assistant no longer offered'**
String get assistantGone;
/// No description provided for @assistantGoneNote.
///
/// In en, this message translates to:
/// **'This assistant is no longer offered to you here, so the conversation can\'t go on.'**
String get assistantGoneNote;
/// No description provided for @assistantNotKept.
///
/// In en, this message translates to:
/// **'Not kept: this device can\'t encrypt conversations, so this one is gone once you close it.'**
String get assistantNotKept;
/// No description provided for @assistantFollowUp.
///
/// In en, this message translates to:
/// **'Ask more…'**
String get assistantFollowUp;
/// No description provided for @assistantStop.
///
/// In en, this message translates to:
/// **'Stop'**
String get assistantStop;
/// No description provided for @assistantAnswering.
///
/// In en, this message translates to:
/// **'Answering…'**
String get assistantAnswering;
/// No description provided for @assistantStopped.
///
/// In en, this message translates to:
/// **'Stopped before it answered.'**
String get assistantStopped;
/// No description provided for @assistantStoppedEarly.
///
/// In en, this message translates to:
/// **'It looked things up too often and stopped without an answer.'**
String get assistantStoppedEarly;
/// No description provided for @assistantNoAnswer.
///
/// In en, this message translates to:
/// **'No answer.'**
String get assistantNoAnswer;
/// No description provided for @assistantCopyAnswer.
///
/// In en, this message translates to:
/// **'Copy the answer'**
String get assistantCopyAnswer;
/// No description provided for @assistantCopied.
///
/// In en, this message translates to:
/// **'Answer copied'**
String get assistantCopied;
/// No description provided for @assistantLookedUpStudysets.
///
/// In en, this message translates to:
/// **'Listed {count, plural, =0{no studysets} =1{1 studyset} other{{count} studysets}}'**
String assistantLookedUpStudysets(int count);
/// No description provided for @assistantLookedUpCards.
///
/// In en, this message translates to:
/// **'Looked for “{query}” in cards: {count, plural, =0{none found} =1{1 found} other{{count} found}}'**
String assistantLookedUpCards(String query, int count);
/// No description provided for @assistantLookedUpNotes.
///
/// In en, this message translates to:
/// **'Looked for “{query}” in notes: {count, plural, =0{none found} =1{1 found} other{{count} found}}'**
String assistantLookedUpNotes(String query, int count);
/// No description provided for @assistantLookedUpNote.
///
/// In en, this message translates to:
/// **'Read a note'**
String get assistantLookedUpNote;
/// No description provided for @assistantLookedUpNoNote.
///
/// In en, this message translates to:
/// **'Looked for a note, found none'**
String get assistantLookedUpNoNote;
/// No description provided for @settingsSearchEngine.
///
/// In en, this message translates to:

View file

@ -6716,6 +6716,160 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get assistantSearchFailed => 'Die Suche ließ sich nicht öffnen.';
@override
String get assistantChoose => 'Wer antwortet';
@override
String get assistantWebSearch => 'Websuche';
@override
String get assistantChooseAgree => 'Erst lesen und zustimmen';
@override
String get assistantChooseOwn => 'Mit deinem Schlüssel';
@override
String assistantHintAsk(String name) {
return '$name fragen…';
}
@override
String get assistantSendAsk => 'Fragen';
@override
String get assistantScope => 'Was er nachschlagen darf';
@override
String get assistantScopeTicked => 'Nur was ich auswähle';
@override
String get assistantScopeStudyset => 'Dieses Lernset';
@override
String get assistantScopeTree => 'Ganzer Baum';
@override
String get assistantScopeLibrary => 'Bibliothek';
@override
String get assistantScopeTickedLong => 'Nur was ich oben auswähle';
@override
String get assistantScopeStudysetLong => 'Dieses Lernset mit seinen Themen';
@override
String get assistantScopeTreeLong => 'Sein ganzer Baum, von oben';
@override
String get assistantScopeLibraryLong => 'Meine ganze Bibliothek';
@override
String get assistantScopePrivate => 'Private Lernsets einbeziehen';
@override
String assistantScopeWithPrivate(String scope) {
return '$scope, auch private';
}
@override
String get assistantHistory => 'Unterhaltungen';
@override
String get assistantHistoryEmpty => 'Noch keine Unterhaltungen';
@override
String get assistantHistoryEmptyHint =>
'Frag einen Assistenten im Feld unten.';
@override
String get assistantHistoryDelete => 'Unterhaltung löschen';
@override
String assistantHistoryDeleteMessage(String title) {
return '„$title“ verschwindet von allen deinen Geräten.';
}
@override
String get assistantHistoryDeleteConfirm => 'Löschen';
@override
String get assistantGone => 'Ein nicht mehr angebotener Assistent';
@override
String get assistantGoneNote =>
'Dieser Assistent wird dir hier nicht mehr angeboten, also lässt sich die Unterhaltung nicht fortsetzen.';
@override
String get assistantNotKept =>
'Nicht gespeichert: Dieses Gerät kann Unterhaltungen nicht verschlüsseln, also ist diese weg, sobald du sie schließt.';
@override
String get assistantFollowUp => 'Weiter fragen…';
@override
String get assistantStop => 'Anhalten';
@override
String get assistantAnswering => 'Antwortet…';
@override
String get assistantStopped => 'Vor der Antwort angehalten.';
@override
String get assistantStoppedEarly =>
'Er hat zu oft nachgeschlagen und ohne Antwort aufgehört.';
@override
String get assistantNoAnswer => 'Keine Antwort.';
@override
String get assistantCopyAnswer => 'Antwort kopieren';
@override
String get assistantCopied => 'Antwort kopiert';
@override
String assistantLookedUpStudysets(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Lernsets',
one: '1 Lernset',
zero: 'Keine Lernsets',
);
return '$_temp0 aufgelistet';
}
@override
String assistantLookedUpCards(String query, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count gefunden',
one: '1 gefunden',
zero: 'keine gefunden',
);
return 'Karten nach „$query“ durchsucht: $_temp0';
}
@override
String assistantLookedUpNotes(String query, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count gefunden',
one: '1 gefunden',
zero: 'keine gefunden',
);
return 'Notizen nach „$query“ durchsucht: $_temp0';
}
@override
String get assistantLookedUpNote => 'Eine Notiz gelesen';
@override
String get assistantLookedUpNoNote => 'Eine Notiz gesucht, keine gefunden';
@override
String get settingsSearchEngine => 'Suchmaschine';

View file

@ -6652,6 +6652,160 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get assistantSearchFailed => 'The search couldn\'t be opened.';
@override
String get assistantChoose => 'Who answers';
@override
String get assistantWebSearch => 'Web search';
@override
String get assistantChooseAgree => 'Read and agree first';
@override
String get assistantChooseOwn => 'With your key';
@override
String assistantHintAsk(String name) {
return 'Ask $name…';
}
@override
String get assistantSendAsk => 'Ask';
@override
String get assistantScope => 'What it may look up';
@override
String get assistantScopeTicked => 'Only what I choose';
@override
String get assistantScopeStudyset => 'This studyset';
@override
String get assistantScopeTree => 'Its tree';
@override
String get assistantScopeLibrary => 'Library';
@override
String get assistantScopeTickedLong => 'Only what I choose above';
@override
String get assistantScopeStudysetLong => 'This studyset with its topics';
@override
String get assistantScopeTreeLong => 'Its whole tree, from the top';
@override
String get assistantScopeLibraryLong => 'My whole library';
@override
String get assistantScopePrivate => 'Include private studysets';
@override
String assistantScopeWithPrivate(String scope) {
return '$scope, private ones too';
}
@override
String get assistantHistory => 'Conversations';
@override
String get assistantHistoryEmpty => 'No conversations yet';
@override
String get assistantHistoryEmptyHint =>
'Ask an assistant in the field below.';
@override
String get assistantHistoryDelete => 'Delete conversation';
@override
String assistantHistoryDeleteMessage(String title) {
return '“$title” goes from all your devices.';
}
@override
String get assistantHistoryDeleteConfirm => 'Delete';
@override
String get assistantGone => 'An assistant no longer offered';
@override
String get assistantGoneNote =>
'This assistant is no longer offered to you here, so the conversation can\'t go on.';
@override
String get assistantNotKept =>
'Not kept: this device can\'t encrypt conversations, so this one is gone once you close it.';
@override
String get assistantFollowUp => 'Ask more…';
@override
String get assistantStop => 'Stop';
@override
String get assistantAnswering => 'Answering…';
@override
String get assistantStopped => 'Stopped before it answered.';
@override
String get assistantStoppedEarly =>
'It looked things up too often and stopped without an answer.';
@override
String get assistantNoAnswer => 'No answer.';
@override
String get assistantCopyAnswer => 'Copy the answer';
@override
String get assistantCopied => 'Answer copied';
@override
String assistantLookedUpStudysets(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count studysets',
one: '1 studyset',
zero: 'no studysets',
);
return 'Listed $_temp0';
}
@override
String assistantLookedUpCards(String query, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count found',
one: '1 found',
zero: 'none found',
);
return 'Looked for “$query” in cards: $_temp0';
}
@override
String assistantLookedUpNotes(String query, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count found',
one: '1 found',
zero: 'none found',
);
return 'Looked for “$query” in notes: $_temp0';
}
@override
String get assistantLookedUpNote => 'Read a note';
@override
String get assistantLookedUpNoNote => 'Looked for a note, found none';
@override
String get settingsSearchEngine => 'Search engine';

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/learn_theme.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/settings_own_assistants.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_presenter.dart';
@ -103,6 +104,17 @@ class _PersonAppState() extends State<PersonApp> {
Dependencies.of(context).instanceOperators,
),
),
// The assistants the person may ask, for every field
// asking (ADR 0021): read once, and anew after an
// agreement.
BlocProvider(
create: (context) => AssistantsCubit(
Dependencies.of(context).assistants,
ownAssistants: SettingsOwnAssistants(
PersonDependencies.of(context).settings,
),
)..load(),
),
// The Pomodoro timer goes on across every screen.
BlocProvider(
create: (context) => PomodoroBloc(

View file

@ -36,6 +36,9 @@ class DeviceSettingsBloc(
state.withSearchEngine(event.name, customUrl: event.customUrl),
),
);
on<DeviceSettingsEventAskedWith>(
(event, emit) => _apply(emit, state.withAskWith(event.key)),
);
on<DeviceSettingsEventPdfSidebarDragged>(
(event, emit) => _apply(emit, state.withPdfSidebarShare(event.share)),
);
@ -113,6 +116,13 @@ final class const DeviceSettingsEventSearchEngineChosen(
List<Object?> get props => [name, customUrl];
}
/// The field's question went to [key] (`DeviceSettings.askWith`).
final class const DeviceSettingsEventAskedWith(final String key)
extends DeviceSettingsEvent {
@override
List<Object?> get props => [key];
}
/// The person shows the card browser of [studysetId] one card at a time
/// ([detailed]) or as a grid.
final class const DeviceSettingsEventBrowserViewChosen(

View file

@ -66,6 +66,10 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
final num share when share > 0 && share < 1 => share.toDouble(),
_ => null,
},
askWith: switch (json['askWith']) {
final String key => key,
_ => null,
},
);
} on FormatException catch (error) {
log('The device settings are unreadable', name: 'Settings', error: error);
@ -88,6 +92,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'searchEngine': ?settings.searchEngine,
'customSearchUrl': ?settings.customSearchUrl,
'pdfSidebarShare': ?settings.pdfSidebarShare,
'askWith': ?settings.askWith,
}),
flush: true,
);

View file

@ -11,8 +11,8 @@ enum Appearance() {
/// 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
/// searches the web from the app.
/// browsers show one card at a time, whether and how the person searches
/// the web from the app, and who answered the field last.
/// 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({
@ -50,6 +50,12 @@ final class const DeviceSettings({
/// How much of the room beside a PDF its editor takes, from 0 to 1, as
/// last dragged; null for the reader's default width.
final double? pdfSidebarShare,
/// Who answered the field's last question, whom it offers first: `web`
/// for the web search, `assistant:<id>` for an assistant the operator
/// granted, `own:<id>` for one with the person's own key (ADR 0021);
/// null before the first.
final String? askWith,
}) with Equatable {
/// These settings with [appearance] instead.
DeviceSettings withAppearance(Appearance appearance) =>
@ -88,6 +94,9 @@ final class const DeviceSettings({
DeviceSettings withPdfSidebarShare(double share) =>
_copy(pdfSidebarShare: share);
/// These settings with [key] having answered last ([askWith]).
DeviceSettings withAskWith(String key) => _copy(askWith: key);
DeviceSettings _copy({
Appearance? appearance,
Object? language = _keep,
@ -98,6 +107,7 @@ final class const DeviceSettings({
Object? searchEngine = _keep,
Object? customSearchUrl = _keep,
double? pdfSidebarShare,
Object? askWith = _keep,
}) => DeviceSettings(
appearance: appearance ?? this.appearance,
language: identical(language, _keep) ? this.language : language as String?,
@ -112,6 +122,7 @@ final class const DeviceSettings({
? this.customSearchUrl
: customSearchUrl as String?,
pdfSidebarShare: pdfSidebarShare ?? this.pdfSidebarShare,
askWith: identical(askWith, _keep) ? this.askWith : askWith as String?,
);
@override
@ -125,6 +136,7 @@ final class const DeviceSettings({
searchEngine,
customSearchUrl,
pdfSidebarShare,
askWith,
];
}

View file

@ -9,6 +9,10 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
sealed class const AssistantTopic() {
/// The parts that can go along, in the order offered.
List<AssistantPart> get parts;
/// The studyset asked on, which an assistant's scope may reach from
/// (ADR 0021, D7); null on Home and in the Library.
String? get studysetId;
}
/// The person's learning as a whole: asked on Home or in the Library.
@ -16,17 +20,26 @@ sealed class const AssistantTopic() {
final class const AssistantTopicLearning() extends AssistantTopic {
@override
List<AssistantPart> get parts => const [];
@override
String? get studysetId => null;
}
/// One studyset, asked on its screen: its name, and its description where
/// it has one.
final class const AssistantTopicStudyset({
/// Its id.
required final String id,
/// Its title.
required final String title,
/// Its description.
final RichContent? description,
}) extends AssistantTopic {
@override
String get studysetId => id;
@override
List<AssistantPart> get parts => [
AssistantPart(kind: AssistantPartKind.studyset, text: title),
@ -38,7 +51,10 @@ final class const AssistantTopicStudyset({
/// 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({
/// The title of the studyset being studied.
/// The studyset being studied.
@override required final String studysetId,
/// Its title.
required final String studysetTitle,
/// The card.

View file

@ -0,0 +1,69 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/conversation_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
/// Who answers a question asked in the field: the web search, or an
/// assistant (ADR 0021).
sealed class const Answerer() {
/// How `DeviceSettings.askWith` names it.
String get key;
}
/// The web search, which opens the question in the browser.
final class const WebAnswerer() extends Answerer {
@override
String get key => 'web';
}
/// An assistant, which answers in a conversation.
final class const AssistantAnswerer(final AssistantChoice choice)
extends Answerer {
@override
String get key => askKeyOf(assistantRefOf(choice));
}
/// How `DeviceSettings.askWith` names the assistant [ref].
String askKeyOf(AssistantRef ref) =>
ref.own ? 'own:${ref.id}' : 'assistant:${ref.id}';
/// The assistants [state] offers to ask: those the operator granted, then
/// the person's own, only where the operator allows them (D6).
List<AssistantChoice> assistantChoicesOf(AssistantsState state) => [
for (final assistant in state.offer.assistants) GrantedAssistant(assistant),
if (state.offer.ownKeys)
for (final assistant in state.own) PersonalAssistant(assistant),
];
/// The assistant [ref] names among those [state] offers; null for one no
/// longer offered.
AssistantChoice? assistantChoiceOf(AssistantsState state, AssistantRef ref) =>
assistantChoicesOf(
state,
).where((choice) => assistantRefOf(choice) == ref).firstOrNull;
/// Who answers the field's question among [answerers]: the one [picked]
/// here, else the one that answered last ([askWith]), else an assistant
/// agreed to, any assistant, the web search; null for none at all.
Answerer? currentAnswerer(
List<Answerer> answerers, {
String? picked,
String? askWith,
}) {
for (final key in [picked, askWith]) {
if (answerers.where((answerer) => answerer.key == key).firstOrNull
case final answerer?) {
return answerer;
}
}
final assistants = answerers.whereType<AssistantAnswerer>();
return assistants
.where(
(answerer) => switch (answerer.choice) {
GrantedAssistant(:final assistant) => assistant.agreed,
PersonalAssistant() => true,
},
)
.firstOrNull ??
assistants.firstOrNull ??
answerers.firstOrNull;
}

View file

@ -1,19 +1,56 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.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/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/conversation_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.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_learn_flutter/src/feature/assistant/widget/answerers.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_words.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/conversation_history.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/conversation_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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 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);
/// Whether the field shows on this device: the person turned on searching
/// the web from the app here (Settings › Web search), or the instance
/// offers them an assistant (ADR 0021).
bool assistantOn(BuildContext context) {
final web = context.select<DeviceSettingsBloc, bool>(
(bloc) => bloc.state.assistant,
);
final assistants = context.select<AssistantsCubit, bool>(
(cubit) => assistantChoicesOf(cubit.state).isNotEmpty,
);
return web || assistants;
}
/// Who may answer the field's questions here: the web search where it is
/// on, then the assistants offered.
List<Answerer> answerersOf(BuildContext context) {
final web = context.select<DeviceSettingsBloc, bool>(
(bloc) => bloc.state.assistant,
);
final assistants = context.watch<AssistantsCubit>().state;
return [
if (web) const WebAnswerer(),
for (final choice in assistantChoicesOf(assistants))
AssistantAnswerer(choice),
];
}
IconData _iconOf(Answerer answerer) => switch (answerer) {
WebAnswerer() => LucideIcons.globe,
AssistantAnswerer(choice: PersonalAssistant()) => LucideIcons.keyRound,
AssistantAnswerer() => LucideIcons.sparkles,
};
/// 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
@ -47,10 +84,13 @@ const _ring = LinearGradient(
/// How thick [_ring] is.
const _ringWidth = 2.0;
/// 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.
/// A field to ask about [topic], in [_ring]: the question, who answers it
/// (the web search, or an assistant the instance offers, ADR 0021), and
/// above it a chip for each part of the topic to send along; none is
/// chosen, so only what matters goes (decided 2026-10-10). Asking an
/// assistant, the chips start with how far it may look, only what is
/// chosen until widened (D49, D50), and the field lists the conversations
/// kept (D48). Empty again once sent.
///
/// With [onClose], a close button leads, as where the field unfolded from
/// a button.
@ -78,31 +118,107 @@ class _AssistantFieldState() extends State<AssistantField> {
/// The parts chosen to go along.
final _chosen = <AssistantPart>{};
/// Who answers, as picked in the field's menu; null for the one that
/// answered last.
String? _picked;
/// How far an assistant may look: only what is chosen until the person
/// widens it (D50), and private studysets only where switched on (D8).
var _reach = AssistantReach.ticked;
var _includePrivate = false;
/// Whether the topic's studyset sits inside another, so that its tree
/// reaches further than it; null until read.
bool? _nested;
var _nestedRead = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_nestedRead && widget.topic.studysetId != null) {
_nestedRead = true;
unawaited(_readNested(PersonDependencies.of(context).studysets));
}
}
Future<void> _readNested(StudysetRepository studysets) async {
final id = widget.topic.studysetId;
final all = await studysets.all();
final nested =
all.where((studyset) => studyset.id == id).firstOrNull?.parentId !=
null;
if (mounted) {
setState(() => _nested = nested);
}
}
@override
void dispose() {
_question.dispose();
super.dispose();
}
/// The parts a web search can take, in the topic's order.
List<AssistantPart> get _offered => [
/// The parts [answerer] can take, in the topic's order: a web search
/// only those a search takes, an assistant all of them.
List<AssistantPart> _offered(Answerer? answerer) => [
for (final part in widget.topic.parts)
if (part.searchable) part,
if (answerer is AssistantAnswerer || part.searchable) part,
];
Future<void> _send() async {
final offered = _offered;
void _clear() {
_question.clear();
setState(() {
_chosen.clear();
_reach = AssistantReach.ticked;
_includePrivate = false;
});
}
Future<void> _send(Answerer answerer) async {
final chosen = [
for (final part in offered)
for (final part in _offered(answerer))
if (_chosen.contains(part)) part,
];
if (_question.text.trim().isEmpty && chosen.isEmpty) {
final question = _question.text;
if (question.trim().isEmpty && chosen.isEmpty) {
return;
}
final question = _question.text;
_question.clear();
setState(_chosen.clear);
await sendQuestion(context, question: question, chosen: chosen);
final settings = context.read<DeviceSettingsBloc>();
switch (answerer) {
case WebAnswerer():
settings.add(DeviceSettingsEventAskedWith(answerer.key));
_clear();
await sendQuestion(context, question: question, chosen: chosen);
case AssistantAnswerer(:final choice):
// Agreed to once, before the first question (D40).
if (choice case GrantedAssistant(:final assistant)
when !assistant.agreed) {
await showAssistantConsentSheet(
context,
assistant: assistant,
cubit: context.read<AssistantsCubit>(),
);
return;
}
final scope = AssistantScope(
reach: _reach,
studysetId: widget.topic.studysetId,
includePrivate: _reach != AssistantReach.ticked && _includePrivate,
);
settings.add(DeviceSettingsEventAskedWith(answerer.key));
_clear();
unawaited(
showConversation(
context,
choice: choice,
assistant: assistantRefOf(choice),
name: choice.name,
scope: scope,
question: question,
chosen: chosen,
),
);
}
widget.onAsked?.call();
}
@ -110,10 +226,23 @@ class _AssistantFieldState() extends State<AssistantField> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final offered = _offered;
final shape = offered.isEmpty
? const StadiumBorder()
: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24));
final answerers = answerersOf(context);
final answerer = currentAnswerer(
answerers,
picked: _picked,
askWith: context.select<DeviceSettingsBloc, String?>(
(bloc) => bloc.state.askWith,
),
);
if (answerer == null) {
return const SizedBox.shrink();
}
final asksAssistant = answerer is AssistantAnswerer;
final offered = _offered(answerer);
final chips = offered.isNotEmpty || asksAssistant;
final shape = chips
? RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))
: const StadiumBorder();
final row = Row(
children: [
if (widget.onClose case final close?)
@ -123,14 +252,11 @@ class _AssistantFieldState() extends State<AssistantField> {
onPressed: close,
),
Padding(
padding: EdgeInsets.only(
left: widget.onClose == null ? 10 : 0,
right: 8,
),
child: Icon(
LucideIcons.globe,
size: 18,
color: theme.colorScheme.primary,
padding: EdgeInsets.only(left: widget.onClose == null ? 4 : 0),
child: _Picker(
answerers: answerers,
current: answerer,
onPicked: (picked) => setState(() => _picked = picked.key),
),
),
Expanded(
@ -141,11 +267,18 @@ class _AssistantFieldState() extends State<AssistantField> {
autofocus: widget.autofocus,
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.search,
textInputAction: asksAssistant
? TextInputAction.send
: TextInputAction.search,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => unawaited(_send()),
onSubmitted: (_) => unawaited(_send(answerer)),
decoration: InputDecoration(
hintText: l10n.assistantHintWeb,
hintText: switch (answerer) {
WebAnswerer() => l10n.assistantHintWeb,
AssistantAnswerer(:final choice) => l10n.assistantHintAsk(
choice.name,
),
},
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
@ -154,15 +287,30 @@ class _AssistantFieldState() extends State<AssistantField> {
),
),
),
if (answerers.any((each) => each is AssistantAnswerer))
IconButton(
tooltip: l10n.assistantHistory,
icon: const Icon(LucideIcons.history),
onPressed: () => unawaited(
showConversationHistory(
context,
studysetId: widget.topic.studysetId,
),
),
),
ListenableBuilder(
listenable: _question,
builder: (context, _) => IconButton.filled(
tooltip: l10n.assistantSendWeb,
icon: const Icon(LucideIcons.search),
tooltip: asksAssistant
? l10n.assistantSendAsk
: l10n.assistantSendWeb,
icon: Icon(
asksAssistant ? LucideIcons.arrowUp : LucideIcons.search,
),
onPressed:
_question.text.trim().isEmpty && !offered.any(_chosen.contains)
? null
: () => unawaited(_send()),
: () => unawaited(_send(answerer)),
),
),
],
@ -178,16 +326,34 @@ class _AssistantFieldState() extends State<AssistantField> {
child: Padding(
// The send button clear of the ring, more so in the corner the
// chips' rounded box leaves.
padding: offered.isEmpty
? const EdgeInsets.fromLTRB(4, 0, 6, 0)
: const EdgeInsets.fromLTRB(4, 0, 8, 8),
child: offered.isEmpty
? row
: Column(
padding: chips
? const EdgeInsets.fromLTRB(4, 0, 8, 8)
: const EdgeInsets.fromLTRB(4, 0, 6, 0),
child: chips
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Parts(
leading: asksAssistant
? _ScopeChip(
reaches: [
AssistantReach.ticked,
if (widget.topic.studysetId != null)
AssistantReach.studyset,
if (widget.topic.studysetId != null &&
_nested == true)
AssistantReach.tree,
AssistantReach.library,
],
reach: _reach,
includePrivate: _includePrivate,
onReach: (reach) =>
setState(() => _reach = reach),
onIncludePrivate: (on) =>
setState(() => _includePrivate = on),
)
: null,
parts: offered,
chosen: _chosen,
onToggle: (part) => setState(
@ -198,7 +364,8 @@ class _AssistantFieldState() extends State<AssistantField> {
),
row,
],
),
)
: row,
),
),
),
@ -206,10 +373,166 @@ class _AssistantFieldState() extends State<AssistantField> {
}
}
/// Who answers, leading the field: a menu of [answerers] where there is
/// more than one, else just its sign.
class const _Picker({
required final List<Answerer> answerers,
required final Answerer current,
required final ValueChanged<Answerer> onPicked,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final sign = Icon(
_iconOf(current),
size: 18,
color: theme.colorScheme.primary,
);
if (answerers.length < 2) {
return Padding(
padding: const EdgeInsets.only(left: 6, right: 8),
child: sign,
);
}
return MenuAnchor(
menuChildren: [
for (final answerer in answerers)
MenuItemButton(
leadingIcon: Icon(_iconOf(answerer)),
trailingIcon: answerer.key == current.key
? const Icon(LucideIcons.check)
: null,
onPressed: () => onPicked(answerer),
child: _AnswererLabel(answerer: answerer),
),
],
builder: (context, controller, _) => IconButton(
tooltip: l10n.assistantChoose,
visualDensity: VisualDensity.compact,
onPressed: () =>
controller.isOpen ? controller.close() : controller.open(),
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
sign,
Icon(
LucideIcons.chevronDown,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}
/// An answerer's name in the picker's menu, with what to know beneath:
/// that it must be agreed to first, or that it asks with the person's key.
class const _AnswererLabel({required final Answerer answerer})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final (name, note) = switch (answerer) {
WebAnswerer() => (l10n.assistantWebSearch, null),
AssistantAnswerer(choice: GrantedAssistant(:final assistant)) => (
assistant.name,
assistant.agreed ? null : l10n.assistantChooseAgree,
),
AssistantAnswerer(choice: final PersonalAssistant own) => (
own.name,
l10n.assistantChooseOwn,
),
};
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name),
if (note != null)
Text(
note,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
/// How far an assistant may look, as one chip leading the parts (D49):
/// its menu holds the [reaches] offered here and the switch for private
/// studysets (D8), which only a wider reach than what is chosen has.
class const _ScopeChip({
required final List<AssistantReach> reaches,
required final AssistantReach reach,
required final bool includePrivate,
required final ValueChanged<AssistantReach> onReach,
required final ValueChanged<bool> onIncludePrivate,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return MenuAnchor(
menuChildren: [
for (final each in reaches)
MenuItemButton(
leadingIcon: each == reach
? const Icon(LucideIcons.check)
: const SizedBox.square(dimension: 24),
onPressed: () => onReach(each),
child: Text(reachLongText(l10n, each)),
),
const Divider(height: 1),
CheckboxMenuButton(
value: includePrivate && reach != AssistantReach.ticked,
onChanged: reach == AssistantReach.ticked
? null
: (on) => onIncludePrivate(on ?? false),
child: Text(l10n.assistantScopePrivate),
),
],
builder: (context, controller, _) => FilterChip(
visualDensity: VisualDensity.compact,
showCheckmark: false,
selected: reach != AssistantReach.ticked,
tooltip: l10n.assistantScope,
avatar: Icon(LucideIcons.scanSearch, color: theme.colorScheme.primary),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
scopeText(
l10n,
AssistantScope(reach: reach, includePrivate: includePrivate),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
const Icon(LucideIcons.chevronDown, size: 14),
],
),
onSelected: (_) =>
controller.isOpen ? controller.close() : controller.open(),
),
);
}
}
/// 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.
/// they need, after [leading] where there is one, each showing as much of
/// its text as its line holds; the whole of it on hovering or a long
/// press.
class const _Parts({
required final Widget? leading,
required final List<AssistantPart> parts,
required final Set<AssistantPart> chosen,
required final ValueChanged<AssistantPart> onToggle,
@ -220,7 +543,7 @@ class const _Parts({
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
String line(String text) => text.replaceAll(RegExp(r'\s+'), ' ').trim();
final leading = this.leading;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 8, 2),
// At most about three lines of chips; more scroll.
@ -232,6 +555,7 @@ class const _Parts({
spacing: 8,
runSpacing: 8,
children: [
?leading,
for (final part in parts)
Tooltip(
message: part.sent,
@ -243,24 +567,7 @@ class const _Parts({
child: FilterChip(
visualDensity: VisualDensity.compact,
label: Text(
switch (part.kind) {
AssistantPartKind.front => l10n.assistantLineFront(
line(part.text),
),
AssistantPartKind.back => l10n.assistantLineBack(
line(part.text),
),
AssistantPartKind.hint => l10n.assistantLineHint(
part.number ?? 1,
line(part.text),
),
AssistantPartKind.explanation =>
l10n.assistantLineExplanation(line(part.text)),
AssistantPartKind.studyset =>
l10n.assistantChipStudyset(line(part.text)),
AssistantPartKind.description =>
l10n.assistantLineDescription(line(part.text)),
},
partText(l10n, part),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@ -278,8 +585,9 @@ class const _Parts({
}
}
/// 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.
/// Home's field at its foot, above the navigation: asks about the person's
/// learning. Nothing while neither the web search nor an assistant is
/// there.
class const AssistantBar({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -347,6 +655,13 @@ class _AssistantButtonState() extends State<AssistantButton> {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final size = widget.small ? 44.0 : 56.0;
// Folded, it shows who answers next.
final asksAssistant = currentAnswerer(
answerersOf(context),
askWith: context.select<DeviceSettingsBloc, String?>(
(bloc) => bloc.state.askWith,
),
) is AssistantAnswerer;
final button = LayoutBuilder(
builder: (context, constraints) {
final width =
@ -363,7 +678,9 @@ class _AssistantButtonState() extends State<AssistantButton> {
),
)
: Tooltip(
message: l10n.assistantOpen,
message: asksAssistant
? l10n.assistantSendAsk
: l10n.assistantOpen,
child: DecoratedBox(
decoration: const ShapeDecoration(
shape: CircleBorder(),
@ -381,7 +698,9 @@ class _AssistantButtonState() extends State<AssistantButton> {
child: SizedBox.square(
dimension: size - 2 * _ringWidth,
child: Icon(
LucideIcons.search,
asksAssistant
? LucideIcons.sparkles
: LucideIcons.search,
color: theme.colorScheme.primary,
size: widget.small ? 20 : 24,
),

View file

@ -0,0 +1,63 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_lookup.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
/// [reach] in a word or two, as the scope's chip and a conversation's
/// header name it.
String reachText(AppLocalizations l10n, AssistantReach reach) =>
switch (reach) {
AssistantReach.ticked => l10n.assistantScopeTicked,
AssistantReach.studyset => l10n.assistantScopeStudyset,
AssistantReach.tree => l10n.assistantScopeTree,
AssistantReach.library => l10n.assistantScopeLibrary,
};
/// [reach] in full, as the scope's menu offers it.
String reachLongText(AppLocalizations l10n, AssistantReach reach) =>
switch (reach) {
AssistantReach.ticked => l10n.assistantScopeTickedLong,
AssistantReach.studyset => l10n.assistantScopeStudysetLong,
AssistantReach.tree => l10n.assistantScopeTreeLong,
AssistantReach.library => l10n.assistantScopeLibraryLong,
};
/// [scope] as a conversation's header names it: how far it reaches, and
/// whether private studysets are in it.
String scopeText(AppLocalizations l10n, AssistantScope scope) {
final reach = reachText(l10n, scope.reach);
return scope.includePrivate && scope.reach != AssistantReach.ticked
? l10n.assistantScopeWithPrivate(reach)
: reach;
}
/// [lookup] as its line in the conversation says it.
String lookupText(AppLocalizations l10n, AssistantLookup lookup) =>
switch (lookup.kind) {
LookupKind.studysets => l10n.assistantLookedUpStudysets(lookup.found),
LookupKind.cards => l10n.assistantLookedUpCards(
lookup.query ?? '',
lookup.found,
),
LookupKind.notes => l10n.assistantLookedUpNotes(
lookup.query ?? '',
lookup.found,
),
LookupKind.note =>
lookup.found > 0
? l10n.assistantLookedUpNote
: l10n.assistantLookedUpNoNote,
};
/// [part] on its chip: what it is and its text, on one line.
String partText(AppLocalizations l10n, AssistantPart part) {
final text = part.text.replaceAll(RegExp(r'\s+'), ' ').trim();
return switch (part.kind) {
AssistantPartKind.front => l10n.assistantLineFront(text),
AssistantPartKind.back => l10n.assistantLineBack(text),
AssistantPartKind.hint => l10n.assistantLineHint(part.number ?? 1, text),
AssistantPartKind.explanation => l10n.assistantLineExplanation(text),
AssistantPartKind.studyset => l10n.assistantChipStudyset(text),
AssistantPartKind.description => l10n.assistantLineDescription(text),
};
}

View file

@ -0,0 +1,178 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/answerers.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_words.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/conversation_sheet.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The person's kept conversations (ADR 0021, D48), the newest first, those
/// asked on [studysetId] before the rest, each with who answers and how far
/// it looks: opening one continues it in its sheet; deleting one asks
/// first.
Future<void> showConversationHistory(
BuildContext context, {
String? studysetId,
}) {
final assistants = context.read<AssistantsCubit>();
return showAdaptiveSheet<void>(
context,
builder: (context) => BlocProvider.value(
value: assistants,
child: _History(studysetId: studysetId),
),
);
}
class const _History({final String? studysetId}) extends StatefulWidget {
@override
State<_History> createState() => _HistoryState();
}
class _HistoryState() extends State<_History> {
late final _conversations = PersonDependencies.of(
context,
).conversations.watch();
/// Every studyset's title, for the conversations asked on one.
late final Future<Map<String, String>> _titles =
PersonDependencies.of(
context,
).studysets.withDeleted().then(
(all) => {
for (final studyset in all) studyset.id: studyset.content.title,
},
);
/// [all] with those asked on the studyset open first, each part newest
/// first, as they come.
List<AssistantConversation> _ordered(List<AssistantConversation> all) => [
for (final conversation in all)
if (conversation.scope.studysetId == widget.studysetId &&
widget.studysetId != null)
conversation,
for (final conversation in all)
if (conversation.scope.studysetId != widget.studysetId ||
widget.studysetId == null)
conversation,
];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SheetScaffold(
title: l10n.assistantHistory,
body: StreamBuilder<List<AssistantConversation>>(
stream: _conversations,
builder: (context, snapshot) => switch (snapshot.data) {
null => const Padding(
padding: EdgeInsets.all(24),
child: Center(child: AppLoadingIndicator()),
),
[] => Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: EmptyListMessage(
icon: LucideIcons.messagesSquare,
title: l10n.assistantHistoryEmpty,
hint: l10n.assistantHistoryEmptyHint,
),
),
final all => FutureBuilder<Map<String, String>>(
future: _titles,
builder: (context, titles) => Column(
children: [
for (final conversation in _ordered(all))
_ConversationTile(
conversation: conversation,
studysetTitle: switch (conversation.scope.studysetId) {
final id? => titles.data?[id],
null => null,
},
),
],
),
),
},
),
);
}
}
class const _ConversationTile({
required final AssistantConversation conversation,
required final String? studysetTitle,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final assistants = context.watch<AssistantsCubit>().state;
final choice = assistantChoiceOf(assistants, conversation.assistant);
final name = choice?.name ?? l10n.assistantGone;
final scope = conversation.scope;
final reach = scopeText(l10n, scope);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
choice == null ? LucideIcons.circleSlash : LucideIcons.sparkles,
),
title: Text(
conversation.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[
name,
if (studysetTitle != null && scope.reach != AssistantReach.library)
'$studysetTitle · $reach'
else
reach,
DateFormat.yMMMd(locale).format(conversation.startedAt.toLocal()),
].join(' · '),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
tooltip: l10n.assistantHistoryDelete,
icon: const Icon(LucideIcons.trash2),
onPressed: () => unawaited(_delete(context)),
),
onTap: () {
// The conversation's sheet over the screen, not over this list,
// opened from the navigator, which outlives the list.
final navigator = Navigator.of(context)..pop();
unawaited(
showConversation(
navigator.context,
choice: choice,
assistant: conversation.assistant,
name: name,
scope: scope,
conversationId: conversation.id,
),
);
},
);
}
Future<void> _delete(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final conversations = PersonDependencies.of(context).conversations;
if (await confirmDestructiveAction(
context,
title: l10n.assistantHistoryDelete,
message: l10n.assistantHistoryDeleteMessage(conversation.title),
confirmLabel: l10n.assistantHistoryDeleteConfirm,
)) {
await conversations.delete(conversation.id);
}
}
}

View file

@ -0,0 +1,561 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart' show markdownToDocument;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.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/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/conversation_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/turn_view.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_words.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens a conversation with [choice] in [scope] in a sheet over the screen
/// it is asked on (ADR 0021, D47): at half height, dragged up to the full,
/// with follow-up questions at its foot. Asks [question] with [chosen] at
/// once where given, or continues the kept conversation [conversationId].
/// [choice] null shows a kept conversation whose assistant has gone, which
/// can't go on.
Future<void> showConversation(
BuildContext context, {
required AssistantChoice? choice,
required AssistantRef assistant,
required String name,
required AssistantScope scope,
String? conversationId,
String? question,
List<AssistantPart> chosen = const [],
}) {
final person = PersonDependencies.of(context);
final cubit = ConversationCubit(
assistants: Dependencies.of(context).assistants,
lookups: person.assistantLookups,
conversations: person.conversations,
choice: choice,
assistant: assistant,
scope: scope,
conversationId: conversationId,
);
unawaited(() async {
await cubit.open();
if (question != null) {
await cubit.ask(question, chosen);
}
}());
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => BlocProvider(
// Closing the sheet closes the cubit, which stops an answer on its
// way.
create: (_) => cubit,
child: _ConversationSheet(
name: name,
scope: scope,
canAsk: choice != null,
),
),
);
}
class const _ConversationSheet({
required final String name,
required final AssistantScope scope,
required final bool canAsk,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
// The keyboard pushes the sheet up rather than covering the field.
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
// Half the height to start with (D47), the default, dragged up to the
// full.
child: DraggableScrollableSheet(
expand: false,
snap: true,
snapSizes: const [0.5],
builder: (context, controller) => _ConversationView(
controller: controller,
name: name,
scope: scope,
canAsk: canAsk,
),
),
);
}
class const _ConversationView({
required final ScrollController controller,
required final String name,
required final AssistantScope scope,
required final bool canAsk,
}) extends StatelessWidget {
/// How close to its end the conversation counts as followed: closer, it
/// follows the answer as it grows.
static const _followWithin = 48.0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return BlocConsumer<ConversationCubit, ConversationState>(
listener: (context, state) {
final following =
!controller.hasClients ||
controller.position.extentAfter < _followWithin;
if (following) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (controller.hasClients) {
controller.jumpTo(controller.position.maxScrollExtent);
}
});
}
},
builder: (context, state) => Column(
children: [
Expanded(
child: CustomScrollView(
controller: controller,
slivers: [
PinnedHeaderSliver(
child: _Header(name: name, scope: scope),
),
if (!state.keeps)
SliverToBoxAdapter(
child: _Note(
icon: LucideIcons.hardDriveDownload,
text: l10n.assistantNotKept,
),
),
if (state.reading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: AppLoadingIndicator()),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
sliver: SliverList.list(
children: [
for (final turn in state.turns)
_Turn(view: TurnView.of(turn.messages)),
if (state.asking case final asking?)
_Turn(
view: TurnView.of(asking.messages),
streaming: asking.text,
),
if (state.unanswered case final unanswered?)
_Unanswered(unanswered: unanswered),
],
),
),
],
),
),
Divider(height: 1, color: theme.colorScheme.outlineVariant),
if (canAsk)
_FollowUp(asking: state.asking != null || state.reading)
else
_Note(icon: LucideIcons.circleSlash, text: l10n.assistantGoneNote),
],
),
);
}
}
/// The handle to drag the sheet by, who answers and how far it looks, and
/// Close.
class const _Header({
required final String name,
required final AssistantScope scope,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return ColoredBox(
color: theme.colorScheme.surfaceContainerLow,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
child: Row(
children: [
Icon(
LucideIcons.sparkles,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
scopeText(l10n, scope),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
tooltip: l10n.assistantClose,
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
],
),
);
}
}
/// A line saying something about the whole conversation.
class const _Note({
required final IconData icon,
required final String text,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: style?.color),
const SizedBox(width: 8),
Expanded(child: Text(text, style: style)),
],
),
);
}
}
/// A turn: the question on the end side, with what went along; each
/// lookup; the answer, as Markdown once whole, or [streaming] while it is
/// written.
class const _Turn({
required final TurnView view,
final String? streaming,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final quiet = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final streaming = this.streaming;
final written = [
view.answer,
?streaming,
].where((text) => text.trim().isNotEmpty).join('\n\n');
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Question(view: view),
for (final lookup in view.lookups)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Icon(LucideIcons.search, size: 14, color: quiet?.color),
const SizedBox(width: 8),
Expanded(child: Text(lookupText(l10n, lookup), style: quiet)),
],
),
),
const SizedBox(height: 12),
if (streaming != null) ...[
if (written.isNotEmpty) Text(written),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
const SizedBox.square(
dimension: 16,
child: ButtonLoadingIndicator(),
),
const SizedBox(width: 8),
Text(l10n.assistantAnswering, style: quiet),
],
),
),
] else ...[
if (written.isEmpty)
Text(l10n.assistantNoAnswer, style: quiet)
else
_Answer(markdown: written),
if (view.stoppedEarly)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(l10n.assistantStoppedEarly, style: quiet),
),
],
],
),
);
}
}
/// What was asked, on the end side as the person's own words.
class const _Question({required final TurnView view}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Align(
alignment: AlignmentDirectional.centerEnd,
child: FractionallySizedBox(
widthFactor: 0.85,
alignment: AlignmentDirectional.centerEnd,
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (view.question.isNotEmpty)
Text(
view.question,
style: TextStyle(
color: theme.colorScheme.onSecondaryContainer,
),
),
for (final part in view.sent)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
partText(l10n, part),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer
.withValues(alpha: 0.75),
),
),
),
],
),
),
),
),
),
);
}
}
/// A whole answer, as the Markdown it is written in, with Copy beneath.
class const _Answer({required final String markdown}) extends StatefulWidget {
@override
State<_Answer> createState() => _AnswerState();
}
class _AnswerState() extends State<_Answer> {
// Decoded once, not with every state the conversation goes through.
late var _content = _decoded(widget.markdown);
static RichContent _decoded(String markdown) =>
markdownToDocument(markdown).toRichContent();
@override
void didUpdateWidget(_Answer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.markdown != oldWidget.markdown) {
_content = _decoded(widget.markdown);
}
}
Future<void> _copy() async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
await Clipboard.setData(ClipboardData(text: widget.markdown));
messenger.showSnackBar(SnackBar(content: Text(l10n.assistantCopied)));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RichContentView(content: _content),
Align(
alignment: AlignmentDirectional.centerStart,
child: IconButton(
tooltip: l10n.assistantCopyAnswer,
visualDensity: VisualDensity.compact,
iconSize: 16,
icon: const Icon(LucideIcons.copy),
onPressed: () => unawaited(_copy()),
),
),
],
);
}
}
/// A question that got no answer: why, and Try again.
class const _Unanswered({required final Unanswered unanswered})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final failure = unanswered.failure;
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Question(view: TurnView.of([unanswered.question])),
const SizedBox(height: 12),
Text(
failure == null
? l10n.assistantStopped
: assistantProblemText(context, failure),
style: TextStyle(
color: failure == null
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.error,
),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
icon: const Icon(LucideIcons.rotateCcw),
label: Text(l10n.commonTryAgain),
onPressed: () =>
unawaited(context.read<ConversationCubit>().retry()),
),
),
],
),
);
}
}
/// The field for a follow-up question, with Ask, or Stop while an answer
/// is on its way.
class const _FollowUp({required final bool asking}) extends StatefulWidget {
@override
State<_FollowUp> createState() => _FollowUpState();
}
class _FollowUpState() extends State<_FollowUp> {
final _question = TextEditingController();
@override
void dispose() {
_question.dispose();
super.dispose();
}
void _ask() {
final question = _question.text;
if (question.trim().isEmpty || widget.asking) {
return;
}
_question.clear();
unawaited(context.read<ConversationCubit>().ask(question, const []));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _question,
maxLength: TextLimits.message,
buildCounter: nearLimitCounter,
minLines: 1,
maxLines: 5,
textInputAction: TextInputAction.send,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => _ask(),
decoration: InputDecoration(
hintText: l10n.assistantFollowUp,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
isDense: true,
),
),
),
if (widget.asking)
IconButton.filledTonal(
tooltip: l10n.assistantStop,
icon: const Icon(LucideIcons.square),
onPressed: () =>
unawaited(context.read<ConversationCubit>().stop()),
)
else
ListenableBuilder(
listenable: _question,
builder: (context, _) => IconButton.filled(
tooltip: l10n.assistantSendAsk,
icon: const Icon(LucideIcons.arrowUp),
onPressed: _question.text.trim().isEmpty ? null : _ask,
),
),
],
),
),
);
}
}

View file

@ -71,6 +71,7 @@ class const StudyScreen({
final StudySessionStateShowing showing => _Showing(
showing,
header: header,
studysetId: studysetId,
title: title,
),
_ => TopGradient(
@ -332,6 +333,9 @@ class const _Showing(
final StudySessionStateShowing state, {
required final Widget header,
/// The studyset studied, which an assistant's scope may reach from.
required final String studysetId,
/// The studyset's title, for searches about the card.
required final String title,
}) extends StatelessWidget {
@ -682,6 +686,7 @@ class const _Showing(
: _aboveAnswering,
child: AssistantButton(
topic: AssistantTopicCard(
studysetId: studysetId,
studysetTitle: title,
card: card.content,
),

View file

@ -315,6 +315,7 @@ class _StudysetViewState() extends State<_StudysetView> {
? (assistantOn(context)
? AssistantButton(
topic: AssistantTopicStudyset(
id: studyset.id,
title: studyset.content.title,
description: studyset.content.description,
),
@ -325,6 +326,7 @@ class _StudysetViewState() extends State<_StudysetView> {
// bar's place.
: AssistantButton(
topic: AssistantTopicStudyset(
id: studyset.id,
title: studyset.content.title,
description: studyset.content.description,
),