feat(meditate): dreams spoken: a microphone in the dream's editor, and Today's Speak it

The editor dictates where the cursor stands with halkyon_app's Dictation,
showing it listens and the words heard, listening again after each pause
until stopped or three silent listens, in the app's language. No
microphone where the device has no recogniser (Linux). On Android it asks
for recognition on the device; the privacy page says what each platform
does. Android's, iOS's and macOS's microphone declarations as Learn's.
ADR 0038, D15.

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-03 14:47:35 +01:00
commit 433e544d44
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 421 additions and 7 deletions

View file

@ -3,6 +3,10 @@
<!-- The app talks to its instance: release builds need this too (Flutter's
template grants it to debug and profile builds only). -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- A dream spoken into the journal (ADR 0038, D15): 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 0038, D7, as
Train's ADR 0030, D53): what is sealed comes back from the
instance, and a backup would hold the device's opened copy. Through Android 11 allowBackup and
@ -66,5 +70,8 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<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 down a dream you tell.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>To write down a dream you tell. Apple's speech recognition turns what you say into text.</string>
</dict>
</plist>

View file

@ -166,5 +166,10 @@
"todayDreamPromptHint": "Bevor er verblasst. Versiegelt auf diesem Gerät.",
"settingsJournal": "Journal",
"dreamPromptTitle": "Nach dem Traum der letzten Nacht fragen",
"dreamPromptHint": "Unter Heute, von 4 bis 12 Uhr, bis du einen aufschreibst"
"dreamPromptHint": "Unter Heute, von 4 bis 12 Uhr, bis du einen aufschreibst",
"dreamDictate": "Diktieren",
"dreamStopDictating": "Diktat beenden",
"dreamListening": "Ich höre zu …",
"dreamSpeechUnavailable": "Dieses Gerät kann gerade nicht zuhören. Darf die App das Mikrofon nutzen?",
"todayDreamSpeak": "Erzählen"
}

View file

@ -254,5 +254,10 @@
"todayDreamPromptHint": "Before it fades. Sealed on this device.",
"settingsJournal": "Journal",
"dreamPromptTitle": "Ask for last night's dream",
"dreamPromptHint": "On Today, from 4 to 12 o'clock, until you write one"
"dreamPromptHint": "On Today, from 4 to 12 o'clock, until you write one",
"dreamDictate": "Dictate",
"dreamStopDictating": "Stop dictating",
"dreamListening": "Listening…",
"dreamSpeechUnavailable": "This device can't listen now. Does the app have the microphone's permission?",
"todayDreamSpeak": "Speak it"
}

View file

@ -1101,6 +1101,36 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'On Today, from 4 to 12 o\'clock, until you write one'**
String get dreamPromptHint;
/// No description provided for @dreamDictate.
///
/// In en, this message translates to:
/// **'Dictate'**
String get dreamDictate;
/// No description provided for @dreamStopDictating.
///
/// In en, this message translates to:
/// **'Stop dictating'**
String get dreamStopDictating;
/// No description provided for @dreamListening.
///
/// In en, this message translates to:
/// **'Listening…'**
String get dreamListening;
/// No description provided for @dreamSpeechUnavailable.
///
/// In en, this message translates to:
/// **'This device can\'t listen now. Does the app have the microphone\'s permission?'**
String get dreamSpeechUnavailable;
/// No description provided for @todayDreamSpeak.
///
/// In en, this message translates to:
/// **'Speak it'**
String get todayDreamSpeak;
}
class _AppLocalizationsDelegate

View file

@ -591,4 +591,20 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dreamPromptHint =>
'Unter Heute, von 4 bis 12 Uhr, bis du einen aufschreibst';
@override
String get dreamDictate => 'Diktieren';
@override
String get dreamStopDictating => 'Diktat beenden';
@override
String get dreamListening => 'Ich höre zu …';
@override
String get dreamSpeechUnavailable =>
'Dieses Gerät kann gerade nicht zuhören. Darf die App das Mikrofon nutzen?';
@override
String get todayDreamSpeak => 'Erzählen';
}

View file

@ -587,6 +587,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dreamPromptHint =>
'On Today, from 4 to 12 o\'clock, until you write one';
@override
String get dreamDictate => 'Dictate';
@override
String get dreamStopDictating => 'Stop dictating';
@override
String get dreamListening => 'Listening…';
@override
String get dreamSpeechUnavailable =>
'This device can\'t listen now. Does the app have the microphone\'s permission?';
@override
String get todayDreamSpeak => 'Speak it';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
show Client, UuidValue;
import 'package:halkyon_meditate_flutter/src/core/device/no_speech.dart';
import 'package:halkyon_meditate_flutter/src/core/device/screen_awake.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/sync_content_key_gateway.dart';
@ -51,6 +52,10 @@ class MeditatePerson(
/// The device's screen, kept on while sitting; nothing in tests that
/// don't say.
final ScreenAwake screen = const NoScreenAwake(),
/// The device's speech recognition, for dictating a dream (D15); none in
/// tests that don't say.
final SpeechInput speech = const NoSpeechInput(),
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// Closes what was opened for the person.
@ -236,6 +241,7 @@ class MeditateDependencies({
accountKeys: () => accountKeys.read(person.authUserId),
bells: device.bells,
screen: device.screen,
speech: device.speech,
onClosed: closed.complete,
);
} on Object {
@ -273,6 +279,7 @@ Future<MeditatePerson> _openPersonData(
required Future<UnlockedAccountKeys?> Function() accountKeys,
required BellPlayer bells,
required ScreenAwake screen,
required SpeechInput speech,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
@ -314,6 +321,7 @@ Future<MeditatePerson> _openPersonData(
dreams: dreams,
bells: bells,
screen: screen,
speech: speech,
onDispose: () async {
try {
await settings.dispose();
@ -351,7 +359,17 @@ class MeditateDevice({
/// The screen, kept on while sitting (D6).
final ScreenAwake screen = const NoScreenAwake(),
/// The device's speech recognition, for dictating a dream (D15).
final SpeechInput speech = const NoSpeechInput(),
}) {
/// How long the recogniser may wait through a pause while a dream is
/// told, and how long one listen may last: as long as the platform allows
/// (Android stops sooner whatever is asked), Dictation listening again
/// after each (D15).
static const dictationPause = Duration(seconds: 30);
static const dictationListen = Duration(minutes: 10);
/// Makes them, [builtIn] being the instance the build names.
static Future<MeditateDevice> create({required Instance builtIn}) async {
final package = await PackageInfo.fromPlatform();
@ -363,6 +381,12 @@ class MeditateDevice({
appVersion: package.version,
bells: AudioplayersBellPlayer(),
screen: const WakelockScreenAwake(),
// On the device where Android can (D15): a dream is the person's own.
speech: DeviceSpeechInput(
preferOnDevice: true,
pauseFor: dictationPause,
listenFor: dictationListen,
),
);
}
}

View file

@ -16,6 +16,9 @@ abstract final class MeditatePaths() {
/// A new dream to write, over the tabs.
static const newDream = '/dream';
/// A new dream, already listening (D15).
static const spokenDream = '/dream?speak=1';
/// The dream [id], to read and edit, over the tabs.
static String dream(String id) => '/dream/$id';

View file

@ -78,7 +78,11 @@ GoRouter buildMeditateRouter() => GoRouter(
// Settings: writing on waking wants the whole screen.
GoRoute(
path: MeditatePaths.newDream,
builder: (context, state) => const FramedScreen(child: DreamScreen()),
builder: (context, state) => FramedScreen(
child: DreamScreen(
speak: state.uri.queryParameters['speak'] == '1',
),
),
routes: [
GoRoute(
path: ':id',

View file

@ -0,0 +1,19 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_app/halkyon_app.dart';
/// A device that recognises no speech, as tests have that don't say: no
/// microphone button shows.
class const NoSpeechInput() implements SpeechInput {
@override
bool get supported => false;
@override
Future<bool> listen({
required String? language,
required ValueChanged<String> onWords,
required VoidCallback onDone,
}) async => false;
@override
Future<void> stop() async {}
}

View file

@ -3,7 +3,9 @@ import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_dependencies.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/bloc/journal_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.dart';
@ -21,10 +23,15 @@ const _labelsOffered = 8;
/// the text first and focused, everything else folded below it, as
/// writing on waking should be quick; a kept one ([dreamId]) opens to
/// read, with Edit and Delete (asked first). Leaving a dream written but
/// not saved asks first.
/// not saved asks first. Where the device recognises speech, a microphone
/// under the text dictates into it (D15).
class const DreamScreen({
/// The dream kept under this ID; none for a new one.
final String? dreamId,
/// Whether a new dream opens already listening (Today's *Speak it*,
/// D15), where the device recognises speech.
final bool speak = false,
super.key,
}) extends StatefulWidget {
@override
@ -45,6 +52,13 @@ class _DreamScreenState() extends State<DreamScreen> {
/// What the editor started from, to tell whether anything changed.
Dream? _started;
/// What is said, written into the text where the cursor stands (D15).
Dictation get _dictation => _dictationMade ??= Dictation(
PersonScope.of<MeditatePerson>(context).speech,
_text,
);
Dictation? _dictationMade;
@override
void initState() {
super.initState();
@ -53,10 +67,18 @@ class _DreamScreenState() extends State<DreamScreen> {
if (_editing) {
_started = _draft();
}
if (widget.speak) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _dictation.supported) {
unawaited(_dictate());
}
});
}
}
@override
void dispose() {
_dictationMade?.dispose();
_text.dispose();
_title.dispose();
_label.dispose();
@ -65,6 +87,20 @@ class _DreamScreenState() extends State<DreamScreen> {
void _changed() => setState(() {});
/// Listens in the app's language (the device's own where it has none of
/// it), saying so where the device can't.
Future<void> _dictate() async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
if (!await _dictation.start(
language: Localizations.localeOf(context).toLanguageTag(),
)) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.dreamSpeechUnavailable)),
);
}
}
/// The dream as the editor holds it, written at [writtenAt] (the first
/// writing's, for one kept).
Dream _draft({DateTime? writtenAt}) => Dream(
@ -98,6 +134,8 @@ class _DreamScreenState() extends State<DreamScreen> {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final journal = context.read<JournalCubit>();
// What was heard stays; nothing more is written into what is saved.
await _dictationMade?.stop();
try {
if (widget.dreamId case final id?) {
await journal.update(id, _draft());
@ -250,6 +288,8 @@ class _DreamScreenState() extends State<DreamScreen> {
border: const OutlineInputBorder(),
),
),
// Where the device recognises speech alone, as Learn's field.
if (_dictation.supported) _DictationRow(_dictation, onStart: _dictate),
const SizedBox(height: 8),
// Folded for a new dream: the text is what matters on waking.
ExpansionTile(
@ -487,6 +527,64 @@ class const _DreamReading(final Dream dream) extends StatelessWidget {
}
}
/// The microphone under a dream's text (D15): tapped, it listens, and says
/// so with the words heard so far; tapped again, it stops. A pause doesn't
/// stop it: [Dictation] listens again while it is on.
class const _DictationRow(
final Dictation dictation, {
required final VoidCallback onStart,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return ListenableBuilder(
listenable: dictation,
builder: (context, _) {
final listening = dictation.listening;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
if (listening)
IconButton.filled(
tooltip: l10n.dreamStopDictating,
icon: const Icon(LucideIcons.square),
onPressed: () => unawaited(dictation.stop()),
)
else
IconButton.filledTonal(
tooltip: l10n.dreamDictate,
icon: const Icon(LucideIcons.mic),
onPressed: onStart,
),
const SizedBox(width: 12),
Expanded(
child: Text(
listening
? [
l10n.dreamListening,
if (dictation.heard.isNotEmpty) dictation.heard,
].join(' ')
: l10n.dreamDictate,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: listening
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
fontStyle: listening ? FontStyle.italic : null,
),
),
),
],
),
);
},
);
}
}
/// A part of the editor's heading.
class const _Heading(final String text) extends StatelessWidget {
@override

View file

@ -1,7 +1,9 @@
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_dependencies.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_paths.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/bloc/journal_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/journal.dart';
@ -143,18 +145,26 @@ String _greeting(AppLocalizations l10n, int hour) => switch (hour) {
};
/// The morning's gentle card (D14): last night's dream, to write down
/// before it fades.
/// before it fades, or where the device recognises speech to tell (*Speak
/// it*, D15), the new dream opening already listening.
class const _DreamPrompt() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final speaks = PersonScope.of<MeditatePerson>(context).speech.supported;
return SectionCard(
padding: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: const Icon(LucideIcons.sunrise),
title: Text(l10n.todayDreamPrompt),
subtitle: Text(l10n.todayDreamPromptHint),
trailing: const Icon(LucideIcons.notebookPen),
trailing: speaks
? TextButton.icon(
icon: const Icon(LucideIcons.mic),
label: Text(l10n.todayDreamSpeak),
onPressed: () => context.push(MeditatePaths.spokenDream),
)
: const Icon(LucideIcons.notebookPen),
onTap: () => context.push(MeditatePaths.newDream),
),
);

View file

@ -6,6 +6,8 @@
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.client</key>

View file

@ -28,5 +28,9 @@
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSMicrophoneUsageDescription</key>
<string>To write down a dream you tell.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>To write down a dream you tell. 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

@ -31,6 +31,7 @@ void main() {
late MemorySealedRowGateway settingsRows;
late MemorySealedRowGateway sittingRows;
late MemorySealedRowGateway dreamRows;
late FakeSpeechInput speech;
late RecordingBells bells;
late RecordingScreen screen;
@ -54,6 +55,7 @@ void main() {
settingsRows = MemorySealedRowGateway();
sittingRows = MemorySealedRowGateway();
dreamRows = MemorySealedRowGateway();
speech = FakeSpeechInput();
bells = RecordingBells();
screen = RecordingScreen();
store = MemoryInstanceStore();
@ -150,6 +152,7 @@ void main() {
dreams: dreams(),
bells: bells,
screen: screen,
speech: speech,
);
},
),
@ -931,6 +934,113 @@ void main() {
});
});
group('dictating a dream (ADR 0038, D15)', () {
/// Opens a new dream from the Journal's +.
Future<void> newDream(WidgetTester tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Journal');
await tester.tap(find.byTooltip('Write down a dream'));
await tester.pumpAndSettle();
}
final text = find.byType(TextField).first;
testWidgets('the microphone writes what is said where the cursor stands, '
"in the app's language, showing it listens; tapped again, it stops", (
tester,
) async {
await newDream(tester);
await tester.enterText(text, 'I was there.');
tester.widget<TextField>(text).controller!.selection =
const TextSelection.collapsed(offset: 5);
await tester.tap(find.byTooltip('Dictate'));
await tester.pump();
expect(speech.languages, [startsWith('en')]);
expect(find.byTooltip('Stop dictating'), findsOneWidget);
expect(find.text('Listening…'), findsOneWidget);
speech.say('at the sea');
await tester.pump();
expect(find.text('Listening… at the sea'), findsOneWidget);
expect(
tester.widget<TextField>(text).controller!.text,
'I was at the sea there.',
);
await tester.tap(find.byTooltip('Stop dictating'));
await tester.pump();
expect(speech.listening, isFalse);
expect(find.byTooltip('Dictate'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
expect(
(await dreams().all()).single.dream.text,
'I was at the sea there.',
);
});
testWidgets("the recogniser's pause doesn't end it: it listens on, the "
'next words after the last, until stopped', (tester) async {
await newDream(tester);
await tester.tap(find.byTooltip('Dictate'));
await tester.pump();
speech
..say('A house')
..finish();
await tester.pump();
expect(speech.listening, isTrue);
expect(find.byTooltip('Stop dictating'), findsOneWidget);
speech.say('by the sea');
await tester.pump();
expect(
tester.widget<TextField>(text).controller!.text,
'A house by the sea',
);
});
testWidgets("where the device can't listen now, it says so", (
tester,
) async {
speech.listens = false;
await newDream(tester);
await tester.tap(find.byTooltip('Dictate'));
await tester.pumpAndSettle();
expect(find.textContaining("can't listen now"), findsOneWidget);
expect(find.byTooltip('Dictate'), findsOneWidget);
});
testWidgets('where the device recognises no speech, there is no '
'microphone, here or on Today', (tester) async {
speech.supported = false;
await withClock(Clock.fixed(DateTime(2026, 11, 3, 8)), () async {
await start(tester, usedBefore: true);
expect(find.text('Speak it'), findsNothing);
});
await goTab(tester, 'Journal');
await tester.tap(find.byTooltip('Write down a dream'));
await tester.pumpAndSettle();
expect(find.byTooltip('Dictate'), findsNothing);
});
testWidgets("Today's Speak it opens a new dream already listening", (
tester,
) async {
await withClock(Clock.fixed(DateTime(2026, 11, 3, 8)), () async {
await start(tester, usedBefore: true);
await tester.tap(find.text('Speak it'));
await tester.pumpAndSettle();
});
expect(find.widgetWithText(AppBar, 'New dream'), findsOneWidget);
expect(speech.listening, isTrue);
expect(find.byTooltip('Stop dictating'), findsOneWidget);
});
});
testWidgets('signed out, it asks how to start, with an account to create '
'where passwords are offered (ADR 0017), and no GitHub (ADR 0020, '
'D36)', (tester) async {