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:
parent
603218ef96
commit
433e544d44
11 changed files with 421 additions and 7 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue