halkyon/docs/adr/0038-halkyon-meditate.md

362 lines
26 KiB
Markdown
Raw Normal View History

# ADR 0038: Halkyon Meditate, what it does and how it looks
- **Status:** proposed 3 Nov 2026 by the agent, the developer having asked to "continue as
far as possible without my interjection"; every decision below is the agent's
recommendation unless it says the developer's, and is put to the developer with the
first version. Built the same day: steps 1 to 4; the dream journal (D14, the
developer's request of the same day) as step 5.
- **Builds on:** ADR 0033 (Todo's shell, which Meditate copies as Train copied Todo's,
ADR 0030, D12); ADR 0030, D7, D14, D15 and D61 (sealed rows, the person's key, What's
new); ADR 0031, D5 and D7 (Meditate's icon: Lucide's `sprout`, cobalt through pacific
into fern); ADR 0014 and 0016 (the module and the register).
## Context
On 3 Nov 2026 the developer asked for Halkyon Meditate, following "the basic approach as
the other applications": the bar and the rail, the theme, the settings and account
screens; and to look at eleven screenshots of 7Mind's Android app (1 Nov 2026,
`~/Downloads/7mind-screenshots/`). On 2 Nov they chose fern as Meditate's backdrop
(ADR 0031, D5). What the screenshots show:
- **Three tabs**: *For you*, *Explore*, *Profile*.
- **For you**: a greeting with the person's name; a band of newly added content, each a
card with its kind (Meditation, Sound, Sleep Story, Movement, Morning Stories, a
course of several tracks), its length and its speaker; *Your favorites* and *Recently
played*, each a row of the same cards with *More*.
- **Explore**: a search field, four goals as large tiles (reduce stress, sleep better,
learn to meditate, take care of yourself), categories as a row of icons (autogenic
training, breathing, dream journeys, knowledge, meditation, morning stories, movement,
progressive muscle relaxation, routines, sleep stories, sounds), and topics as a list
of cards with a line each (sleep, strength, personal development, relaxation, feelings
and relationships, daily life, silence and sounds).
- **Profile**: minutes, sessions and days as large figures; the week's days as dots with
the days in a row; Favorites, Downloads and *Your path*; then Account, Subscription,
Notifications, Language, Appearance, the streak's visibility, help, legal information.
- No player is shown; 7Mind's content is recorded speech by its speakers, behind a
subscription.
Halkyon has no recorded meditations, and none may be taken from 7Mind (its content, texts,
names and pictures are its own). Everything below is Halkyon's own.
## Decisions (3 Nov 2026, the agent's, to be confirmed)
- **D1 Meditate's frame is Todo's and Train's** (ADR 0033, D1; ADR 0030, D60): go_router
with a `StatefulShellRoute`, each tab keeping its own stack, halkyon_ui's
`NavigationFrame` (the bar floating above the foot below medium windows, a rail with
Meditate's icon on top from medium on), halkyon_app's first start, sign-in and
account, What's new after an update, About, English and German. Copied from Todo's
and renamed, as Train's was (ADR 0030, D12), Todo's own features left out.
- **D2 Three tabs: Today, Explore, Profile**, as 7Mind has three, Today opening (four
since D14, the Journal between Explore and Profile).
*Today* (not *For you*: nothing is chosen for the person by anyone) starts a sitting at
once, shows the week and the streak, the favourite practices and the ones done last.
*Explore* holds every practice, by kind and by what it is for, with search. *Profile* is
every Halkyon app's (`HalkyonProfile`, decided 2 Nov 2026): the person's own tab first,
then Meditate's *Practice* tab with the figures, the week and the sittings. The cog to
Settings stands atop Today and Profile, as Train's does atop Home and Profile. Over four
tabs with the timer as one: the timer is one tap from Today and from Explore, and a
sitting is a screen over the tabs (D6), as Train's recording is.
- **D3 The washes are fern** (the developer's, 2 Nov: ADR 0031, D5): every tab and the
sitting's screen wash down from the top in `PaletteColor.fern`; buttons and links stay
cobalt, as in every app.
- **D4 What is practised: Halkyon's own practices, guided by text and bells, and the
silent timer.** Without recorded speech, a *practice* is a sitting of a set or chosen
length with **cues**: short lines of guidance shown on the sitting's screen one after
the other, spread evenly over the length chosen (so a practice scales to each of its
lengths), and later read out by the device's text-to-speech (see *Later*). The
practices are written for Meditate in English and German (`practice.dart` holds what
each is, the ARBs its words), so their licence is the app's. Version 0.1.0 has six:
counting breaths, a three-minute pause, a body scan, open awareness, kind wishes
(loving-kindness) and settling for sleep, under the kinds Breathing, Body, Awareness,
Kindness, Sleep and Pause, and the goals *Calm down*, *Sleep*, *Focus* and *Begin*;
breath at the nostrils, walking, the 4-7-8 breath and box breathing come next, the
breathing ones with a paced ring. A practice's ID never changes once released. The
**silent timer** is a sitting of any length from 1 to 120 minutes with a bell at the
start, at the end and, if chosen, every 1 to 30 minutes between. Recorded guidance (the
instance's or the person's own files) is a later step: it needs the instance to hold and
serve files, their licences checked, and their downloads kept on the device.
- **D5 The bells are made for Meditate**: three sounds (a singing bowl, a small bell, a
wooden block) synthesised by `apps/meditate/tool/bells.py` (Python's standard library
alone: decaying partials with the bowl's beating) into `assets/bells/*.wav`, so they
carry the app's licence and no one else's; played with `audioplayers` (already in the
workspace's lock, Learn's). Over recorded bells from freesound.org and the like, whose
licences differ file by file.
- **D6 A sitting is a screen over the tabs**: a ring filling as the time passes, what
is left inside it counted down by the seconds begun (5:00 until a second has passed),
the practice's cue
of the moment, Pause and Resume, and End (also the close button and back). Time is counted from the clock, not from a
ticker, so a sitting paused by the system or the app going to the background is right
as it comes back. The screen stays on while it runs where the person's setting says so
(on by default, `wakelock_plus`, as the lock already holds it). Ended early, it asks
whether to keep what was sat (Keep, Discard, or Sit on); a sitting under a minute is
never kept and ends without asking. At the end, the bell, and *Done* with what was
sat. Bells while the app is in the background are a later step (a scheduled
notification's sound), as is reading the cues out.
- **D7 What is kept: sealed rows, synced, as Todo's and Train's** (ADR 0033, D5;
ADR 0030, D7, D15): the person's key (`content_key`), one **settings** row
(`meditate_settings`: the bell, the interval and starting bells, the screen kept on, the
favourite practices, whether the streak shows) and a row a **sitting** (`sitting`: the
practice or the timer, when it started as a wall-clock time with the device's offset,
how long it was planned and sat). Everything is in the sealed payload; the instance
sees that rows exist, their sizes and when they changed. Called *sitting*, not
*session*, as Serverpod's `Session` is every endpoint's. A person's data goes with them
(`deleteMeditateData`, the sync spaces' cascade, as Todo's).
- **D8 The figures are worked out on the device from the sittings**: minutes, sittings,
days with a sitting, the days in a row (up to today, or up to yesterday while today has
none yet, so a streak doesn't break before the day is over), and the week's days as
dots from the week's first day in the person's language. The streak can be hidden
(Settings, as 7Mind's *Streak visibility*), as some find streaks a pressure.
- **D9 Today** greets by the time of day (no name: the account has a handle, not
necessarily a first name), offers *Sit now* with 5, 10 and 20 minutes and the timer's
own length, the practice of the day (one after the other by the date, the same on
every device), the week with the streak, the favourites and the last three sittings.
- **D10 Explore** is a search field (titles, summaries and kinds, every word typed,
without case or accents), the goals as four tiles (chosen again to let go), the kinds
as chips, and every practice as a card,
narrowed by what is chosen. A practice's page says what it is, its length (a choice of
lengths where it scales), how it goes (its cues in order), its favourite heart and
*Start*.
- **D11 Settings** as Todo's (ADR 0033, D4): Sync's card first, the operator's card, then
Meditate's own *Sitting* card (the bell, the starting bell, the interval bell's
default, the screen kept on, the timer's default length) and *Practice* card (the
streak shown), the account, What's new and About, the version below.
- **D12 No reminders and no notifications in the first version**: a daily reminder at a
time the person sets is the first thing to add after it (Todo's notifications show the
way).
- **D13 Development ports of its own**: API 8280, Insights 8281, web 8282, PostgreSQL
8290 (Learn's and Todo's are 8080 to 8090, Train's 8180 to 8190), so Meditate's server
runs beside the others as a member of Learn's register.
## The dream journal (3 Nov 2026, the developer's request)
- **D14 A dream journal**, asked for by the developer on 3 Nov 2026 with what an entry
holds, that it is sealed and synced as the sittings, the Journal's list, search and
filters, Today's morning card and its switch in Settings, the empty state's word on
sealing, quick writing, and optional figures in Profile. What follows beyond that is
the agent's recommendation, to be confirmed:
- **An entry** (`Dream`): the night, named by the morning after it (a date at
midnight UTC, as every day the app counts) and by default today's date at any hour,
so "last night" even when written at 1 o'clock; a title, empty where none; the text;
the marks lucid, nightmare and recurring, as a set; clarity 1 to 5 or none; the mood
on waking from five (glad, calm, puzzled, sad, uneasy: few enough to choose half
awake, a mixed or neutral one left out as *none* says it); free labels (people,
places, themes, one list rather than kinds of label, each once whatever its case,
those used before offered by how often); and when it was first written, kept through
edits. Marks and moods by name in the payload (schema 1), so never renamed; a value a
version doesn't know reads as none.
- **Its own table, `dream`**, a row a dream, sealed for its table and row as the
sittings (D7), rather than rows in `sitting` or one row of all dreams: a dream is
changed and deleted on its own, and a row each keeps the sync's merges to the one
changed.
- **Where it lives: a fourth tab, Journal**, between Explore and Profile (Today,
Explore, Journal, Profile), as the developer suggested: a dream is written every
morning and searched later, a list of its own like Explore's, which under Profile
would be two taps deep and crowd the person's figures; four destinations still fit
the bar on a phone. Its icon is Lucide's `moon-star`. A dream opens over the tabs,
as a sitting and Settings do, for the whole screen while writing.
- **The Journal**: the newest month first, each month a card with its dreams the
newest night first; a search over title, text and labels as Explore's (every word,
without case or accents); the marks as filter chips, a dream shown where it carries
every one chosen; *+* above the bar as Train's buttons. Empty, it says what it is for
and that every entry is sealed on the device before it syncs.
- **Writing**: the text field first, focused, the rest (night, title, marks, clarity,
mood, labels) folded below it for a new dream and unfolded for a kept one; Save
once there is text. A kept dream opens to read, with Edit and Delete (asked first);
leaving a dream written but not saved asks first.
- **Today's card** from 4 to 12 o'clock while last night has no dream, below the
greeting, leading to a new dream; switched off in Settings' *Journal* card
(`dreamPrompt`, on at first, in the sealed settings: schema 1 stays, as a reader
that doesn't know it reads it as at first).
- **Profile's Practice** shows, once the journal holds a dream, a small *Dream
journal* card: the dreams of this month and the lucid ones of all.
- Not done: reminders to write (D12), dreams linked to sittings, figures over time.
- **D15 Dreams spoken** (3 Nov 2026, the developer's request): speech-to-text in the
journal's editor, as Learn's assistant field takes questions spoken (Halkyon Learn's
ADR 0021, D67: the device's own speech recognition, `speech_to_text`). The agent's
recommendations, to be confirmed:
- **Shared, not copied**: Learn's `SpeechInput` and `DeviceSpeechInput` move into
halkyon_app with their fake, Learn importing them from there unchanged; beside them
`Dictation`, generic, which writes what is heard where the cursor stands (in place
of a selection, set apart by a space on either side where the text has none) and
shows the words so far. Learn's button stays Learn's: it sends, and replaces from
where it started. Todo and Train link the plugin now but declare no microphone, so
nothing there listens.
- **In the editor**: a microphone under the text, where the device recognises speech
(none on Linux, as Learn's); listening, it turns into Stop with "Listening…" and the
words heard beside it. It listens in the app's language, else the device's own.
- **A dream is longer than a question: it restarts by itself** while the button stays
on, rather than offering to continue: half awake, a tap after every pause would
interrupt the telling, and the button says throughout that it listens. Each listen
may pause 30 s and last 10 minutes (Apple's recogniser stops sooner, Android's after
a few seconds' silence whatever is asked), and after three listens running hear
nothing it stops by itself, so a phone left on the table doesn't listen on.
- **Privacy**: on Android Meditate asks for recognition on the device
(`DeviceSpeechInput(preferOnDevice: true)`: Android 12's on-device recogniser where
the language is installed, else the recogniser with *prefer offline*); should that
fail before a word is heard, it listens as Android otherwise does from then on, which
may send the sound to Google or the phone's maker. Apple's and Windows' recognisers
choose for themselves (the plugin's on-device flag on Apple fails outright where
unsupported, so it isn't asked). The instance never hears it. The privacy page says
so where Meditate's server serves it (`privacyPagePoints` in `meditate_base.dart`,
with what Meditate keeps sealed), as Learn's does.
- **Today's card** offers *Speak it* beside it, opening a new dream already listening.
- The platforms declare it as Learn's do: `RECORD_AUDIO` and the recognition service's
query on Android, the microphone's and speech recognition's usage texts on iOS and
macOS, and macOS's `audio-input` entitlement.
## The operator's library (3 Nov 2026, the developer's request)
- **D16 Guided content the operator provides**, asked for by the developer on 3 Nov
2026, structured as 7Mind's catalogue is (items with a title, a category, a
description, a speaker and recordings; categories; courses of ordered lessons). Only
the structure is taken: nothing of 7Mind's (texts, recordings, names, ids) is in the
repository, its tests or its fixtures, whose entries are made up. The agent's
recommendations, to be confirmed:
- **A folder and a manifest.** The operator mounts a folder into Meditate's server and
names it in `MEDITATE_LIBRARY_DIR`: recordings and pictures, and `library.yaml`, YAML
rather than JSON because operators write it by hand and want comments (a commented,
made-up example: `halkyon_meditate_server/library.example.yaml`). An item: `id` (the
operator's slug, a-z, 0-9 and `-`, which sittings, favourites and lessons done refer
to, so never changed), `title`, `category`, `description`, `speaker`, `language`
(BCP 47), `image`, one or more recordings under `audio` (each a `file`, a `label`
telling them apart by length or voice, and a `duration`), and **`licence` and
`credit`, required**, with an optional `licence-url`. A course: `id`, `title`,
`description`, `category`, `language`, `image` and `lessons` in order, each an
`item` with an optional `title` of its own. The duration is the manifest's, in
seconds or `m:ss`, not read from the file: reading MP3's, AAC's and Opus's lengths
without a decoder is unreliable, and a wrong length would count wrong sittings; a
check that reads it can come with the deploy's image.
- **Categories**: Meditate's eleven keys (`meditation`, `sleep-story`, `sound`,
`movement`, `autogenic-training`, `progressive-muscle-relaxation`, `routine`,
`dream-journey`, `knowledge`, `morning-story`, `other`), named by the app in English
and German; and **the operator's own** under `categories:` with a key and an English
and a German name, never one of Meditate's keys: an instance's library is its own,
and "other" alone would hide what it offers.
- **Checked as read, refusing what fails, with the reason**: an item without a licence
or credit, a recording or picture outside the folder (a `..`, an absolute path or a
link leading out, each resolved), missing, or of another type (recordings: MP3, M4A,
AAC, Ogg, Opus, FLAC; pictures: JPEG, PNG, WebP), a wrong id, category or language,
no duration, an id taken twice, a course pointing at an item not served; the rest is
served. A manifest that doesn't read at all leaves the library as it was served (an
edit half done), one removed ends it. `bin/check_library.dart <folder>` reads a folder
as the server would, before it is deployed, and exits 1 on any problem.
- **In memory, not in the database**: the manifest is the library's whole truth and
small, read as the server starts and again every ten minutes by a future call
(`LibraryReloadFutureCall`, as Learn's dictionaries are reread), an unchanged folder
costing a hash of the manifest and a stat of each file. No table, no migration, and
nothing to keep in step.
- **For signed-in people alone** (`LibraryEndpoint.catalogue`, `requireLogin`): the
operator's licences may not allow offering it to the public.
- **The files on Serverpod's web server**, `/library/<served name>`, by ranges (relic's
`StaticHandler`: `Range`, `If-Range`, `ETag`), as the platforms' players fetch a
recording. A file is asked for by a served name the reading gave it (a hash of its
place, size and time with its extension); no request's path reaches the file system.
Since players can't all send the session's header, the catalogue brings an
**access**: an hour's start plus 12 hours, signed with a key the server makes as it
starts (a restart ends every access; the app asks again), in the URL's query.
Without one: 401; one not signed here or ended: 403; any other name: 404. Starting
from the hour, the access is the same for everyone given it within that hour, so a
fetch names no account (the privacy page says so). Rejected: an endpoint streaming
the bytes (Serverpod's endpoints answer whole, no ranges), Caddy serving the folder
(it can't check a session or an access without a plugin).
- **In the app**: Explore shows the library below Meditate's six practices, with chips
of the categories it uses and the same search, its courses (lessons done counted)
then its items; an item's page has its recordings, Play, the heart, keeping it on the
device, for a sound a silent sitting with it looped (the timer's length), and its
credit and licence; a course's page lists its lessons, ticked once done. The player
(`GuidePlayer` on audioplayers, beside the bells') plays, pauses and seeks; played to
its end, an item is kept as a sitting of its whole length under `library/<id>`, so
the figures count it, and as its course's lesson done; stopped before, nothing.
**Playing on with the screen off** (Android's foreground service and media
notification, iOS's `audio` background mode, as `audio_service` does) is for later:
not cheap to do well.
- **The person's, sealed and synced**: favourites of the library in the settings' list
beside the practices', as `library/<id>`; lessons done in a new table, `lesson_done`,
a row each under an ID keyed by the account, the course and the item, so two devices
doing it make one row; both name the operator's ids, so a lesson whose course went
counts again should it return.
- **Offline**: the last catalogue is kept in the person's folder (`library.json`), and a
recording is kept on the device only on the person's asking, removed again on it (and
with their folder); a kept one plays from there.
- **No library, nothing**: an instance without one shows no section in Explore and no
credits in Settings.
- **Credits**: Settings lists every item's credit and licence beside About (*Library
credits*), and each item's page shows its own.
- **The deploy, later** (step 6): the folder mounted read-only into Meditate's
container (`/srv/meditate-library:/library:ro`, `MEDITATE_LIBRARY_DIR=/library`),
Caddy forwarding `/library/*` to the web server with the rest, `check_library`
compiled into the image so an operator checks a folder on the host.
- **D17 What a recording speaks** (5 Nov 2026, the developer's request): an item's page
says its spoken language; where the library has several, its cards do too and Explore
offers them, several at once, none meaning every one, kept in the person's sealed
settings (`libraryLanguages`). Languages group by their primary subtag (`de-CH` with
`de`). A library with one language asks nothing, and a choice no longer offered is
dropped, so that a choice never hides a library.
## Steps
1. **The shell** (3 Nov): Todo's copied and renamed, its features left out, the three tabs
(D1, D2, D3), Settings (D11), the icon (ADR 0031, D7).
2. **The data** (3 Nov): the server's models and its migrations, the device's key,
settings and sittings (D7).
3. **The practices and the sitting** (3 Nov): the catalogue (D4), the bells (D5), the
sitting's screen (D6), Explore (D10).
4. **Today and Profile** (3 Nov): D8, D9.
5. **The dream journal** (3 Nov): D14, the `dream` table and its migration, the Journal
tab, a dream's screen, Today's card, Settings' *Journal*, Profile's figures; and
dreams spoken (D15).
6. **The operator's library** (3 Nov): D16, the server's reading, endpoint, files and
check, the `lesson_done` table and its migration, Explore's section, an item's and a
course's pages, the player, keeping on the device, the credits.
7. **Released and deployed** (later, as Todo's step 5): `apps/meditate/release.dart`, a key
of its own, the F-Droid listing, a snap, `deploy/compose.meditate.yaml` at
meditate.halkyon.eu (its `.env` values the developer has set already, 2 Nov), its
database and role, `redeploy.sh` and `backup.sh` taking it in.
## Migrations
- `initial`: Serverpod's tables, its auth modules', halkyon_base's and the OIDC module's
as they stand, as Train's first migration (ADR 0030); only `CREATE TABLE`, `CREATE
INDEX` and foreign keys, nothing to transform.
- `initial` also holds the sync engine's tables (`offline_sync_*`), as the module is
in `config/generator.yaml` from the start.
- `sittings`: Meditate's three tables, `content_key`, `meditate_settings` and `sitting`
(`CREATE TABLE`, each an `id` UUID made by `gen_random_uuid_v7()`, which the migration
defines again with `CREATE OR REPLACE`, a nullable `spaceId`, and the envelope's
columns), an index on each `spaceId` (`CREATE INDEX … USING btree`) so deleting a
person doesn't read the tables, and each `spaceId`'s foreign key to
`offline_sync_spaces` deleting with the space (`ON DELETE CASCADE`); then the version
rows of every module (`INSERT … ON CONFLICT DO UPDATE` into `serverpod_migrations`).
Purely additive: no row exists to transform.
- `dreams` (D14): the table `dream` as `sitting` is (`CREATE TABLE "dream"`: `id` a UUID
v7 by default, the function defined again with `CREATE OR REPLACE`, unchanged; a
nullable `spaceId`; `keyId`, `payload` not null and `nonce`, the envelope), the index
`dream_space_idx` on `spaceId`, its foreign key to `offline_sync_spaces("id")` with
`ON DELETE CASCADE`, so a person deleted takes their dreams with their space; then the
version rows. A new, empty table: nothing existing changes.
- `library` (D16): the table `lesson_done` as `dream` is (`CREATE TABLE "lesson_done"`
with the same columns, `id` a UUID v7 by default, the function again `CREATE OR
REPLACE`), the index `lesson_done_space_idx` on `spaceId` (btree), its foreign key to
`offline_sync_spaces("id")` `ON DELETE CASCADE`, so a person deleted takes their
lessons done with their space; then the version rows. The library itself has no
table. A new, empty table: nothing existing changes.
## Later
- Recorded guidance (D4), the cues read out by text-to-speech, bells in the background
(D6), a daily reminder (D12), sittings written to Health Connect as mindfulness
sessions (Android 14's `MindfulnessSessionRecord`), courses of Meditate's own
practices in order; for the library (D16), playing on with the screen off, durations
read from the files, the pictures shown.
## Consequences
- The practices are text, short and translatable, so a new one is a change to
`practice.dart` and its words in the two ARBs; a practice once used keeps its ID, as
sittings name it.
- Without recorded voices, Meditate is a timer with guidance more than a library;
recorded guidance (D4) is what would make it more, and an instance's library (D16)
brings it where its operator offers one.
- An operator's library is theirs to license: Meditate refuses an item without a licence
and credit, but can't check that they hold.