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:
parent
1aa679fe3e
commit
138c4dd46e
14 changed files with 719 additions and 169 deletions
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue