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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-10 11:51:58 +02:00
commit f4baf053b3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 1140 additions and 25 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -55,6 +55,21 @@ void main() {
);
});
test(
"doesn't ask Claude until turned on, then keeps it, and the rest",
() async {
expect((await store.read()).assistant, isFalse);
await store.write(
const DeviceSettings(assistant: true, pomodoroWhileStudying: false),
);
final read = await FileDeviceSettingsStore(file).read();
expect(read.assistant, isTrue);
expect(read.pomodoroWhileStudying, isFalse);
},
);
test('keeps a return to the device language', () async {
await store.write(const DeviceSettings(language: 'en-US'));
await store.write(const DeviceSettings(appearance: Appearance.light));

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