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
20 changed files with 421 additions and 7 deletions
|
|
@ -4,7 +4,7 @@ What holds for every app and package of this repository is in the [root AGENTS.m
|
||||||
|
|
||||||
- **Packages**: `halkyon_meditate_server`, `halkyon_meditate_client` (generated, never edited), `halkyon_meditate_flutter`; app ID `app.halkyon.meditate` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`). Copied from Todo's tree on 3 Nov 2026 and stripped to the shell, so its frame, keys and sealed rows are Todo's and Train's.
|
- **Packages**: `halkyon_meditate_server`, `halkyon_meditate_client` (generated, never edited), `halkyon_meditate_flutter`; app ID `app.halkyon.meditate` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`). Copied from Todo's tree on 3 Nov 2026 and stripped to the shell, so its frame, keys and sealed rows are Todo's and Train's.
|
||||||
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`meditateBase()`, `lib/src/meditate_base.dart`): its basics say it serves `meditate` (never change it once apps use it), sign-in mails name Halkyon Meditate, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteMeditateData]`, `lib/src/meditate_data.dart`). **Accounts** (ADR 0016): Meditate holds its own register when it runs alone, and is a member of the instance's register where the operator sets `ACCOUNT_REGISTER_URL` (`AccountsMode.fromEnvironment()`) with the instance's shared `INSTANCE_SECRET`; `INSTANCE_MEMBER_MEDITATE` names it to the register. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled once each. Synced tables (ADR 0038, D7; `database: sync`, every row sealed): `content_key`, `meditate_settings` (one row a person, under an ID keyed by the account), `sitting` and `dream` (D14), each indexing `spaceId`. Migrations: `initial` (Serverpod's, the auth modules', the module's and the sync engine's tables), `sittings` (the three tables) and `dreams` (`dream`). Tests: `test/integration/meditate_server_test.dart` (the module's endpoints through the server's own dispatch, `halkyon_base.<endpoint>`, and the sync engine's connector refusing calls without a session), `test/integration/sitting/person_deletion_test.dart`.
|
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`meditateBase()`, `lib/src/meditate_base.dart`): its basics say it serves `meditate` (never change it once apps use it), sign-in mails name Halkyon Meditate, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteMeditateData]`, `lib/src/meditate_data.dart`). **Accounts** (ADR 0016): Meditate holds its own register when it runs alone, and is a member of the instance's register where the operator sets `ACCOUNT_REGISTER_URL` (`AccountsMode.fromEnvironment()`) with the instance's shared `INSTANCE_SECRET`; `INSTANCE_MEMBER_MEDITATE` names it to the register. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled once each. Synced tables (ADR 0038, D7; `database: sync`, every row sealed): `content_key`, `meditate_settings` (one row a person, under an ID keyed by the account), `sitting` and `dream` (D14), each indexing `spaceId`. Migrations: `initial` (Serverpod's, the auth modules', the module's and the sync engine's tables), `sittings` (the three tables) and `dreams` (`dream`). Tests: `test/integration/meditate_server_test.dart` (the module's endpoints through the server's own dispatch, `halkyon_base.<endpoint>`, and the sync engine's connector refusing calls without a session), `test/integration/sitting/person_deletion_test.dart`.
|
||||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Meditate's frame (`meditateFrame`) and `ServerpodInstanceChecker(app: 'meditate')`; `MeditateDependencies` per instance, `MeditatePerson` per person (their sync, `SettingsRepository`, `SittingRepository`, `DreamRepository`, and the device's `BellPlayer` and `ScreenAwake`); SimpleLogin's way back on `app.halkyon.meditate://` and the loopback port 43826. The person's app (`MeditatePersonApp`) washes in fern (`ScreenColor`, D3) and holds `PracticeCubit` (settings and sittings, read again on every change) and `JournalCubit` (the dreams) for every screen. go_router (`buildMeditateRouter`, `MeditatePaths`): halkyon_ui's `NavigationFrame` (`MeditateShell`) with Today, Explore, Journal and Profile (D2, D14; a bar below medium windows, a rail with the icon from medium on), a practice's page in Explore's branch (`PracticeScreen`), and over the tabs the sitting (`SittingScreen`, handed its `SittingPlan` as `extra`), a dream (`DreamScreen`, `/dream` new, `/dream/:id` kept) and Settings. **Today** (`TodayTab`, D9): greeting by the hour, from 4 to 12 o'clock a card to write down last night's dream while it has none (`dreamPromptDue`, D14), Sit now (5, 10, 20 and the timer's own length), the practice of the day (`practiceOfDay`), the week and the days in a row (`WeekCard`), favourites, recently sat. **Explore** (`ExploreTab`, D10): search folded without case or accents (`matchesSearch`), four goal tiles, kind chips, practice cards. **The Journal** (`JournalTab`, D14; `lib/src/feature/journal/`): dreams by month, the newest first (`dreamsByMonth`), searched over title, text and labels and filtered by marks (`findDreams`), *+* to write one; a dream's night is the date of the morning after it (`defaultNight`: today's date at any hour); its screen writes with the text first and focused, the rest folded, and reads a kept one with Edit and Delete. **Profile** (D2, D8): halkyon_app's `HalkyonProfile` with a *Practice* tab (minutes, sittings, days, the week, the journal's dreams this month and lucid ones once it holds one, every sitting with Delete). **The practices** are `practices` in `lib/src/feature/practice/model/practice.dart` (IDs never change once released), their words in the ARBs by ID (`practiceTexts`), their cues spread evenly over the length (`Practice.cueAt`). **A sitting** (`SittingCubit`, D6): its time from `clock.now()`, the ticks only showing it; the bell at the start (where on), between every interval and at the end; the screen kept on while it runs (where on); kept whole at its end, and ended early kept from a minute on where the person says so. **The figures** (`practiceFigures`, D8) on the device: a sitting's day by the wall clock it was sat by (`Sitting.offsetMinutes`), the streak up to today or yesterday, the week from the language's first day. **Settings** (D11): Sync, the operator's card, *Sitting* (bell, starting bell, bells between, screen on, the timer's length), *Practice* (the streak shown), *Journal* (Today's morning card, `dreamPrompt`), the account, What's new, About, the version. Texts in British English (the template) and German (du), `en_US` and `en_GB` empty. Widget tests start the whole app with halkyon_app's fakes (`test/app/meditate_app_test.dart`), the bells and screen recorded (`test/support/recording_device.dart`); a sitting on screen ticks every second, so tests pump seconds (`sit`) rather than `pumpAndSettle`; a test needing the hour (Today's morning card) starts the app inside `withClock` (`startAt`).
|
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Meditate's frame (`meditateFrame`) and `ServerpodInstanceChecker(app: 'meditate')`; `MeditateDependencies` per instance, `MeditatePerson` per person (their sync, `SettingsRepository`, `SittingRepository`, `DreamRepository`, and the device's `BellPlayer` and `ScreenAwake`); SimpleLogin's way back on `app.halkyon.meditate://` and the loopback port 43826. The person's app (`MeditatePersonApp`) washes in fern (`ScreenColor`, D3) and holds `PracticeCubit` (settings and sittings, read again on every change) and `JournalCubit` (the dreams) for every screen. go_router (`buildMeditateRouter`, `MeditatePaths`): halkyon_ui's `NavigationFrame` (`MeditateShell`) with Today, Explore, Journal and Profile (D2, D14; a bar below medium windows, a rail with the icon from medium on), a practice's page in Explore's branch (`PracticeScreen`), and over the tabs the sitting (`SittingScreen`, handed its `SittingPlan` as `extra`), a dream (`DreamScreen`, `/dream` new, `/dream/:id` kept) and Settings. **Today** (`TodayTab`, D9): greeting by the hour, from 4 to 12 o'clock a card to write down last night's dream while it has none (`dreamPromptDue`, D14), Sit now (5, 10, 20 and the timer's own length), the practice of the day (`practiceOfDay`), the week and the days in a row (`WeekCard`), favourites, recently sat. **Explore** (`ExploreTab`, D10): search folded without case or accents (`matchesSearch`), four goal tiles, kind chips, practice cards. **The Journal** (`JournalTab`, D14; `lib/src/feature/journal/`): dreams by month, the newest first (`dreamsByMonth`), searched over title, text and labels and filtered by marks (`findDreams`), *+* to write one; a dream's night is the date of the morning after it (`defaultNight`: today's date at any hour); its screen writes with the text first and focused, the rest folded, and reads a kept one with Edit and Delete; a microphone under the text dictates where the device recognises speech (D15: halkyon_app's `Dictation` on `MeditatePerson.speech`, `DeviceSpeechInput` preferring Android's on-device recogniser, listening again after each pause until stopped or three silent listens; none on Linux, `NoSpeechInput` in tests that don't say), and Today's card offers *Speak it* (`/dream?speak=1`). The platforms declare the microphone as Learn's do (Android's `RECORD_AUDIO` and recognition query, iOS's and macOS's usage texts, macOS's `audio-input`), and the privacy page's points are `meditate_base.dart`'s. **Profile** (D2, D8): halkyon_app's `HalkyonProfile` with a *Practice* tab (minutes, sittings, days, the week, the journal's dreams this month and lucid ones once it holds one, every sitting with Delete). **The practices** are `practices` in `lib/src/feature/practice/model/practice.dart` (IDs never change once released), their words in the ARBs by ID (`practiceTexts`), their cues spread evenly over the length (`Practice.cueAt`). **A sitting** (`SittingCubit`, D6): its time from `clock.now()`, the ticks only showing it; the bell at the start (where on), between every interval and at the end; the screen kept on while it runs (where on); kept whole at its end, and ended early kept from a minute on where the person says so. **The figures** (`practiceFigures`, D8) on the device: a sitting's day by the wall clock it was sat by (`Sitting.offsetMinutes`), the streak up to today or yesterday, the week from the language's first day. **Settings** (D11): Sync, the operator's card, *Sitting* (bell, starting bell, bells between, screen on, the timer's length), *Practice* (the streak shown), *Journal* (Today's morning card, `dreamPrompt`), the account, What's new, About, the version. Texts in British English (the template) and German (du), `en_US` and `en_GB` empty. Widget tests start the whole app with halkyon_app's fakes (`test/app/meditate_app_test.dart`), the bells and screen recorded (`test/support/recording_device.dart`); a sitting on screen ticks every second, so tests pump seconds (`sit`) rather than `pumpAndSettle`; a test needing the hour (Today's morning card) starts the app inside `withClock` (`startAt`).
|
||||||
- **The bells** (D5) are `assets/bells/*.wav`, synthesised by `apps/meditate/tool/bells.py` (Python's standard library, run from `apps/meditate`; the same bytes every run) and played with `audioplayers` (`AudioplayersBellPlayer`). Only while the app runs: bells with the screen off are for later.
|
- **The bells** (D5) are `assets/bells/*.wav`, synthesised by `apps/meditate/tool/bells.py` (Python's standard library, run from `apps/meditate`; the same bytes every run) and played with `audioplayers` (`AudioplayersBellPlayer`). Only while the app runs: bells with the screen off are for later.
|
||||||
- **The icon** is Lucide's `sprout` from cobalt through pacific into fern (ADR 0031, D5, D7), drawn by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py meditate` from the root): `assets/icon/halkyon_meditate_icon.png`, Lucide's licence beside it, every platform's launcher icon.
|
- **The icon** is Lucide's `sprout` from cobalt through pacific into fern (ADR 0031, D5, D7), drawn by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py meditate` from the root): `assets/icon/halkyon_meditate_icon.png`, Lucide's licence beside it, every platform's launcher icon.
|
||||||
- **`serverpod start`** runs in `apps/meditate/halkyon_meditate_server` on ports of its own (8280 to 8282, PostgreSQL 8290), so it can run beside Learn's (8080…) and Train's (8180…); its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them.
|
- **`serverpod start`** runs in `apps/meditate/halkyon_meditate_server` on ports of its own (8280 to 8282, PostgreSQL 8290), so it can run beside Learn's (8080…) and Train's (8180…); its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
<!-- The app talks to its instance: release builds need this too (Flutter's
|
<!-- The app talks to its instance: release builds need this too (Flutter's
|
||||||
template grants it to debug and profile builds only). -->
|
template grants it to debug and profile builds only). -->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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
|
<!-- No backup and no transfer to another phone (ADR 0038, D7, as
|
||||||
Train's ADR 0030, D53): what is sealed comes back from the
|
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
|
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"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.speech.RecognitionService"/>
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -66,5 +66,9 @@
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -166,5 +166,10 @@
|
||||||
"todayDreamPromptHint": "Bevor er verblasst. Versiegelt auf diesem Gerät.",
|
"todayDreamPromptHint": "Bevor er verblasst. Versiegelt auf diesem Gerät.",
|
||||||
"settingsJournal": "Journal",
|
"settingsJournal": "Journal",
|
||||||
"dreamPromptTitle": "Nach dem Traum der letzten Nacht fragen",
|
"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.",
|
"todayDreamPromptHint": "Before it fades. Sealed on this device.",
|
||||||
"settingsJournal": "Journal",
|
"settingsJournal": "Journal",
|
||||||
"dreamPromptTitle": "Ask for last night's dream",
|
"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:
|
/// In en, this message translates to:
|
||||||
/// **'On Today, from 4 to 12 o\'clock, until you write one'**
|
/// **'On Today, from 4 to 12 o\'clock, until you write one'**
|
||||||
String get dreamPromptHint;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -591,4 +591,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get dreamPromptHint =>
|
String get dreamPromptHint =>
|
||||||
'Unter Heute, von 4 bis 12 Uhr, bis du einen aufschreibst';
|
'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
|
@override
|
||||||
String get dreamPromptHint =>
|
String get dreamPromptHint =>
|
||||||
'On Today, from 4 to 12 o\'clock, until you write one';
|
'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`).
|
/// 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_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
|
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
|
||||||
show Client, UuidValue;
|
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/device/screen_awake.dart';
|
||||||
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.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';
|
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
|
/// The device's screen, kept on while sitting; nothing in tests that
|
||||||
/// don't say.
|
/// don't say.
|
||||||
final ScreenAwake screen = const NoScreenAwake(),
|
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,
|
final Future<void> Function()? _onDispose,
|
||||||
}) implements Disposable {
|
}) implements Disposable {
|
||||||
/// Closes what was opened for the person.
|
/// Closes what was opened for the person.
|
||||||
|
|
@ -236,6 +241,7 @@ class MeditateDependencies({
|
||||||
accountKeys: () => accountKeys.read(person.authUserId),
|
accountKeys: () => accountKeys.read(person.authUserId),
|
||||||
bells: device.bells,
|
bells: device.bells,
|
||||||
screen: device.screen,
|
screen: device.screen,
|
||||||
|
speech: device.speech,
|
||||||
onClosed: closed.complete,
|
onClosed: closed.complete,
|
||||||
);
|
);
|
||||||
} on Object {
|
} on Object {
|
||||||
|
|
@ -273,6 +279,7 @@ Future<MeditatePerson> _openPersonData(
|
||||||
required Future<UnlockedAccountKeys?> Function() accountKeys,
|
required Future<UnlockedAccountKeys?> Function() accountKeys,
|
||||||
required BellPlayer bells,
|
required BellPlayer bells,
|
||||||
required ScreenAwake screen,
|
required ScreenAwake screen,
|
||||||
|
required SpeechInput speech,
|
||||||
required void Function() onClosed,
|
required void Function() onClosed,
|
||||||
}) async {
|
}) async {
|
||||||
final db = await client.createSyncSession(
|
final db = await client.createSyncSession(
|
||||||
|
|
@ -314,6 +321,7 @@ Future<MeditatePerson> _openPersonData(
|
||||||
dreams: dreams,
|
dreams: dreams,
|
||||||
bells: bells,
|
bells: bells,
|
||||||
screen: screen,
|
screen: screen,
|
||||||
|
speech: speech,
|
||||||
onDispose: () async {
|
onDispose: () async {
|
||||||
try {
|
try {
|
||||||
await settings.dispose();
|
await settings.dispose();
|
||||||
|
|
@ -351,7 +359,17 @@ class MeditateDevice({
|
||||||
|
|
||||||
/// The screen, kept on while sitting (D6).
|
/// The screen, kept on while sitting (D6).
|
||||||
final ScreenAwake screen = const NoScreenAwake(),
|
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.
|
/// Makes them, [builtIn] being the instance the build names.
|
||||||
static Future<MeditateDevice> create({required Instance builtIn}) async {
|
static Future<MeditateDevice> create({required Instance builtIn}) async {
|
||||||
final package = await PackageInfo.fromPlatform();
|
final package = await PackageInfo.fromPlatform();
|
||||||
|
|
@ -363,6 +381,12 @@ class MeditateDevice({
|
||||||
appVersion: package.version,
|
appVersion: package.version,
|
||||||
bells: AudioplayersBellPlayer(),
|
bells: AudioplayersBellPlayer(),
|
||||||
screen: const WakelockScreenAwake(),
|
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.
|
/// A new dream to write, over the tabs.
|
||||||
static const newDream = '/dream';
|
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.
|
/// The dream [id], to read and edit, over the tabs.
|
||||||
static String dream(String id) => '/dream/$id';
|
static String dream(String id) => '/dream/$id';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,11 @@ GoRouter buildMeditateRouter() => GoRouter(
|
||||||
// Settings: writing on waking wants the whole screen.
|
// Settings: writing on waking wants the whole screen.
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: MeditatePaths.newDream,
|
path: MeditatePaths.newDream,
|
||||||
builder: (context, state) => const FramedScreen(child: DreamScreen()),
|
builder: (context, state) => FramedScreen(
|
||||||
|
child: DreamScreen(
|
||||||
|
speak: state.uri.queryParameters['speak'] == '1',
|
||||||
|
),
|
||||||
|
),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':id',
|
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:clock/clock.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.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/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/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/bloc/journal_cubit.dart';
|
||||||
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.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
|
/// the text first and focused, everything else folded below it, as
|
||||||
/// writing on waking should be quick; a kept one ([dreamId]) opens to
|
/// writing on waking should be quick; a kept one ([dreamId]) opens to
|
||||||
/// read, with Edit and Delete (asked first). Leaving a dream written but
|
/// 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({
|
class const DreamScreen({
|
||||||
/// The dream kept under this ID; none for a new one.
|
/// The dream kept under this ID; none for a new one.
|
||||||
final String? dreamId,
|
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,
|
super.key,
|
||||||
}) extends StatefulWidget {
|
}) extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -45,6 +52,13 @@ class _DreamScreenState() extends State<DreamScreen> {
|
||||||
/// What the editor started from, to tell whether anything changed.
|
/// What the editor started from, to tell whether anything changed.
|
||||||
Dream? _started;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -53,10 +67,18 @@ class _DreamScreenState() extends State<DreamScreen> {
|
||||||
if (_editing) {
|
if (_editing) {
|
||||||
_started = _draft();
|
_started = _draft();
|
||||||
}
|
}
|
||||||
|
if (widget.speak) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _dictation.supported) {
|
||||||
|
unawaited(_dictate());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_dictationMade?.dispose();
|
||||||
_text.dispose();
|
_text.dispose();
|
||||||
_title.dispose();
|
_title.dispose();
|
||||||
_label.dispose();
|
_label.dispose();
|
||||||
|
|
@ -65,6 +87,20 @@ class _DreamScreenState() extends State<DreamScreen> {
|
||||||
|
|
||||||
void _changed() => setState(() {});
|
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
|
/// The dream as the editor holds it, written at [writtenAt] (the first
|
||||||
/// writing's, for one kept).
|
/// writing's, for one kept).
|
||||||
Dream _draft({DateTime? writtenAt}) => Dream(
|
Dream _draft({DateTime? writtenAt}) => Dream(
|
||||||
|
|
@ -98,6 +134,8 @@ class _DreamScreenState() extends State<DreamScreen> {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final journal = context.read<JournalCubit>();
|
final journal = context.read<JournalCubit>();
|
||||||
|
// What was heard stays; nothing more is written into what is saved.
|
||||||
|
await _dictationMade?.stop();
|
||||||
try {
|
try {
|
||||||
if (widget.dreamId case final id?) {
|
if (widget.dreamId case final id?) {
|
||||||
await journal.update(id, _draft());
|
await journal.update(id, _draft());
|
||||||
|
|
@ -250,6 +288,8 @@ class _DreamScreenState() extends State<DreamScreen> {
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Where the device recognises speech alone, as Learn's field.
|
||||||
|
if (_dictation.supported) _DictationRow(_dictation, onStart: _dictate),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Folded for a new dream: the text is what matters on waking.
|
// Folded for a new dream: the text is what matters on waking.
|
||||||
ExpansionTile(
|
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.
|
/// A part of the editor's heading.
|
||||||
class const _Heading(final String text) extends StatelessWidget {
|
class const _Heading(final String text) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.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/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/app/meditate_paths.dart';
|
||||||
import 'package:halkyon_meditate_flutter/src/feature/journal/bloc/journal_cubit.dart';
|
import 'package:halkyon_meditate_flutter/src/feature/journal/bloc/journal_cubit.dart';
|
||||||
import 'package:halkyon_meditate_flutter/src/feature/journal/model/journal.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
|
/// 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 {
|
class const _DreamPrompt() extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final speaks = PersonScope.of<MeditatePerson>(context).speech.supported;
|
||||||
return SectionCard(
|
return SectionCard(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: const Icon(LucideIcons.sunrise),
|
leading: const Icon(LucideIcons.sunrise),
|
||||||
title: Text(l10n.todayDreamPrompt),
|
title: Text(l10n.todayDreamPrompt),
|
||||||
subtitle: Text(l10n.todayDreamPromptHint),
|
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),
|
onTap: () => context.push(MeditatePaths.newDream),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
|
|
|
||||||
|
|
@ -28,5 +28,9 @@
|
||||||
<string>MainMenu</string>
|
<string>MainMenu</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>NSApplication</string>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,7 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ void main() {
|
||||||
late MemorySealedRowGateway settingsRows;
|
late MemorySealedRowGateway settingsRows;
|
||||||
late MemorySealedRowGateway sittingRows;
|
late MemorySealedRowGateway sittingRows;
|
||||||
late MemorySealedRowGateway dreamRows;
|
late MemorySealedRowGateway dreamRows;
|
||||||
|
late FakeSpeechInput speech;
|
||||||
late RecordingBells bells;
|
late RecordingBells bells;
|
||||||
late RecordingScreen screen;
|
late RecordingScreen screen;
|
||||||
|
|
||||||
|
|
@ -54,6 +55,7 @@ void main() {
|
||||||
settingsRows = MemorySealedRowGateway();
|
settingsRows = MemorySealedRowGateway();
|
||||||
sittingRows = MemorySealedRowGateway();
|
sittingRows = MemorySealedRowGateway();
|
||||||
dreamRows = MemorySealedRowGateway();
|
dreamRows = MemorySealedRowGateway();
|
||||||
|
speech = FakeSpeechInput();
|
||||||
bells = RecordingBells();
|
bells = RecordingBells();
|
||||||
screen = RecordingScreen();
|
screen = RecordingScreen();
|
||||||
store = MemoryInstanceStore();
|
store = MemoryInstanceStore();
|
||||||
|
|
@ -150,6 +152,7 @@ void main() {
|
||||||
dreams: dreams(),
|
dreams: dreams(),
|
||||||
bells: bells,
|
bells: bells,
|
||||||
screen: screen,
|
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 '
|
testWidgets('signed out, it asks how to start, with an account to create '
|
||||||
'where passwords are offered (ADR 0017), and no GitHub (ADR 0020, '
|
'where passwords are offered (ADR 0017), and no GitHub (ADR 0020, '
|
||||||
'D36)', (tester) async {
|
'D36)', (tester) async {
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,45 @@ HalkyonBase meditateBase({AccountsMode accounts = const HoldsRegister()}) =>
|
||||||
minimumAppBuild: minimumAppBuild,
|
minimumAppBuild: minimumAppBuild,
|
||||||
deletePersonData: const [deleteMeditateData],
|
deletePersonData: const [deleteMeditateData],
|
||||||
accounts: accounts,
|
accounts: accounts,
|
||||||
|
privacyPagePoints: _privacyPagePoints,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// What the instance's privacy page says of Meditate's data (ADR 0038, D7,
|
||||||
|
/// D14, D15), where Meditate's server serves the page: what is sealed, and
|
||||||
|
/// what a dream spoken passes through. Change it with what Meditate keeps
|
||||||
|
/// or sends.
|
||||||
|
const List<PrivacyPagePoint> _privacyPagePoints = [
|
||||||
|
(
|
||||||
|
english:
|
||||||
|
'On Halkyon Meditate, your settings, the sittings you keep and the '
|
||||||
|
'dreams you write down are encrypted on your devices with your key: '
|
||||||
|
'the instance sees how many there are, their size and when they '
|
||||||
|
'changed, never what they hold. A device that can’t encrypt keeps '
|
||||||
|
'none.',
|
||||||
|
german:
|
||||||
|
'In Halkyon Meditate werden deine Einstellungen, die Sitzungen, die '
|
||||||
|
'du aufbewahrst, und die Träume, die du aufschreibst, auf deinen '
|
||||||
|
'Geräten mit deinem Schlüssel verschlüsselt: Die Instanz sieht, wie '
|
||||||
|
'viele es sind, wie groß sie sind und wann sie sich änderten, nie, '
|
||||||
|
'was sie enthalten. Ein Gerät, das nicht verschlüsseln kann, bewahrt '
|
||||||
|
'keine auf.',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
english:
|
||||||
|
'A dream you speak is written by your device’s own speech '
|
||||||
|
'recognition. On Android, Meditate asks for recognition on the '
|
||||||
|
'device, which Android 12 and later offer where the language is '
|
||||||
|
'installed; otherwise Android’s recogniser may send the sound to '
|
||||||
|
'Google or the phone’s maker. Apple’s and Windows’ recognisers decide '
|
||||||
|
'for themselves whether the sound leaves the device. It never reaches '
|
||||||
|
'the instance.',
|
||||||
|
german:
|
||||||
|
'Einen Traum, den du erzählst, schreibt die Spracherkennung deines '
|
||||||
|
'Geräts auf. Unter Android bittet Meditate um Erkennung auf dem '
|
||||||
|
'Gerät, die Android ab Version 12 bietet, wo die Sprache installiert '
|
||||||
|
'ist; sonst kann Androids Spracherkennung den Ton an Google oder den '
|
||||||
|
'Hersteller des Telefons senden. Die Spracherkennung von Apple und '
|
||||||
|
'Windows entscheidet selbst, ob der Ton das Gerät verlässt. Bei der '
|
||||||
|
'Instanz kommt er nie an.',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,20 @@ void main() {
|
||||||
return methodConnector.call(sessionBuilder.build(), parameters);
|
return methodConnector.call(sessionBuilder.build(), parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('the privacy page says what Meditate keeps sealed, and that a '
|
||||||
|
"dream spoken is written by the device's own speech recognition, "
|
||||||
|
'which on Android may send the sound to Google (ADR 0038, D15)', () {
|
||||||
|
final points = meditateBase().privacyPagePoints;
|
||||||
|
expect(points, isNotEmpty);
|
||||||
|
final english = points.map((point) => point.english).join(' ');
|
||||||
|
final german = points.map((point) => point.german).join(' ');
|
||||||
|
expect(english, contains('dreams'));
|
||||||
|
expect(english, contains('speech recognition'));
|
||||||
|
expect(english, contains('Google'));
|
||||||
|
expect(german, contains('Spracherkennung'));
|
||||||
|
expect(german, contains('Google'));
|
||||||
|
});
|
||||||
|
|
||||||
test('its basics name the app it serves, `meditate`, so only Meditate '
|
test('its basics name the app it serves, `meditate`, so only Meditate '
|
||||||
'takes it for an instance of its own', () async {
|
'takes it for an instance of its own', () async {
|
||||||
final basics = (await call('instance', 'basics'))! as InstanceBasics;
|
final basics = (await call('instance', 'basics'))! as InstanceBasics;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue