feat(learn,app): the field's row as one, its model by name in its provider's colour, the microphone dictating (ADR 0021, D69)

The developer's review of 4 Nov 2026. Below the question, × and + stand
at the start, who answers is centred and the conversations and the
microphone stand at the end, every button the same rounded square, the
microphone listening and Send too. The row above the text ("Only what I
choose · 2 parts") goes, as +'s sheet offers the same choice; a web
search gets + with that row alone. Who answers shows its model's name
alone ('Haiku 4.5', AssistantProvider.modelName: Anthropic's without
"Claude", others as they are), outlined in its provider's colour (D60).
The microphone is halkyon_app's Dictation: what is said goes where the
cursor stands, and it listens again after each pause until stopped. It
gives way to Send once anything could be sent, as Claude's field does.

Whats-New: The question's buttons are in one row: close and + on the left, who answers in the middle, Conversations and the microphone on the right.
Whats-New: What goes along is chosen under +, for the web search too.
Whats-New: Who answers shows the model's name, in its provider's colour.
Whats-New: The microphone keeps listening after a pause, writing where the cursor is, until you stop it.
Whats-New-De: Die Knöpfe der Frage stehen in einer Reihe: Schließen und + links, wer antwortet in der Mitte, Unterhaltungen und Mikrofon rechts.
Whats-New-De: Was mitgeht, wählst du unter +, auch für die Websuche.
Whats-New-De: Wer antwortet, zeigt den Namen des Modells, in der Farbe seines Anbieters.
Whats-New-De: Das Mikrofon hört nach einer Pause weiter zu und schreibt, wo der Cursor steht, bis du es stoppst.

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-04 20:57:46 +01:00
commit b4b27a1d0c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 488 additions and 305 deletions

View file

@ -380,6 +380,32 @@ void main() {
expect(onDevice, ['what does 火 mean']);
});
testWidgets('the microphone listens again after a pause until stopped, '
'each listen going where the cursor stands (D69)', (tester) async {
await showApp(tester);
await unfold(tester);
await tester.tap(find.byTooltip('Speak your question'));
await tester.pump();
speech.say('what does 火 mean');
await tester.pump();
// The cursor placed at the start before the pause, the next listen's
// words go there.
tester.widget<TextField>(find.byType(TextField)).controller!.selection =
const TextSelection.collapsed(offset: 0);
speech.finish();
await tester.pump();
expect(find.byTooltip('Stop listening'), findsOneWidget, reason: 'on');
expect(speech.listening, isTrue, reason: 'listening again');
speech.say('Tell me');
await tester.pump();
expect(find.text('Tell me what does 火 mean'), findsOneWidget);
await tester.tap(find.byTooltip('Stop listening'));
await tester.pump();
expect(speech.listening, isFalse);
expect(find.byTooltip('Search'), findsOneWidget);
});
testWidgets('says so where no speech recognition is installed, and how '
'to get one, never stuck listening (GrapheneOS, 4 Nov 2026)', (
tester,
@ -617,10 +643,13 @@ void main() {
await showApp(tester, location: RoutePaths.study('japanese'));
await unfold(tester);
// What goes along is one row above the field, opening its sheet (D67).
expect(find.text('Send along'), findsOneWidget);
// What goes along is +'s one row for a search, opening its sheet (D69).
expect(find.text('Send along'), findsNothing);
await tester.tap(find.byTooltip('What goes along'));
await tester.pumpAndSettle();
expect(find.text('Files'), findsNothing, reason: 'a search takes none');
await tester.tap(find.text('What goes along').last);
await tester.pumpAndSettle();
expect(find.text('Studyset: Japanese'), findsNothing);
expect(find.text('How far it looks'), findsNothing);
await tester.tap(find.text('Front: ひ'));
@ -628,7 +657,11 @@ void main() {
await tester.pump();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text(' · 2 parts'), findsOneWidget);
await tester.tap(find.byTooltip('What goes along'));
await tester.pumpAndSettle();
expect(find.text('2 parts'), findsOneWidget, reason: "+'s row says so");
await tester.tapAt(const Offset(200, 20));
await tester.pumpAndSettle();
await send(tester, 'mnemonic');
expect(searched.last.queryParameters['q'], 'mnemonic ひ hi');
searched.clear();
@ -636,6 +669,8 @@ void main() {
await unfold(tester);
await tester.tap(find.byTooltip('What goes along'));
await tester.pumpAndSettle();
await tester.tap(find.text('What goes along').last);
await tester.pumpAndSettle();
await tester.tap(find.textContaining('Hint 1: ひらがな'));
await tester.pump();
await tester.tap(find.text('Done'));

View file

@ -255,9 +255,16 @@ void main() {
await tester.pumpAndSettle();
}
/// Opens the field's sheet of what goes along (D67).
/// Opens the field's sheet of what goes along, through + (D67, D69).
Future<void> openContext(WidgetTester tester) async {
await tester.tap(find.byTooltip('What goes along'));
await tester.tap(
find.descendant(
of: find.byType(AssistantField),
matching: find.widgetWithIcon(IconButton, LucideIcons.plus),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('What goes along').last);
await tester.pumpAndSettle();
}
@ -339,15 +346,17 @@ void main() {
);
});
testWidgets('the buttons right of who answers stay where they are '
'whatever its name, and every button of the field is boxed, as '
"Claude's (the developer's review of 3 Nov 2026)", (tester) async {
testWidgets('the row below the field: close and + at the start, who '
"answers centred by its model alone, outlined in its provider's "
'colour, the conversations and the microphone at the end, each where '
'it is whatever the name; every button boxed, listening and Send too '
'(D69)', (tester) async {
assistants.offer = AssistantsOffer(
assistants: [
_claude(),
OfferedAssistant(
id: 'opus',
name: 'Claude Opus, thinking longer',
name: 'Claude Opus 4.1',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus',
@ -356,41 +365,79 @@ void main() {
],
);
await showApp(tester);
Rect at(String tooltip) => tester.getRect(
find.descendant(
of: find.byType(AssistantField),
matching: find.byTooltip(tooltip),
),
Finder inField(Finder finder) =>
find.descendant(of: find.byType(AssistantField), matching: finder);
Rect at(String tooltip) => tester.getRect(inField(find.byTooltip(tooltip)));
final field = tester.getRect(find.byType(AssistantField));
final close = at('Close');
final plus = tester.getRect(
inField(find.widgetWithIcon(IconButton, LucideIcons.plus)),
);
final history = at('Conversations');
final speak = at('Speak your question');
final field = tester.getRect(find.byType(AssistantField));
expect(field.right - speak.right, lessThan(16), reason: 'at the right');
expect(close.left - field.left, lessThan(16), reason: 'at the start');
expect(close.right, lessThan(plus.left), reason: '+ after it');
expect(field.right - speak.right, lessThan(16), reason: 'at the end');
expect(history.right, lessThan(speak.left), reason: 'the history before');
await openPicker(tester);
await tester.tap(find.text('Claude Opus, thinking longer').last);
await tester.tap(find.text('Claude Opus 4.1').last);
await tester.pumpAndSettle();
expect(find.text('Ask Claude Opus, thinking longer…'), findsOneWidget);
expect(find.text('Ask Claude Opus 4.1…'), findsOneWidget);
final picker = inField(find.byTooltip('Who answers'));
expect(
find.descendant(of: picker, matching: find.text('Opus 4.1')),
findsOneWidget,
reason: 'the model alone, its provider by the colour',
);
expect(tester.getRect(picker).center.dx, closeTo(field.center.dx, 2));
final outline = tester
.widgetList<Material>(
find.descendant(of: picker, matching: find.byType(Material)),
)
.map((material) => material.shape)
.whereType<RoundedRectangleBorder>()
.first
.side
.color;
expect(
outline,
providerColorIn(
tester.element(picker),
AssistantProvider.anthropic,
),
);
expect(at('Close'), close);
expect(at('Conversations'), history);
expect(at('Speak your question'), speak);
void expectBoxed(Finder button, String what) => expect(
tester.widget<IconButton>(button).style?.backgroundColor?.resolve({}),
isNotNull,
reason: '$what boxed',
);
for (final icon in [
LucideIcons.x,
LucideIcons.plus,
LucideIcons.history,
LucideIcons.mic,
]) {
final button = tester.widget<IconButton>(
find.descendant(
of: find.byType(AssistantField),
matching: find.widgetWithIcon(IconButton, icon),
),
);
expect(
button.style?.backgroundColor?.resolve({}),
isNotNull,
reason: '$icon boxed',
);
expectBoxed(inField(find.widgetWithIcon(IconButton, icon)), '$icon');
}
await tester.tap(find.byTooltip('Speak your question'));
await tester.pump();
expectBoxed(
inField(find.widgetWithIcon(IconButton, LucideIcons.square)),
'listening',
);
speech.say('Why');
await tester.tap(find.byTooltip('Stop listening'));
await tester.pump();
expectBoxed(
inField(find.widgetWithIcon(IconButton, LucideIcons.arrowUp)),
'Send',
);
expect(at('Ask'), speak, reason: 'Send in the place of the microphone');
});
testWidgets("an answer's Copy stands beneath it, its icon in line with "
@ -665,7 +712,11 @@ void main() {
) async {
await showApp(tester, location: RoutePaths.studyset('japanese'));
await unfold(tester);
expect(find.text('Only what I choose'), findsOneWidget);
expect(
find.text('Only what I choose'),
findsNothing,
reason: 'behind + alone (D69)',
);
await openContext(tester);
expect(find.text('Studyset: Japanese'), findsOneWidget);
@ -678,7 +729,11 @@ void main() {
await tester.tap(find.text('This studyset with its topics'));
await tester.pumpAndSettle();
await closeContext(tester);
expect(find.text('This studyset'), findsOneWidget);
await tester.tap(find.widgetWithIcon(IconButton, LucideIcons.plus));
await tester.pumpAndSettle();
expect(find.text('This studyset'), findsOneWidget, reason: "+'s row");
await tester.tapAt(const Offset(200, 20));
await tester.pumpAndSettle();
assistants.answers.add(_says('Verbs, mostly.'));
await ask(tester, 'What is in here?');
@ -880,17 +935,17 @@ void main() {
tester,
) async {
await showApp(tester);
Finder inField(String text) => find.descendant(
of: find.byType(AssistantField),
matching: find.text(text),
);
await openContext(tester);
await tester.tap(find.text('My whole library'));
await tester.pumpAndSettle();
await tester.tap(find.text('Include private studysets'));
await tester.pumpAndSettle();
await closeContext(tester);
expect(inField('Library, private ones too'), findsOneWidget);
await tester.tap(find.widgetWithIcon(IconButton, LucideIcons.plus));
await tester.pumpAndSettle();
expect(find.text('Library, private ones too'), findsOneWidget);
await tester.tapAt(const Offset(200, 20));
await tester.pumpAndSettle();
await ask(tester, 'What do I study?');
@ -1358,6 +1413,22 @@ void main() {
..add(_says('Lines one and two.'));
await ask(tester, 'Why て-form?');
Finder inSheet(String tooltip) => find.descendant(
of: find.byType(BottomSheet),
matching: find.byTooltip(tooltip),
);
await tester.tap(inSheet('Speak your question'));
await tester.pump();
speech.say('And these?');
await tester.pump();
await tester.tap(inSheet('Stop listening'));
await tester.pump();
expect(
find.widgetWithText(TextField, 'And these?'),
findsOneWidget,
reason: 'dictated into it',
);
picker.next = (name: 'lines.txt', bytes: bytesOf('One.\nTwo.'));
await add(
tester,
@ -1366,18 +1437,6 @@ void main() {
within: find.byType(BottomSheet),
);
expect(find.widgetWithText(InputChip, 'lines.txt'), findsOneWidget);
expect(
find.descendant(
of: find.byType(BottomSheet),
matching: find.byTooltip('Speak your question'),
),
findsOneWidget,
);
await tester.enterText(
find.widgetWithText(TextField, 'Ask more…'),
'And these?',
);
await tester.pump();
await tester.tap(
find.descendant(
of: find.byType(BottomSheet),