feat(learn): the assistant's button folded on Home too, the conversation at full height folding into it, and smaller fixes from the developer's list (ADR 0021, D64, D65)

- Home's assistant starts folded into its button at the lower left, as
  in the Library, where a field stood at its foot.
- The button has Material 3's floating action button shape (a rounded
  square) rather than a circle, its gradient ring kept.
- On a studyset below expanded windows it stands in one row with Add
  material and Study rather than above them.
- While studying, the ratings below the card make way while its field
  is open rather than standing under it.
- The conversation opens at the window's full height, its header above
  the list, so a drag on the header moves the sheet and a drag on the
  list scrolls it. Close ends the conversation; any other way out folds
  it into the button (ConversationDockCubit, above the router), its
  answer still coming, until any button opens it again or another
  conversation starts.
- An answer's Copy icon lines up with the answer's text.
- A sense's grammar notes run on after its glosses as one flowing text,
  so a short note such as "n" no longer takes a line of its own.
- A card's hints show their count in a grey badge beside the title, so
  it no longer reads as the hint's number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 00:42:14 +01:00
commit 138c4dd46e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 719 additions and 169 deletions

File diff suppressed because one or more lines are too long

View file

@ -658,7 +658,7 @@
"cardExplanation": "Erklärung",
"cardAddExplanation": "Erklärung hinzufügen",
"cardRemoveExplanation": "Erklärung entfernen",
"cardHintsSheetTitle": "{count, plural, =1{Hinweis} other{Hinweise {count}}}",
"cardHintsSheetTitle": "{count, plural, =1{Hinweis} other{Hinweise}}",
"cardPreviousHint": "Vorheriger Hinweis",
"cardNextHint": "Nächster Hinweis",
"cardReport": "Meldung",
@ -1810,6 +1810,7 @@
"settingsAskAssistantSubtitle": "Aus, bis du es einschaltest. Fügt ein Feld auf der Startseite und einen Knopf in der Bibliothek, bei einem Lernset und beim Lernen hinzu. Eine Frage öffnet sich als Websuche, nur mit den Teilen einer Karteikarte, die du wählst; von hier aus wird nichts gesendet.",
"assistantOpen": "Im Web suchen",
"assistantClose": "Schließen",
"assistantBackToConversation": "Zurück zur Unterhaltung",
"assistantLineFront": "Vorderseite: {text}",

View file

@ -755,7 +755,7 @@
"cardExplanation": "Explanation",
"cardAddExplanation": "Add explanation",
"cardRemoveExplanation": "Remove explanation",
"cardHintsSheetTitle": "{count, plural, =1{Hint} other{Hints {count}}}",
"cardHintsSheetTitle": "{count, plural, =1{Hint} other{Hints}}",
"@cardHintsSheetTitle": {"placeholders": {"count": {"type": "int"}}},
"cardPreviousHint": "Previous hint",
"cardNextHint": "Next hint",
@ -2120,6 +2120,10 @@
"settingsAskAssistantSubtitle": "Off unless you turn it on. Adds a field on Home and a button in the Library, on a studyset and while studying. A question opens as a web search, with only the parts of a card you choose; nothing is sent from here.",
"assistantOpen": "Search the web",
"assistantClose": "Close",
"assistantBackToConversation": "Back to the conversation",
"@assistantBackToConversation": {
"description": "The tooltip of the assistant's button while a conversation is folded away into it, its sheet swiped down."
},
"assistantLineFront": "Front: {text}",

View file

@ -3931,7 +3931,7 @@ abstract class AppLocalizations {
/// No description provided for @cardHintsSheetTitle.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Hint} other{Hints {count}}}'**
/// **'{count, plural, =1{Hint} other{Hints}}'**
String cardHintsSheetTitle(int count);
/// No description provided for @cardPreviousHint.
@ -10689,6 +10689,12 @@ abstract class AppLocalizations {
/// **'Close'**
String get assistantClose;
/// The tooltip of the assistant's button while a conversation is folded away into it, its sheet swiped down.
///
/// In en, this message translates to:
/// **'Back to the conversation'**
String get assistantBackToConversation;
/// A line of context handed to the AI assistant after the question.
///
/// In en, this message translates to:

View file

@ -2393,7 +2393,7 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Hinweise $count',
other: 'Hinweise',
one: 'Hinweis',
);
return '$_temp0';
@ -6691,6 +6691,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get assistantClose => 'Schließen';
@override
String get assistantBackToConversation => 'Zurück zur Unterhaltung';
@override
String assistantLineFront(String text) {
return 'Vorderseite: $text';

View file

@ -2371,7 +2371,7 @@ class AppLocalizationsEn extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Hints $count',
other: 'Hints',
one: 'Hint',
);
return '$_temp0';
@ -6627,6 +6627,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get assistantClose => 'Close';
@override
String get assistantBackToConversation => 'Back to the conversation';
@override
String assistantLineFront(String text) {
return 'Front: $text';

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/bloc/conversation_dock_cubit.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';
@ -115,6 +116,9 @@ class _PersonAppState() extends State<PersonApp> {
),
)..load(),
),
// A conversation folded away into the assistant's
// button, for every button to open again.
BlocProvider(create: (context) => ConversationDockCubit()),
// The Pomodoro timer goes on across every screen.
BlocProvider(
create: (context) => PomodoroBloc(

View file

@ -0,0 +1,81 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
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';
/// A conversation folded away into the assistant's button: its sheet
/// swiped down rather than closed (decided 2026-11-01), its [cubit] going
/// on, an answer on its way still coming.
final class const ConversationDockState({
/// The conversation itself.
required final ConversationCubit cubit,
/// Who answers, as the sheet names them.
required final String name,
/// How far it looks.
required final AssistantScope scope,
/// Who answers; null for an assistant no longer offered.
required final AssistantChoice? choice,
}) with Equatable {
@override
List<Object?> get props => [cubit, name, scope, choice];
}
/// The conversation folded away, if any: one at a time, for every button
/// of the person's app to open again; above the router, so it outlives the
/// screen it was asked on, and closes with the person's app.
class ConversationDockCubit() extends Cubit<ConversationDockState?> {
this : super(null);
/// The conversation whose sheet shows, closed with the person's app
/// should its sheet never say how it went.
ConversationCubit? _shown;
/// [conversation]'s sheet shows.
void showing(ConversationCubit conversation) => _shown = conversation;
/// Folds [conversation] away as its sheet goes, closing one folded away
/// before.
Future<void> dock(ConversationDockState conversation) async {
if (_shown == conversation.cubit) {
_shown = null;
}
final before = state;
emit(conversation);
if (before != null && before.cubit != conversation.cubit) {
await before.cubit.close();
}
}
/// [conversation]'s sheet closed with Close: it ends.
Future<void> ended(ConversationCubit conversation) async {
if (_shown == conversation) {
_shown = null;
}
await conversation.close();
}
/// The conversation folded away, no longer folded: its sheet opens
/// again, which docks or ends it once more as it goes.
ConversationDockState? take() {
final docked = state;
emit(null);
return docked;
}
/// Closes the conversation folded away, as another one starts.
Future<void> clear() async {
final docked = take();
await docked?.cubit.close();
}
@override
Future<void> close() async {
await _shown?.close();
await state?.cubit.close();
await super.close();
}
}

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/widget/stored_file_image.da
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_dock_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';
@ -15,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/widget/answerers.dar
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/asking.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/assistant/widget/grammar_check_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
@ -781,39 +783,15 @@ class const _Thumbnail({required final String assetId})
);
}
/// 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) {
if (!assistantOn(context)) {
return const SizedBox.shrink();
}
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Center(
heightFactor: 1,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: const AssistantField(topic: AssistantTopicLearning()),
),
),
),
);
}
}
/// A round button that unfolds into [AssistantField], [expandedWidth] wide,
/// and folds back once sent or closed: the web search's floating button in
/// the Library, on a studyset and while studying, at the lower left
/// (decided 2026-10-16, as Vaia has it). [below] (a studyset's bar) stands
/// under it, [end] (the Library's Create studyset) at the other end of its
/// row; either makes way while it is unfolded.
/// A button shaped as a floating action button that unfolds into
/// [AssistantField], [expandedWidth] wide, and folds back once sent or
/// closed: the web search's floating button on Home (decided 2026-11-01)
/// and in the Library, on a studyset and while studying, at the lower left
/// (decided 2026-10-16, as Vaia has it). [beside] (a studyset's bar) fills
/// its row after it, [end] (the Library's Create studyset) stands at the
/// other end of its row; either makes way while it is unfolded.
///
/// Nothing (or only [below]) while searching from the app is off.
/// Nothing (or only [beside] or [end]) while searching from the app is off.
class const AssistantButton({
/// What questions here are about.
required final AssistantTopic topic,
@ -825,11 +803,16 @@ class const AssistantButton({
/// Smaller, as above another floating button.
final bool small = false,
/// A floating button under it.
final Widget? below,
/// A floating bar in its row after it, filling the rest (a studyset's
/// Add material and Study, decided 2026-11-01).
final Widget? beside,
/// A floating button at the other end of its row.
final Widget? end,
/// Told whenever it unfolds (true) and folds back (false), for what it
/// covers to make way meanwhile.
final ValueChanged<bool>? onUnfolded,
super.key,
}) extends StatefulWidget {
@override
@ -839,18 +822,26 @@ class const AssistantButton({
class _AssistantButtonState() extends State<AssistantButton> {
var _expanded = false;
void _fold() => setState(() => _expanded = false);
void _fold() => _unfold(false);
void _unfold(bool expanded) {
setState(() => _expanded = expanded);
widget.onUnfolded?.call(expanded);
}
@override
Widget build(BuildContext context) {
final below = widget.below;
final beside = widget.beside;
final end = widget.end;
if (!assistantOn(context)) {
return below ?? end ?? const SizedBox.shrink();
return beside ?? end ?? const SizedBox.shrink();
}
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final size = widget.small ? 44.0 : 56.0;
// Shaped as the floating action buttons beside it (decided 2026-11-01):
// Material 3's rounded square, 16 at their full size, 12 when small.
final corner = widget.small ? 12.0 : 16.0;
// Folded, it shows who answers next.
final next = currentAnswerer(
answerersOf(context),
@ -859,6 +850,10 @@ class _AssistantButtonState() extends State<AssistantButton> {
),
);
final asksAssistant = next is AssistantAnswerer;
// A conversation folded away opens again rather than the field.
final docked = context.select<ConversationDockCubit, bool>(
(dock) => dock.state != null,
);
final button = LayoutBuilder(
builder: (context, constraints) {
final width =
@ -879,7 +874,9 @@ class _AssistantButtonState() extends State<AssistantButton> {
answerer: next,
builder: (context, agreed) => Tooltip(
message: [
if (asksAssistant)
if (docked)
l10n.assistantBackToConversation
else if (asksAssistant)
l10n.assistantSendAsk
else
l10n.assistantOpen,
@ -895,23 +892,33 @@ class _AssistantButtonState() extends State<AssistantButton> {
color: theme.colorScheme.onTertiary,
),
child: DecoratedBox(
decoration: const ShapeDecoration(
shape: CircleBorder(),
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
corner + _ringWidth,
),
),
gradient: _ring,
),
child: Padding(
padding: const EdgeInsets.all(_ringWidth),
child: Material(
elevation: 6,
shape: const CircleBorder(),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(corner),
),
color: theme.colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => setState(() => _expanded = true),
onTap: docked
? () => unawaited(reopenConversation(context))
: () => _unfold(true),
child: SizedBox.square(
dimension: size - 2 * _ringWidth,
child: Icon(
asksAssistant
docked
? LucideIcons.messageCircleMore
: asksAssistant
? LucideIcons.sparkles
: LucideIcons.search,
color: theme.colorScheme.primary,
@ -936,16 +943,17 @@ class _AssistantButtonState() extends State<AssistantButton> {
],
);
}
if (below == null) {
if (beside == null || _expanded) {
return button;
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
button,
if (!_expanded) ...[const SizedBox(height: 16), below],
],
return Padding(
padding: const EdgeInsetsDirectional.only(start: 16),
child: Row(
children: [
button,
Expanded(child: beside),
],
),
);
}
}

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
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/bloc/conversation_dock_cubit.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';
@ -24,14 +25,17 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens a conversation with [choice] in [scope] for [task] 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; where halkyon_ui's
/// sheets are dialogs (from expanded windows on, and on the desktop), a
/// dialog as tall as the window allows. Asks [question] with
/// [chosen] at once where given, a new conversation called [title] where
/// given, or continues the kept conversation [conversationId]. [choice]
/// null shows a kept conversation whose assistant has gone, which can't go
/// on.
/// the screen it is asked on (ADR 0021, D47): at the window's full height
/// (decided 2026-11-01), dragged by its header, with follow-up questions at
/// its foot; where halkyon_ui's sheets are dialogs (from expanded windows
/// on, and on the desktop), a dialog as tall as the window allows. Close
/// ends it; going any other way (swiped down, back, a tap beside it) folds
/// it into the assistant's button, kept going ([ConversationDockCubit]),
/// which [reopenConversation] opens again. A conversation folded away before
/// goes as this one starts. Asks [question] with [chosen] at once where
/// given, a new conversation called [title] 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,
@ -49,6 +53,8 @@ Future<void> showConversation(
LlmEffort? effort,
}) {
final person = PersonDependencies.of(context);
final dock = context.read<ConversationDockCubit>();
unawaited(dock.clear());
final cubit = ConversationCubit(
assistants: Dependencies.of(context).assistants,
lookups: person.assistantLookups,
@ -68,26 +74,72 @@ Future<void> showConversation(
await cubit.ask(question, chosen);
}
}());
// Closing the sheet closes the cubit, which stops an answer on its way.
return _present(
context,
dock,
ConversationDockState(
cubit: cubit,
name: name,
scope: scope,
choice: choice,
),
);
}
/// Opens the conversation folded away into the assistant's button again,
/// if one is.
Future<void> reopenConversation(BuildContext context) async {
final dock = context.read<ConversationDockCubit>();
if (dock.take() case final docked?) {
await _present(context, dock, docked);
}
}
/// Shows [conversation] in its sheet or dialog, and ends it once closed
/// with Close, else folds it away into [dock].
Future<void> _present(
BuildContext context,
ConversationDockCubit dock,
ConversationDockState conversation,
) async {
dock.showing(conversation.cubit);
// The cubit outlives the sheet: it is ended or folded away below.
Widget provided(Widget child) =>
BlocProvider(create: (context) => cubit, child: child);
BlocProvider.value(value: conversation.cubit, child: child);
// A dialog where halkyon_ui's sheets are (Todo's ADR 0033, D11): from
// expanded windows on, and on the desktop at every width.
if (sheetShowsAsDialog(context)) {
return showDialog<void>(
context: context,
builder: (context) => provided(
_ConversationDialog(name: name, scope: scope, choice: choice),
),
);
final closed = sheetShowsAsDialog(context)
? await showDialog<bool>(
context: context,
builder: (context) => provided(
_ConversationDialog(
name: conversation.name,
scope: conversation.scope,
choice: conversation.choice,
),
),
)
: await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => provided(
_ConversationSheet(
name: conversation.name,
scope: conversation.scope,
choice: conversation.choice,
),
),
);
if (dock.isClosed) {
return;
}
if (closed ?? false) {
// Ending the cubit stops an answer on its way.
await dock.ended(conversation.cubit);
} else {
await dock.dock(conversation);
}
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) =>
provided(_ConversationSheet(name: name, scope: scope, choice: choice)),
);
}
/// The conversation as a dialog, as tall as the window lets it, up to a
@ -131,26 +183,38 @@ class _ConversationDialogState() extends State<_ConversationDialog> {
);
}
/// The conversation as a bottom sheet at the window's full height: its
/// header drags it, the conversation scrolls beneath (decided 2026-11-01:
/// at half height, as a draggable sheet, it grew only once its list was
/// scrolled to the top).
class const _ConversationSheet({
required final String name,
required final AssistantScope scope,
required final AssistantChoice? choice,
}) extends StatelessWidget {
}) extends StatefulWidget {
@override
State<_ConversationSheet> createState() => _ConversationSheetState();
}
class _ConversationSheetState() extends State<_ConversationSheet> {
final _controller = ScrollController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@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,
choice: choice,
child: SizedBox.expand(
child: _ConversationView(
controller: _controller,
name: widget.name,
scope: widget.scope,
choice: widget.choice,
handle: true,
),
),
@ -192,13 +256,12 @@ class const _ConversationView({
},
builder: (context, state) => Column(
children: [
// Above the list, so a drag on it moves the sheet, not the list.
_Header(name: name, scope: scope, handle: handle),
Expanded(
child: CustomScrollView(
controller: controller,
slivers: [
PinnedHeaderSliver(
child: _Header(name: name, scope: scope, handle: handle),
),
if (!state.keeps)
SliverToBoxAdapter(
child: _Note(
@ -322,7 +385,8 @@ class const _Header({
IconButton(
tooltip: l10n.assistantClose,
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.of(context).pop(),
// Ends the conversation; any other way out folds it.
onPressed: () => Navigator.of(context).pop(true),
),
],
),
@ -626,12 +690,21 @@ class _AnswerState() extends State<_Answer> {
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()),
// Moved out by the room around its icon (a compact button is 40
// wide, its icon 16), so the icon lines up with the answer's text
// while the button keeps its size to tap.
child: Transform.translate(
offset: Offset(
Directionality.of(context) == TextDirection.rtl ? 12 : -12,
0,
),
child: IconButton(
tooltip: l10n.assistantCopyAnswer,
visualDensity: VisualDensity.compact,
iconSize: 16,
icon: const Icon(LucideIcons.copy),
onPressed: () => unawaited(_copy()),
),
),
),
],

View file

@ -73,6 +73,9 @@ class const _Pill({
/// The hints one at a time, the first first; with several, a way back and
/// on below, between them which of how many shows.
/// The key of the hints sheet's count.
const hintsCountKey = ValueKey('hints-count');
class const _HintsSheet({required final List<RichContent> hints})
extends StatefulWidget {
@override
@ -87,8 +90,45 @@ class _HintsSheetState() extends State<_HintsSheet> {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final count = widget.hints.length;
final title = l10n.cardHintsSheetTitle(count);
return SheetScaffold(
title: l10n.cardHintsSheetTitle(count),
title: title,
// How many in a grey badge of their own, so the number isn't read as
// the hint's place, which the footer gives.
heading: count < 2
? null
: Row(
children: [
Flexible(
child: Text(
title,
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
DecoratedBox(
key: hintsCountKey,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 1,
),
child: Text(
'$count',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
],
),
footer: count > 1
? Row(
mainAxisAlignment: MainAxisAlignment.center,

View file

@ -22,28 +22,34 @@ class const VocabularySensesView({
Widget sense(int index, VocabularySense sense) {
final label = sense.label?.trim() ?? '';
final tags = sense.tags;
final content = Wrap(
spacing: 8,
runSpacing: 2,
alignment: numbered || !centred
? WrapAlignment.start
: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (label.isNotEmpty) Text('($label)', style: tagStyle),
Text(glossesOf(sense), style: style),
for (final (i, code) in tags.indexed)
Tooltip(
message: jmdictTag(code).full,
// Glued to its own tag, so a line never starts with a comma.
child: Text(
i == tags.length - 1
? jmdictTag(code).abbr
: '${jmdictTag(code).abbr},',
style: tagStyle,
// One flowing text (decided 2026-11-01): a Wrap of its parts put a
// note on a line of its own whenever the glosses took two.
final content = Text.rich(
TextSpan(
style: style,
children: [
if (label.isNotEmpty) TextSpan(text: '($label) ', style: tagStyle),
TextSpan(text: glossesOf(sense)),
for (final (i, code) in tags.indexed) ...[
const TextSpan(text: ' '),
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: Tooltip(
message: jmdictTag(code).full,
// Glued to its own tag, so a line never starts with a comma.
child: Text(
i == tags.length - 1
? jmdictTag(code).abbr
: '${jmdictTag(code).abbr},',
style: tagStyle,
),
),
),
),
],
],
],
),
textAlign: numbered || !centred ? TextAlign.start : TextAlign.center,
);
if (!numbered) {
return content;

View file

@ -5,6 +5,7 @@ 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/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.dart';
import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
@ -22,8 +23,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]). Where the person searches the web from the app, a
/// field sits at the foot ([AssistantBar]).
/// ([TopGradient]). Where the person searches the web or asks an assistant
/// from the app, its button stands at the lower left ([AssistantButton]).
///
/// 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
@ -103,9 +104,17 @@ class _HomeState() extends State<_Home> {
// No field, no slot: an empty one would take the room the body keeps
// for the navigation bar.
extendBody: true,
bottomNavigationBar: _arranging || !assistantOn(context)
// Folded into its button at the lower left, as in the Library (decided
// 2026-11-01), clear of the navigation bar floating over the foot.
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
floatingActionButton: _arranging || !assistantOn(context)
? null
: const AssistantBar(),
: Padding(
padding: EdgeInsets.only(
bottom: FloatingNavigationBar.coverOf(context),
),
child: const AssistantButton(topic: AssistantTopicLearning()),
),
appBar: _arranging
? AppBar(
backgroundColor: Colors.transparent,
@ -164,7 +173,9 @@ class _HomeState() extends State<_Home> {
builder: (context) => _dashboard(
loaded,
today,
bottom: MediaQuery.paddingOf(context).bottom,
bottom:
MediaQuery.paddingOf(context).bottom +
(assistantOn(context) ? 72 : 0),
),
),
),
@ -234,8 +245,8 @@ class _HomeState() extends State<_Home> {
: layout.shown.isEmpty
? _AllHidden(onArrange: _arrange)
: ListView(
// Room at the end for what floats over the foot: the Ask
// field and the navigation bar.
// Room at the end for what floats over the foot: the
// navigation bar, and the assistant's button above it.
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottom),
children: [
for (final widget in layout.shown)

View file

@ -354,7 +354,6 @@ class const _Showing(
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final card = state.card;
final key = ValueKey((card.id, state.shownAt));
void show() => _send(context, const StudySessionEventAnswerShown());
@ -646,6 +645,32 @@ class const _Showing(
CardType.frontBack || CardType.vocabulary || CardType.character => true,
CardType.multipleChoice || CardType.matching => false,
};
return _WhileAsking(
builder: (context, asking, onAsking) => _layout(
context,
ratedBelow: ratedBelow,
asking: asking,
onAsking: onAsking,
header: header,
body: body,
ratings: ratings,
topic: topic,
),
);
}
Widget _layout(
BuildContext context, {
required bool ratedBelow,
required bool asking,
required ValueChanged<bool> onAsking,
required Widget header,
required Widget body,
required Widget ratings,
required AssistantTopic topic,
}) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: TopGradient.overlayStyle(context),
child: TopGradient(
@ -709,6 +734,7 @@ class const _Showing(
child: AssistantButton(
topic: topic,
small: true,
onUnfolded: onAsking,
expandedWidth:
constraints.maxWidth -
2 * _fieldInset,
@ -721,7 +747,9 @@ class const _Showing(
),
),
),
if (ratedBelow)
// Out of the way while the field is open, rather than
// under it (decided 2026-11-01).
if (ratedBelow && !asking)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Center(
@ -787,6 +815,31 @@ class const _Showing(
}
}
/// Builds with whether the assistant's field is open over the card, and
/// how to say it opened or folded.
class const _WhileAsking({
required final Widget Function(
BuildContext context,
bool asking,
ValueChanged<bool> onAsking,
)
builder,
}) extends StatefulWidget {
@override
State<_WhileAsking> createState() => _WhileAskingState();
}
class _WhileAskingState() extends State<_WhileAsking> {
var _asking = false;
@override
Widget build(BuildContext context) => widget.builder(
context,
_asking,
(asking) => setState(() => _asking = asking),
);
}
/// The session couldn't be read, and a way to try again.
class const _Failed() extends StatelessWidget {
@override

View file

@ -322,9 +322,9 @@ class _StudysetViewState() extends State<_StudysetView> {
),
)
: null)
// Asking stands above the bar on the left, smaller (decided
// 2026-10-16, as Vaia has it); unfolded, its field takes the
// bar's place.
// Asking stands at the left of the bar's row, smaller (decided
// 2026-10-16, as Vaia has it; in the row since 2026-11-01);
// unfolded, its field takes the bar's place.
: AssistantButton(
topic: AssistantTopicStudyset(
id: studyset.id,
@ -332,7 +332,7 @@ class _StudysetViewState() extends State<_StudysetView> {
description: studyset.content.description,
),
small: true,
below: _StudyBar(studyset: studyset, addMaterial: _addMaterial),
beside: _StudyBar(studyset: studyset, addMaterial: _addMaterial),
),
builder: (context, fab) => TopGradient(
color: color,

View file

@ -10,6 +10,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_fie
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/rating_row.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -107,9 +108,19 @@ void main() {
/// What the one web search looked for, on Brave Search.
String searchedFor() => searched.single.queryParameters['q']!;
/// Unfolds the floating button.
Future<void> unfold(WidgetTester tester) async {
await tester.tap(find.byTooltip('Search the web'));
await tester.pumpAndSettle();
}
/// Types [question] into the field and searches for it, with the field's
/// own button: the Library's search is called Search too.
Future<void> send(WidgetTester tester, String question) async {
// Home's, like every other, starts folded into its button.
if (find.byType(AssistantField).evaluate().isEmpty) {
await unfold(tester);
}
await tester.enterText(find.byType(TextField).last, question);
await tester.pump();
await tester.tap(
@ -121,12 +132,6 @@ void main() {
await tester.pumpAndSettle();
}
/// Unfolds the floating button.
Future<void> unfold(WidgetTester tester) async {
await tester.tap(find.byTooltip('Search the web'));
await tester.pumpAndSettle();
}
group('Searching the web', () {
test("puts the question into the engine's address", () {
expect(
@ -281,22 +286,21 @@ void main() {
});
});
testWidgets("Home's field searches the web for the question, and empties; "
testWidgets("Home's button unfolds into the field, which searches the web and folds back; "
'it names no AI assistant and offers none (ADR 0022)', (tester) async {
// A desktop: a web search opens Brave Search's results.
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
await showApp(tester);
expect(find.byType(AssistantField), findsNothing, reason: 'folded');
await unfold(tester);
expect(find.text('Search the web…'), findsOneWidget);
expect(find.byIcon(LucideIcons.sparkles), findsNothing);
await send(tester, 'は vs が');
expect(searched.single.host, 'search.brave.com');
expect(searchedFor(), 'は vs が');
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
isEmpty,
);
expect(find.byType(AssistantField), findsNothing, reason: 'folded back');
debugDefaultTargetPlatformOverride = null;
});
@ -331,6 +335,7 @@ void main() {
tester,
) async {
await showApp(tester);
await unfold(tester);
await tester.enterText(find.byType(TextField), ' ');
await tester.pump();
@ -352,6 +357,30 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
testWidgets("the Library's button is shaped as Create studyset beside "
"it, Material 3's rounded square, with the ring around it", (
tester,
) async {
await showApp(tester, location: RoutePaths.library);
final button = find.byTooltip('Search the web');
final shape = tester
.widget<Material>(
find.descendant(of: button, matching: find.byType(Material)).first,
)
.shape;
expect(
shape,
const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
);
expect(
tester.getSize(button),
tester.getSize(find.byTooltip('Create studyset')),
);
});
testWidgets("the Library's button sits at the lower left, Create studyset "
'at the lower right, unfolds into the field, and folds back once '
'sent', (tester) async {
@ -377,13 +406,13 @@ void main() {
tester,
) async {
await showApp(tester, location: RoutePaths.studyset('japanese'));
// At the lower left, above the studyset's bar.
final center = tester.getCenter(find.byTooltip('Search the web'));
expect(center.dx, lessThan(100));
expect(
center.dy,
lessThan(tester.getCenter(find.byTooltip('Add material')).dy),
);
// At the lower left, in one row with the studyset's bar (decided
// 2026-11-01): before Add material, centred on the same line.
final button = tester.getRect(find.byTooltip('Search the web'));
final add = tester.getRect(find.byTooltip('Add material'));
expect(button.left, lessThan(24));
expect(button.right, lessThan(add.left));
expect(button.center.dy, closeTo(add.center.dy, 1));
await unfold(tester);
expect(find.text('Studyset: Japanese'), findsNothing);
@ -414,6 +443,36 @@ void main() {
expect(button.bottom, lessThan(check.top));
});
testWidgets('in a session, the ratings below the card make way while the '
'field is open, and come back once it folds (decided 2026-11-01)', (
tester,
) async {
final repository = CardRepository(cards);
addTearDown(repository.dispose);
await repository.create(
'japanese',
CardContent(front: paragraph('ひ'), back: paragraph('hi')),
);
await showApp(tester, location: RoutePaths.study('japanese'));
await tester.tap(find.text('Show answer'));
await tester.pumpAndSettle();
expect(find.byType(RatingRow), findsOneWidget);
await unfold(tester);
expect(find.byType(RatingRow), findsNothing);
final field = tester.getRect(find.byType(AssistantField));
expect(field.bottom, greaterThan(tester.view.physicalSize.height - 80));
await tester.tap(
find.descendant(
of: find.byType(AssistantField),
matching: find.byTooltip('Close'),
),
);
await tester.pumpAndSettle();
expect(find.byType(RatingRow), findsOneWidget);
});
testWidgets("in a session, the card's parts go along only as chosen, and "
'a chosen hint alone is enough to search', (tester) async {
// A desktop: a web search opens Brave Search's results.

View file

@ -170,6 +170,9 @@ void main() {
/// The efforts this device last asked each assistant at (D63).
Map<String, LlmEffort> askEfforts = const {},
/// Whether Home's button is unfolded into its field once it shows.
bool unfolded = true,
}) async {
tester.view.physicalSize = const Size(400, 900);
tester.view.devicePixelRatio = 1;
@ -203,10 +206,27 @@ void main() {
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
if (unfolded && location == RoutePaths.home) {
final button = find.byType(AssistantButton);
if (button.evaluate().isNotEmpty) {
await tester.tap(button);
await tester.pumpAndSettle();
}
}
}
/// Unfolds the floating button, an assistant answering.
Future<void> unfold(WidgetTester tester, {String tooltip = 'Ask'}) async {
await tester.tap(find.byTooltip(tooltip));
await tester.pumpAndSettle();
}
/// Types [question] into the field and asks it with the field's button.
Future<void> ask(WidgetTester tester, String question) async {
// Home's, like every other, starts folded into its button.
if (find.byType(AssistantField).evaluate().isEmpty) {
await unfold(tester);
}
await tester.enterText(
find.descendant(
of: find.byType(AssistantField),
@ -225,12 +245,6 @@ void main() {
await tester.pumpAndSettle();
}
/// Unfolds the floating button, an assistant answering.
Future<void> unfold(WidgetTester tester, {String tooltip = 'Ask'}) async {
await tester.tap(find.byTooltip(tooltip));
await tester.pumpAndSettle();
}
/// The settings the person's devices last wrote.
PersonSettings lastSettings() =>
decodeSettingsPayload(settings.writes.last.$2);
@ -240,7 +254,13 @@ void main() {
testWidgets("Home's field asks the assistant granted, and the answer comes "
'in a sheet over Home, kept', (tester) async {
await showApp(tester);
await showApp(tester, unfolded: false);
// Folded at the lower left, as in the Library (decided 2026-11-01).
expect(find.byType(AssistantField), findsNothing);
final button = tester.getRect(find.byTooltip('Ask'));
expect(button.left, lessThan(40));
expect(button.bottom, lessThan(900 - 64), reason: 'above the bar');
await unfold(tester);
expect(find.text('Ask Claude…'), findsOneWidget);
assistants.answers.add(_says('Because it joins clauses.'));
@ -265,6 +285,100 @@ void main() {
);
});
testWidgets("an answer's Copy stands beneath it, its icon in line with "
"the answer's text", (tester) async {
await showApp(tester);
assistants.answers.add(_says('Because it joins clauses.'));
await ask(tester, 'Why て-form?');
final text = tester.getRect(
find.text('Because it joins clauses.', findRichText: true),
);
final icon = tester.getRect(
find.descendant(
of: find.byTooltip('Copy the answer'),
matching: find.byType(Icon),
),
);
expect(icon.top, greaterThan(text.bottom));
expect(icon.left, closeTo(text.left, 1));
});
testWidgets("the conversation's sheet opens at the window's full height, "
'dragged by its handle', (tester) async {
await showApp(tester);
assistants.answers.add(_says('Because it joins clauses.'));
await ask(tester, 'Why て-form?');
final sheet = tester.getRect(find.byType(BottomSheet));
expect(sheet.top, lessThan(40));
expect(sheet.bottom, 900);
});
testWidgets('swiped down, the conversation folds into the button, kept; '
'the button opens it again, and Close ends it', (tester) async {
await showApp(tester);
assistants.answers.add(_says('Because it joins clauses.'));
await ask(tester, 'Why て-form?');
final answer = find.text('Because it joins clauses.', findRichText: true);
expect(answer, findsOneWidget);
await tester.drag(find.text('Claude').last, const Offset(0, 700));
await tester.pumpAndSettle();
expect(answer, findsNothing);
expect(find.byType(AssistantField), findsNothing);
await tester.tap(find.byTooltip('Back to the conversation'));
await tester.pumpAndSettle();
expect(answer, findsOneWidget);
expect(find.text('Why て-form?'), findsOneWidget);
expect(assistants.asked, hasLength(1), reason: 'asked once');
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(answer, findsNothing);
expect(find.byTooltip('Back to the conversation'), findsNothing);
expect(find.byTooltip('Ask'), findsOneWidget);
});
testWidgets('folded away, the conversation waits behind every button, on '
'another tab too; once closed, the next question starts anew', (
tester,
) async {
await showApp(tester);
assistants.answers
..add(_says('Because it joins clauses.'))
..add(_says('It marks the topic.'));
await ask(tester, 'Why て-form?');
await tester.drag(find.text('Claude').last, const Offset(0, 700));
await tester.pumpAndSettle();
await openTab(tester, 'Library');
await tester.tap(find.byTooltip('Back to the conversation'));
await tester.pumpAndSettle();
expect(find.text('Why て-form?'), findsOneWidget);
await tester.drag(find.text('Claude').last, const Offset(0, 700));
await tester.pumpAndSettle();
await openTab(tester, 'Home');
await tester.tap(find.byTooltip('Back to the conversation'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
await ask(tester, 'What does は do?');
await tester.drag(find.text('Claude').last, const Offset(0, 700));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Back to the conversation'));
await tester.pumpAndSettle();
expect(
find.text('It marks the topic.', findRichText: true),
findsOneWidget,
);
expect(find.text('Why て-form?'), findsNothing);
});
testWidgets('on the desktop the conversation opens in a dialog, however '
"narrow the window, with no handle to drag (Todo's ADR 0033, D11)", (
tester,
@ -506,7 +620,12 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('It joins.', findRichText: true), findsOneWidget);
expect(find.text('Ask more…'), findsOneWidget);
await tester.tap(find.byTooltip('Close'));
await tester.tap(
find.descendant(
of: find.byType(BottomSheet),
matching: find.byTooltip('Close'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Conversations'));

View file

@ -37,7 +37,10 @@ void main() {
);
expect(find.text('1.'), findsOneWidget);
expect(find.text('house; home'), findsOneWidget);
expect(
find.textContaining('house; home', findRichText: true),
findsOneWidget,
);
final tag = tester.widget<Text>(find.text('n,'));
expect(tag.style?.fontStyle, FontStyle.italic);
expect(tag.style?.fontSize, 16 * 0.85);
@ -78,4 +81,41 @@ void main() {
expect(notes.single.style?.fontStyle, FontStyle.italic);
expect(notes.single.style?.color, theme.colorScheme.onSurfaceVariant);
});
testWidgets("a sense's notes run on after its glosses, never on a line "
'of their own while they fit after the last word', (tester) async {
tester.view.physicalSize = const Size(300, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await show(
tester,
const Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 240,
child: VocabularySensesView(
senses: [
VocabularySense(
glosses: ['fondness for alcohol', 'drinker'],
pos: ['n'],
),
],
style: TextStyle(fontSize: 16),
centred: false,
),
),
),
);
final glosses = find.textContaining(
'fondness for alcohol; drinker',
findRichText: true,
);
final tag = tester.getRect(find.text('n'));
final text = tester.getRect(glosses);
// Two lines of glosses: the note on the second, after "drinker".
expect(text.height, greaterThan(30));
expect(tag.left, greaterThan(text.left + 20));
expect(tag.bottom, closeTo(text.bottom, 6));
});
}

View file

@ -719,7 +719,16 @@ void main() {
await tester.pump(const Duration(milliseconds: 700));
await tester.pumpAndSettle();
expect(find.byType(VocabularySensesView), findsOneWidget);
expect(find.text('station, as in sense 6 of a long entry'), findsOneWidget);
expect(
find.descendant(
of: find.byType(VocabularySensesView),
matching: find.textContaining(
'station, as in sense 6 of a long entry',
findRichText: true,
),
),
findsOneWidget,
);
// Left, it folds again.
await mouse.moveTo(Offset.zero);

View file

@ -3,6 +3,7 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_extras.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
@ -375,7 +376,15 @@ void main() {
await tester.tap(find.text('Hints'));
await tester.pumpAndSettle();
expect(find.text('Hints 2'), findsOneWidget);
// The count in a grey badge of its own, not read as the hint's place.
expect(find.text('Hints'), findsWidgets);
expect(
find.descendant(
of: find.byKey(hintsCountKey),
matching: find.text('2'),
),
findsOneWidget,
);
expect(find.text('1 / 2'), findsOneWidget);
expect(find.textContaining('a verb', findRichText: true), findsOneWidget);
await tester.tap(find.byTooltip('Next hint'));
@ -834,8 +843,11 @@ void main() {
await tester.tap(find.text('Show answer'));
await tester.pumpAndSettle();
expect(find.text('1.'), findsOneWidget);
expect(find.text('frog'), findsOneWidget);
expect(find.text('(zoology)'), findsOneWidget);
expect(find.textContaining('frog', findRichText: true), findsWidgets);
expect(
find.textContaining('(zoology) rain frog', findRichText: true),
findsOneWidget,
);
expect(find.byTooltip('usually written using kana alone'), findsOneWidget);
});