feat(todo): quick add's last button dictates the title while it is empty (ADR 0033, D29)

As Todoist's bar and Learn's assistant field (ADR 0021, D69): while the
title is empty its last button is the microphone, filled as the send
button is, dictating where the cursor stands and listening again after
each pause through halkyon_app's Dictation; what is said is read as if
typed. It shows a stop button while listening and the send button once
there is text, or alone where nothing recognises speech (Linux). A
phone without a recogniser is told how to get one.

Todo gets the device's speech input in its dependencies, and declares
the microphone and the recognition service on Android, the usage texts
on iOS and macOS and the audio input entitlement on macOS, as Learn
does.

Whats-New: Add task's last button is a microphone while the task has no name yet: say the task, dates included.
Whats-New-De: Der letzte Knopf von „Aufgabe hinzufügen“ ist ein Mikrofon, solange die Aufgabe noch keinen Namen hat: Sprich die Aufgabe, Daten eingeschlossen.

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 22:29:37 +01:00
commit aac7d106aa
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -57,6 +57,7 @@ void main() {
late MemorySealedRowGateway labelRows;
late MemorySealedRowGateway settingsRows;
late MemoryReminderGateway reminders;
late FakeSpeechInput speech;
/// How many syncs ran.
late int syncs;
@ -85,6 +86,7 @@ void main() {
labelRows = MemorySealedRowGateway();
settingsRows = MemorySealedRowGateway();
reminders = MemoryReminderGateway();
speech = FakeSpeechInput();
store = MemoryInstanceStore();
looks = MemoryLookStore();
gateway = FakeAuthGateway();
@ -161,6 +163,7 @@ void main() {
FakePublicProfileGateway(),
drawAvatar: (picture) async => picture,
),
speech: speech,
appVersion: (version: '0.1.0', build: 1),
sourceCodeUrl: () async =>
'https://code.halkyon.app/project-halkyon/halkyon',
@ -1548,6 +1551,81 @@ void main() {
expect(find.widgetWithText(QuickAddButton, 'Today'), findsNothing);
});
group('its last button the microphone while the title is empty, as '
"Todoist's (D29)", () {
Future<void> openEmpty(WidgetTester tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await tester.tap(find.byTooltip('Add task'));
await tester.pumpAndSettle();
}
testWidgets('the microphone while empty, sending once there is text', (
tester,
) async {
await openEmpty(tester);
expect(find.byTooltip('Dictate the task'), findsOneWidget);
expect(find.byTooltip('Add'), findsNothing);
await tester.enterText(find.byKey(quickAddTitleKey), 'Call');
await tester.pumpAndSettle();
expect(find.byTooltip('Dictate the task'), findsNothing);
expect(find.byTooltip('Add'), findsOneWidget);
});
testWidgets('words said go into the title, read as typed; stopped, '
'it sends', (tester) async {
await openEmpty(tester);
await tester.tap(find.byTooltip('Dictate the task'));
await tester.pumpAndSettle();
expect(speech.listening, isTrue);
expect(speech.languages, ['en-US'], reason: "the app's language");
speech.say('Buy milk tomorrow');
await tester.pumpAndSettle();
final title = tester
.widget<TextField>(find.byKey(quickAddTitleKey))
.controller!;
expect(title.text, 'Buy milk tomorrow');
expect(find.widgetWithText(QuickAddButton, 'Tomorrow'), findsOne);
await tester.tap(find.byTooltip('Stop listening'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
final stored = await tester.runAsync(() => tasks().all());
final content = stored!.single.content!;
expect(content.title, 'Buy milk');
expect(content.date?.day, Day.of(DateTime.now()).plus(1));
expect(find.byTooltip('Dictate the task'), findsOneWidget);
});
testWidgets('a device without speech recognition says how to get '
'one, and the microphone stays a microphone', (tester) async {
speech.hasRecognizer = false;
await openEmpty(tester);
await tester.tap(find.byTooltip('Dictate the task'));
await tester.pumpAndSettle();
expect(
find.textContaining('no speech recognition installed'),
findsOneWidget,
);
expect(find.byTooltip('Stop listening'), findsNothing);
expect(find.byTooltip('Dictate the task'), findsOneWidget);
});
testWidgets('where nothing listens (Linux), the send button alone', (
tester,
) async {
speech.supported = false;
await openEmpty(tester);
expect(find.byTooltip('Dictate the task'), findsNothing);
expect(find.byTooltip('Add'), findsOneWidget);
});
});
testWidgets('a token is highlighted while the keyboard still composes '
'it, the composing underline over it (Gboard, Japanese IMEs)', (
tester,