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
5 changed files with 719 additions and 169 deletions

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);
});