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
11 changed files with 421 additions and 7 deletions

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),
),
);