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
5 changed files with 719 additions and 169 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue