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
14 changed files with 236 additions and 5 deletions

File diff suppressed because one or more lines are too long

View file

@ -9,6 +9,11 @@
them off; without, Android may bring a reminder a few minutes late). -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- A task's title dictated in quick add (ADR 0033, D29): the microphone,
asked for once the person taps it, and the device's speech recogniser,
which Android 11 and later only shows the app when it is declared
below. -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- No backup and no transfer to another phone (ADR 0033, step 5, as
Train's ADR 0030, D53): the reminders scheduled, with their tasks'
titles, are kept unsealed. Through Android 11 allowBackup and
@ -85,5 +90,9 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- The device's speech recogniser, for dictation (ADR 0033, D29). -->
<intent>
<action android:name="android.speech.RecognitionService"/>
</intent>
</queries>
</manifest>

View file

@ -66,5 +66,9 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>To write a task you say aloud.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>To write a task you say aloud. Apple's speech recognition turns what you say into text.</string>
</dict>
</plist>

View file

@ -181,6 +181,9 @@
"swipeDate": "Datum",
"tasksMenu": "Mehr",
"quickAddMore": "Mehr",
"quickAddSpeak": "Aufgabe diktieren",
"quickAddStopListening": "Zuhören beenden",
"quickAddSpeechUnavailable": "Dieses Gerät kann gerade nicht zuhören. Prüfe, ob die App das Mikrofon nutzen darf und die Spracherkennung an ist.",
"quickAddTokenHint": "Tippe ein hervorgehobenes Wort an oder drücke direkt danach die Rücktaste, um es als Text zu behalten.",
"deadlineTitle": "Frist",
"orderChanged": "Reihenfolge geändert",

View file

@ -509,6 +509,18 @@
"@quickAddTokenHint": {
"description": "Below quick add's title while it has tokens recognised (D15)."
},
"quickAddSpeak": "Dictate the task",
"@quickAddSpeak": {
"description": "Tooltip of quick add's microphone, shown while the title is empty (D29)."
},
"quickAddStopListening": "Stop listening",
"@quickAddStopListening": {
"description": "Tooltip of quick add's microphone while it listens (D29)."
},
"quickAddSpeechUnavailable": "This device can't listen now. Check that the app may use the microphone and that speech recognition is on.",
"@quickAddSpeechUnavailable": {
"description": "Shown when quick add's microphone can't listen, as with the microphone refused (D29)."
},
"deadlineTitle": "Deadline",
"@deadlineTitle": {
"description": "Quick add's deadline chip and menu entry (D15)."

View file

@ -1192,6 +1192,24 @@ abstract class AppLocalizations {
/// **'Tap a highlighted word, or press Backspace right after it, to keep it as text.'**
String get quickAddTokenHint;
/// Tooltip of quick add's microphone, shown while the title is empty (D29).
///
/// In en, this message translates to:
/// **'Dictate the task'**
String get quickAddSpeak;
/// Tooltip of quick add's microphone while it listens (D29).
///
/// In en, this message translates to:
/// **'Stop listening'**
String get quickAddStopListening;
/// Shown when quick add's microphone can't listen, as with the microphone refused (D29).
///
/// In en, this message translates to:
/// **'This device can\'t listen now. Check that the app may use the microphone and that speech recognition is on.'**
String get quickAddSpeechUnavailable;
/// Quick add's deadline chip and menu entry (D15).
///
/// In en, this message translates to:

View file

@ -773,6 +773,16 @@ class AppLocalizationsDe extends AppLocalizations {
String get quickAddTokenHint =>
'Tippe ein hervorgehobenes Wort an oder drücke direkt danach die Rücktaste, um es als Text zu behalten.';
@override
String get quickAddSpeak => 'Aufgabe diktieren';
@override
String get quickAddStopListening => 'Zuhören beenden';
@override
String get quickAddSpeechUnavailable =>
'Dieses Gerät kann gerade nicht zuhören. Prüfe, ob die App das Mikrofon nutzen darf und die Spracherkennung an ist.';
@override
String get deadlineTitle => 'Frist';

View file

@ -773,6 +773,16 @@ class AppLocalizationsEn extends AppLocalizations {
String get quickAddTokenHint =>
'Tap a highlighted word, or press Backspace right after it, to keep it as text.';
@override
String get quickAddSpeak => 'Dictate the task';
@override
String get quickAddStopListening => 'Stop listening';
@override
String get quickAddSpeechUnavailable =>
'This device can\'t listen now. Check that the app may use the microphone and that speech recognition is on.';
@override
String get deadlineTitle => 'Deadline';

View file

@ -150,6 +150,10 @@ class TodoDependencies({
/// Opens a signed-in person's dependencies ([openPerson]).
required final OpenTodoPerson _openPerson,
/// Turns a task's title said aloud into its words, on the device (ADR
/// 0033, D29); none on Linux.
required final SpeechInput speech,
/// This build's version and number, which Settings shows at its foot so
/// that a report can name the release; none in tests that don't say.
final ({String version, int? build}) appVersion = (version: '', build: null),
@ -248,6 +252,7 @@ class TodoDependencies({
profiles: PublicProfileRepository(
BasePublicProfileGateway(client.modules.base),
),
speech: DeviceSpeechInput(),
appVersion: (version: device.appVersion, build: device.appBuild),
sourceCodeUrl: () async =>
(await client.modules.base.instance.basics()).sourceCodeUrl,

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -132,6 +134,13 @@ class _QuickAddState() extends State<_QuickAdd> {
Day? _deadline;
var _describing = false;
/// The title said aloud (D29), from the last button while the title is
/// empty.
late final _dictation = Dictation(
TodoDependencies.of(context).speech,
_title,
);
/// Where the add button was dropped, moving on below each task added.
late DropSpot? _spot = widget.spot;
@ -143,6 +152,7 @@ class _QuickAddState() extends State<_QuickAdd> {
@override
void dispose() {
_dictation.dispose();
_title
..removeListener(_changed)
..dispose();
@ -379,6 +389,7 @@ class _QuickAddState() extends State<_QuickAdd> {
return;
}
// Ready for the next one, in the same place and on the same day.
await _dictation.stop();
_title.clear();
_description.clear();
setState(() {
@ -612,10 +623,12 @@ class _QuickAddState() extends State<_QuickAdd> {
),
),
const SizedBox(width: 8),
IconButton.filled(
tooltip: l10n.taskAdd,
icon: const Icon(LucideIcons.arrowUp),
onPressed: typing && parsed.title.isNotEmpty ? _add : null,
ListenableBuilder(
listenable: _dictation,
builder: (context, _) => _speakOrSend(
context,
canSend: typing && parsed.title.isNotEmpty,
),
),
],
),
@ -625,6 +638,56 @@ class _QuickAddState() extends State<_QuickAdd> {
);
}
/// The last button (D29), as Todoist's and Learn's assistant field: while
/// the title is empty the microphone, where the device recognises
/// speech, dictating where the cursor stands and listening again after
/// each pause until stopped; once there is text, or where nothing
/// listens, the send button.
Widget _speakOrSend(BuildContext context, {required bool canSend}) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
if (_dictation.listening) {
return IconButton.filled(
tooltip: l10n.quickAddStopListening,
style: IconButton.styleFrom(
backgroundColor: scheme.primaryContainer,
foregroundColor: scheme.onPrimaryContainer,
),
icon: const Icon(LucideIcons.square),
onPressed: () => unawaited(_dictation.stop()),
);
}
if (_title.text.trim().isNotEmpty || !_dictation.supported) {
return IconButton.filled(
tooltip: l10n.taskAdd,
icon: const Icon(LucideIcons.arrowUp),
onPressed: canSend ? _add : null,
);
}
return IconButton.filled(
tooltip: l10n.quickAddSpeak,
icon: const Icon(LucideIcons.mic),
onPressed: () => unawaited(_listen()),
);
}
Future<void> _listen() async {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final words = HalkyonAppLocalizations.of(context);
final problem = switch (await _dictation.start(
language: Localizations.localeOf(context).toLanguageTag(),
)) {
SpeechStart.listening => null,
// A phone without Google's services has no recogniser installed.
SpeechStart.noRecognizer => words.speechNoRecognizer,
SpeechStart.unavailable => l10n.quickAddSpeechUnavailable,
};
if (problem != null) {
messenger.showSnackBar(SnackBar(content: Text(problem)));
}
}
Future<void> _pickDeadline(Day? current) async {
if (await pickDeadline(context, current: current) case (:final day)) {
_dropTokens(const {QuickAddKind.deadline});

View file

@ -12,5 +12,7 @@
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

View file

@ -28,5 +28,9 @@
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSMicrophoneUsageDescription</key>
<string>To write a task you say aloud.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>To write a task you say aloud. Apple's speech recognition turns what you say into text.</string>
</dict>
</plist>

View file

@ -8,5 +8,7 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

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,