feat(meditate): the dream journal, a fourth tab, Today's morning card

ADR 0038, D14. A dream (`Dream`) holds the night it belongs to (the
morning's date, today's by default), an optional title, its text, the
marks lucid, nightmare and recurring, a clarity of 1 to 5, the mood on
waking and free labels, and when it was written; a row each in `dream`,
sealed for its row with the person's key as the sittings
(`DreamRepository` on `SealedRows`, `SyncDreamGateway`), followed by
`JournalCubit` for every screen.

- The Journal, between Explore and Profile: dreams by month, the
  newest first, searched over title, text and labels as Explore
  searches, filtered by marks, `+` above the bar; empty, it says that
  entries are sealed on the device before they sync.
- A dream's screen over the tabs: a new one with its text first and
  focused, the rest folded below; a kept one to read, with Edit and
  Delete (asked first); leaving one written but not saved asks first.
- Today, from 4 to 12 o'clock while last night has no dream, a gentle
  card leading to a new one; Settings' Journal card switches it off
  (`dreamPrompt` in the sealed settings, schema 1 kept).
- Profile's Practice: this month's dreams and the lucid ones, once the
  journal holds one.
- English and German texts; What's new 0.1.0 names the journal; the
  account's deletion says it takes the dreams too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 14:19:10 +01:00
commit 86c9f4522a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
33 changed files with 2762 additions and 41 deletions

View file

@ -3,8 +3,8 @@
What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Meditate needs the same thing. What Meditate is and why: [ADR 0038](../../docs/adr/0038-halkyon-meditate.md) (proposed 3 Nov 2026, built by an agent without the developer's interjection; its decisions await their reading).
- **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) and `sitting`, each indexing `spaceId`. Migrations: `initial` (Serverpod's, the auth modules', the module's and the sync engine's tables) and `sittings` (the three tables). 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`, 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) for every screen. go_router (`buildMeditateRouter`, `MeditatePaths`): halkyon_ui's `NavigationFrame` (`MeditateShell`) with Today, Explore and Profile (D2; 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`) and Settings. **Today** (`TodayTab`, D9): greeting by the hour, 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. **Profile** (D2, D8): halkyon_app's `HalkyonProfile` with a *Practice* tab (minutes, sittings, days, the week, 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), 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`.
- **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 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.
- **`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.

View file

@ -1 +1 @@
- Die erste Version von Halkyon Meditate: Heute mit stillem Timer und Übung des Tages, Entdecken mit Halkyons eigenen angeleiteten Übungen nach Ziel und Art, deine Minuten, Tage und Serie im Profil, drei eigene Klänge, alles versiegelt.
- Die erste Version von Halkyon Meditate: Heute mit stillem Timer und Übung des Tages, Entdecken mit Halkyons eigenen angeleiteten Übungen nach Ziel und Art, ein Traumtagebuch, deine Minuten, Tage und Serie im Profil, drei eigene Klänge, alles versiegelt.

View file

@ -1 +1 @@
- The first release of Halkyon Meditate: Today with a silent timer and the practice of the day, Explore with Halkyon's own guided practices by goal and kind, your minutes, days and streak in Profile, three bells of its own, everything sealed.
- The first release of Halkyon Meditate: Today with a silent timer and the practice of the day, Explore with Halkyon's own guided practices by goal and kind, a dream journal, your minutes, days and streak in Profile, three bells of its own, everything sealed.

View file

@ -4,7 +4,7 @@
"launchProduct": "Meditate",
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Praxis liegen soll. Jede betreibt jemand; weitere kannst du später hinzufügen.",
"accountTitle": "Konto",
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht, mit jeder gespeicherten Sitzung. Dies kann nicht rückgängig gemacht werden.",
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht, mit jeder gespeicherten Sitzung und jedem Traum. Dies kann nicht rückgängig gemacht werden.",
"aboutTitle": "Über Halkyon Meditate",
"settingsTitle": "Einstellungen",
"settingsVersion": "Version {version} (Build {build})",
@ -120,5 +120,51 @@
"timerLengthTitle": "Dauer des stillen Timers",
"settingsPractice": "Praxis",
"showStreakTitle": "Tage in Folge zeigen",
"showStreakHint": "Unter Heute und in deinem Profil"
"showStreakHint": "Unter Heute und in deinem Profil",
"shellTabJournal": "Journal",
"journalSearch": "Träume durchsuchen",
"journalEmptyTitle": "Dein Traumtagebuch",
"journalEmpty": "Schreib einen Traum gleich nach dem Aufwachen auf, bevor er verblasst. Jeder Eintrag wird auf diesem Gerät versiegelt, bevor er synchronisiert wird: Niemand sonst kann ihn lesen, auch die Instanz nicht.",
"journalNoMatch": "Kein Traum passt.",
"journalAdd": "Traum aufschreiben",
"journalFiguresTitle": "Traumtagebuch",
"figuresDreamsThisMonth": "Diesen Monat",
"figuresLucid": "Luzid",
"markLucid": "Luzid",
"markNightmare": "Albtraum",
"markRecurring": "Wiederkehrend",
"moodGlad": "Froh",
"moodCalm": "Ruhig",
"moodPuzzled": "Verwundert",
"moodSad": "Traurig",
"moodUneasy": "Unruhig",
"dreamNew": "Neuer Traum",
"dreamLastNight": "Letzte Nacht",
"dreamNight": "Nacht vor dem {date}",
"dreamTextHint": "Was hast du geträumt?",
"dreamTitleLabel": "Titel (optional)",
"dreamMore": "Nacht, Titel, Merkmale und mehr",
"dreamNightLabel": "Nacht",
"dreamMarksLabel": "Merkmale",
"dreamClarityLabel": "Klarheit",
"dreamClarityHint": "Wie klar du dich erinnerst, 1 bis 5",
"dreamClarityValue": "Klarheit {clarity}/5",
"dreamMoodLabel": "Stimmung beim Aufwachen",
"dreamLabelsLabel": "Schlagwörter",
"dreamLabelsHint": "Menschen, Orte, Themen",
"dreamLabelAdd": "Schlagwort hinzufügen",
"dreamSave": "Speichern",
"dreamEdit": "Bearbeiten",
"dreamDelete": "Löschen",
"dreamDeleteTitle": "Diesen Traum löschen?",
"dreamDeleteMessage": "Er verschwindet auf all deinen Geräten aus deinem Journal.",
"dreamDiscardTitle": "Diesen Traum verwerfen?",
"dreamDiscardMessage": "Was du geschrieben hast, wird nicht gespeichert.",
"dreamDiscard": "Verwerfen",
"dreamWritten": "Geschrieben am {date}",
"todayDreamPrompt": "Schreib den Traum der letzten Nacht auf",
"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"
}

View file

@ -10,7 +10,7 @@
},
"instanceFirstStartSubtitle": "Choose the instance to keep your practice on. Someone runs each one; you can add others later.",
"accountTitle": "Account",
"accountDeleteMessage": "Your account is deleted on the server and on all your devices, with every sitting kept. This can't be undone.",
"accountDeleteMessage": "Your account is deleted on the server and on all your devices, with every sitting and dream kept. This can't be undone.",
"aboutTitle": "About Halkyon Meditate",
"settingsTitle": "Settings",
"settingsVersion": "Version {version} (build {build})",
@ -186,5 +186,73 @@
"timerLengthTitle": "Silent timer's length",
"settingsPractice": "Practice",
"showStreakTitle": "Show the days in a row",
"showStreakHint": "On Today and in your Profile"
"showStreakHint": "On Today and in your Profile",
"shellTabJournal": "Journal",
"journalSearch": "Search dreams",
"journalEmptyTitle": "Your dream journal",
"journalEmpty": "Write down a dream as soon as you wake, before it fades. Every entry is sealed on this device before it syncs: nobody else can read it, not even the instance.",
"journalNoMatch": "No dream matches.",
"journalAdd": "Write down a dream",
"journalFiguresTitle": "Dream journal",
"figuresDreamsThisMonth": "This month",
"figuresLucid": "Lucid",
"markLucid": "Lucid",
"markNightmare": "Nightmare",
"markRecurring": "Recurring",
"moodGlad": "Glad",
"moodCalm": "Calm",
"moodPuzzled": "Puzzled",
"moodSad": "Sad",
"moodUneasy": "Uneasy",
"dreamNew": "New dream",
"dreamLastNight": "Last night",
"dreamNight": "Night before {date}",
"@dreamNight": {
"description": "The night a dream belongs to, named by the morning after it.",
"placeholders": {
"date": {
"type": "String"
}
}
},
"dreamTextHint": "What did you dream?",
"dreamTitleLabel": "Title (optional)",
"dreamMore": "Night, title, marks and more",
"dreamNightLabel": "Night",
"dreamMarksLabel": "Marks",
"dreamClarityLabel": "Clarity",
"dreamClarityHint": "How clearly you remember it, 1 to 5",
"dreamClarityValue": "Clarity {clarity}/5",
"@dreamClarityValue": {
"placeholders": {
"clarity": {
"type": "int"
}
}
},
"dreamMoodLabel": "Mood on waking",
"dreamLabelsLabel": "Labels",
"dreamLabelsHint": "People, places, themes",
"dreamLabelAdd": "Add label",
"dreamSave": "Save",
"dreamEdit": "Edit",
"dreamDelete": "Delete",
"dreamDeleteTitle": "Delete this dream?",
"dreamDeleteMessage": "It goes from your journal on all your devices.",
"dreamDiscardTitle": "Discard this dream?",
"dreamDiscardMessage": "What you wrote isn't saved.",
"dreamDiscard": "Discard",
"dreamWritten": "Written {date}",
"@dreamWritten": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"todayDreamPrompt": "Write down last night's dream",
"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"
}

View file

@ -127,7 +127,7 @@ abstract class AppLocalizations {
/// No description provided for @accountDeleteMessage.
///
/// In en, this message translates to:
/// **'Your account is deleted on the server and on all your devices, with every sitting kept. This can\'t be undone.'**
/// **'Your account is deleted on the server and on all your devices, with every sitting and dream kept. This can\'t be undone.'**
String get accountDeleteMessage;
/// No description provided for @aboutTitle.
@ -825,6 +825,282 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'On Today and in your Profile'**
String get showStreakHint;
/// No description provided for @shellTabJournal.
///
/// In en, this message translates to:
/// **'Journal'**
String get shellTabJournal;
/// No description provided for @journalSearch.
///
/// In en, this message translates to:
/// **'Search dreams'**
String get journalSearch;
/// No description provided for @journalEmptyTitle.
///
/// In en, this message translates to:
/// **'Your dream journal'**
String get journalEmptyTitle;
/// No description provided for @journalEmpty.
///
/// In en, this message translates to:
/// **'Write down a dream as soon as you wake, before it fades. Every entry is sealed on this device before it syncs: nobody else can read it, not even the instance.'**
String get journalEmpty;
/// No description provided for @journalNoMatch.
///
/// In en, this message translates to:
/// **'No dream matches.'**
String get journalNoMatch;
/// No description provided for @journalAdd.
///
/// In en, this message translates to:
/// **'Write down a dream'**
String get journalAdd;
/// No description provided for @journalFiguresTitle.
///
/// In en, this message translates to:
/// **'Dream journal'**
String get journalFiguresTitle;
/// No description provided for @figuresDreamsThisMonth.
///
/// In en, this message translates to:
/// **'This month'**
String get figuresDreamsThisMonth;
/// No description provided for @figuresLucid.
///
/// In en, this message translates to:
/// **'Lucid'**
String get figuresLucid;
/// No description provided for @markLucid.
///
/// In en, this message translates to:
/// **'Lucid'**
String get markLucid;
/// No description provided for @markNightmare.
///
/// In en, this message translates to:
/// **'Nightmare'**
String get markNightmare;
/// No description provided for @markRecurring.
///
/// In en, this message translates to:
/// **'Recurring'**
String get markRecurring;
/// No description provided for @moodGlad.
///
/// In en, this message translates to:
/// **'Glad'**
String get moodGlad;
/// No description provided for @moodCalm.
///
/// In en, this message translates to:
/// **'Calm'**
String get moodCalm;
/// No description provided for @moodPuzzled.
///
/// In en, this message translates to:
/// **'Puzzled'**
String get moodPuzzled;
/// No description provided for @moodSad.
///
/// In en, this message translates to:
/// **'Sad'**
String get moodSad;
/// No description provided for @moodUneasy.
///
/// In en, this message translates to:
/// **'Uneasy'**
String get moodUneasy;
/// No description provided for @dreamNew.
///
/// In en, this message translates to:
/// **'New dream'**
String get dreamNew;
/// No description provided for @dreamLastNight.
///
/// In en, this message translates to:
/// **'Last night'**
String get dreamLastNight;
/// The night a dream belongs to, named by the morning after it.
///
/// In en, this message translates to:
/// **'Night before {date}'**
String dreamNight(String date);
/// No description provided for @dreamTextHint.
///
/// In en, this message translates to:
/// **'What did you dream?'**
String get dreamTextHint;
/// No description provided for @dreamTitleLabel.
///
/// In en, this message translates to:
/// **'Title (optional)'**
String get dreamTitleLabel;
/// No description provided for @dreamMore.
///
/// In en, this message translates to:
/// **'Night, title, marks and more'**
String get dreamMore;
/// No description provided for @dreamNightLabel.
///
/// In en, this message translates to:
/// **'Night'**
String get dreamNightLabel;
/// No description provided for @dreamMarksLabel.
///
/// In en, this message translates to:
/// **'Marks'**
String get dreamMarksLabel;
/// No description provided for @dreamClarityLabel.
///
/// In en, this message translates to:
/// **'Clarity'**
String get dreamClarityLabel;
/// No description provided for @dreamClarityHint.
///
/// In en, this message translates to:
/// **'How clearly you remember it, 1 to 5'**
String get dreamClarityHint;
/// No description provided for @dreamClarityValue.
///
/// In en, this message translates to:
/// **'Clarity {clarity}/5'**
String dreamClarityValue(int clarity);
/// No description provided for @dreamMoodLabel.
///
/// In en, this message translates to:
/// **'Mood on waking'**
String get dreamMoodLabel;
/// No description provided for @dreamLabelsLabel.
///
/// In en, this message translates to:
/// **'Labels'**
String get dreamLabelsLabel;
/// No description provided for @dreamLabelsHint.
///
/// In en, this message translates to:
/// **'People, places, themes'**
String get dreamLabelsHint;
/// No description provided for @dreamLabelAdd.
///
/// In en, this message translates to:
/// **'Add label'**
String get dreamLabelAdd;
/// No description provided for @dreamSave.
///
/// In en, this message translates to:
/// **'Save'**
String get dreamSave;
/// No description provided for @dreamEdit.
///
/// In en, this message translates to:
/// **'Edit'**
String get dreamEdit;
/// No description provided for @dreamDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get dreamDelete;
/// No description provided for @dreamDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete this dream?'**
String get dreamDeleteTitle;
/// No description provided for @dreamDeleteMessage.
///
/// In en, this message translates to:
/// **'It goes from your journal on all your devices.'**
String get dreamDeleteMessage;
/// No description provided for @dreamDiscardTitle.
///
/// In en, this message translates to:
/// **'Discard this dream?'**
String get dreamDiscardTitle;
/// No description provided for @dreamDiscardMessage.
///
/// In en, this message translates to:
/// **'What you wrote isn\'t saved.'**
String get dreamDiscardMessage;
/// No description provided for @dreamDiscard.
///
/// In en, this message translates to:
/// **'Discard'**
String get dreamDiscard;
/// No description provided for @dreamWritten.
///
/// In en, this message translates to:
/// **'Written {date}'**
String dreamWritten(String date);
/// No description provided for @todayDreamPrompt.
///
/// In en, this message translates to:
/// **'Write down last night\'s dream'**
String get todayDreamPrompt;
/// No description provided for @todayDreamPromptHint.
///
/// In en, this message translates to:
/// **'Before it fades. Sealed on this device.'**
String get todayDreamPromptHint;
/// No description provided for @settingsJournal.
///
/// In en, this message translates to:
/// **'Journal'**
String get settingsJournal;
/// No description provided for @dreamPromptTitle.
///
/// In en, this message translates to:
/// **'Ask for last night\'s dream'**
String get dreamPromptTitle;
/// No description provided for @dreamPromptHint.
///
/// In en, this message translates to:
/// **'On Today, from 4 to 12 o\'clock, until you write one'**
String get dreamPromptHint;
}
class _AppLocalizationsDelegate

View file

@ -24,7 +24,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get accountDeleteMessage =>
'Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht, mit jeder gespeicherten Sitzung. Dies kann nicht rückgängig gemacht werden.';
'Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht, mit jeder gespeicherten Sitzung und jedem Traum. Dies kann nicht rückgängig gemacht werden.';
@override
String get aboutTitle => 'Über Halkyon Meditate';
@ -442,4 +442,153 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get showStreakHint => 'Unter Heute und in deinem Profil';
@override
String get shellTabJournal => 'Journal';
@override
String get journalSearch => 'Träume durchsuchen';
@override
String get journalEmptyTitle => 'Dein Traumtagebuch';
@override
String get journalEmpty =>
'Schreib einen Traum gleich nach dem Aufwachen auf, bevor er verblasst. Jeder Eintrag wird auf diesem Gerät versiegelt, bevor er synchronisiert wird: Niemand sonst kann ihn lesen, auch die Instanz nicht.';
@override
String get journalNoMatch => 'Kein Traum passt.';
@override
String get journalAdd => 'Traum aufschreiben';
@override
String get journalFiguresTitle => 'Traumtagebuch';
@override
String get figuresDreamsThisMonth => 'Diesen Monat';
@override
String get figuresLucid => 'Luzid';
@override
String get markLucid => 'Luzid';
@override
String get markNightmare => 'Albtraum';
@override
String get markRecurring => 'Wiederkehrend';
@override
String get moodGlad => 'Froh';
@override
String get moodCalm => 'Ruhig';
@override
String get moodPuzzled => 'Verwundert';
@override
String get moodSad => 'Traurig';
@override
String get moodUneasy => 'Unruhig';
@override
String get dreamNew => 'Neuer Traum';
@override
String get dreamLastNight => 'Letzte Nacht';
@override
String dreamNight(String date) {
return 'Nacht vor dem $date';
}
@override
String get dreamTextHint => 'Was hast du geträumt?';
@override
String get dreamTitleLabel => 'Titel (optional)';
@override
String get dreamMore => 'Nacht, Titel, Merkmale und mehr';
@override
String get dreamNightLabel => 'Nacht';
@override
String get dreamMarksLabel => 'Merkmale';
@override
String get dreamClarityLabel => 'Klarheit';
@override
String get dreamClarityHint => 'Wie klar du dich erinnerst, 1 bis 5';
@override
String dreamClarityValue(int clarity) {
return 'Klarheit $clarity/5';
}
@override
String get dreamMoodLabel => 'Stimmung beim Aufwachen';
@override
String get dreamLabelsLabel => 'Schlagwörter';
@override
String get dreamLabelsHint => 'Menschen, Orte, Themen';
@override
String get dreamLabelAdd => 'Schlagwort hinzufügen';
@override
String get dreamSave => 'Speichern';
@override
String get dreamEdit => 'Bearbeiten';
@override
String get dreamDelete => 'Löschen';
@override
String get dreamDeleteTitle => 'Diesen Traum löschen?';
@override
String get dreamDeleteMessage =>
'Er verschwindet auf all deinen Geräten aus deinem Journal.';
@override
String get dreamDiscardTitle => 'Diesen Traum verwerfen?';
@override
String get dreamDiscardMessage =>
'Was du geschrieben hast, wird nicht gespeichert.';
@override
String get dreamDiscard => 'Verwerfen';
@override
String dreamWritten(String date) {
return 'Geschrieben am $date';
}
@override
String get todayDreamPrompt => 'Schreib den Traum der letzten Nacht auf';
@override
String get todayDreamPromptHint =>
'Bevor er verblasst. Versiegelt auf diesem Gerät.';
@override
String get settingsJournal => 'Journal';
@override
String get dreamPromptTitle => 'Nach dem Traum der letzten Nacht fragen';
@override
String get dreamPromptHint =>
'Unter Heute, von 4 bis 12 Uhr, bis du einen aufschreibst';
}

View file

@ -24,7 +24,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get accountDeleteMessage =>
'Your account is deleted on the server and on all your devices, with every sitting kept. This can\'t be undone.';
'Your account is deleted on the server and on all your devices, with every sitting and dream kept. This can\'t be undone.';
@override
String get aboutTitle => 'About Halkyon Meditate';
@ -440,6 +440,153 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get showStreakHint => 'On Today and in your Profile';
@override
String get shellTabJournal => 'Journal';
@override
String get journalSearch => 'Search dreams';
@override
String get journalEmptyTitle => 'Your dream journal';
@override
String get journalEmpty =>
'Write down a dream as soon as you wake, before it fades. Every entry is sealed on this device before it syncs: nobody else can read it, not even the instance.';
@override
String get journalNoMatch => 'No dream matches.';
@override
String get journalAdd => 'Write down a dream';
@override
String get journalFiguresTitle => 'Dream journal';
@override
String get figuresDreamsThisMonth => 'This month';
@override
String get figuresLucid => 'Lucid';
@override
String get markLucid => 'Lucid';
@override
String get markNightmare => 'Nightmare';
@override
String get markRecurring => 'Recurring';
@override
String get moodGlad => 'Glad';
@override
String get moodCalm => 'Calm';
@override
String get moodPuzzled => 'Puzzled';
@override
String get moodSad => 'Sad';
@override
String get moodUneasy => 'Uneasy';
@override
String get dreamNew => 'New dream';
@override
String get dreamLastNight => 'Last night';
@override
String dreamNight(String date) {
return 'Night before $date';
}
@override
String get dreamTextHint => 'What did you dream?';
@override
String get dreamTitleLabel => 'Title (optional)';
@override
String get dreamMore => 'Night, title, marks and more';
@override
String get dreamNightLabel => 'Night';
@override
String get dreamMarksLabel => 'Marks';
@override
String get dreamClarityLabel => 'Clarity';
@override
String get dreamClarityHint => 'How clearly you remember it, 1 to 5';
@override
String dreamClarityValue(int clarity) {
return 'Clarity $clarity/5';
}
@override
String get dreamMoodLabel => 'Mood on waking';
@override
String get dreamLabelsLabel => 'Labels';
@override
String get dreamLabelsHint => 'People, places, themes';
@override
String get dreamLabelAdd => 'Add label';
@override
String get dreamSave => 'Save';
@override
String get dreamEdit => 'Edit';
@override
String get dreamDelete => 'Delete';
@override
String get dreamDeleteTitle => 'Delete this dream?';
@override
String get dreamDeleteMessage =>
'It goes from your journal on all your devices.';
@override
String get dreamDiscardTitle => 'Discard this dream?';
@override
String get dreamDiscardMessage => 'What you wrote isn\'t saved.';
@override
String get dreamDiscard => 'Discard';
@override
String dreamWritten(String date) {
return 'Written $date';
}
@override
String get todayDreamPrompt => 'Write down last night\'s dream';
@override
String get todayDreamPromptHint => 'Before it fades. Sealed on this device.';
@override
String get settingsJournal => 'Journal';
@override
String get dreamPromptTitle => 'Ask for last night\'s dream';
@override
String get dreamPromptHint =>
'On Today, from 4 to 12 o\'clock, until you write one';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -5,6 +5,7 @@ import 'package:halkyon_meditate_flutter/src/app/meditate_dependencies.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_frame.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_languages.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_router.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/bloc/journal_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/practice_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -98,6 +99,12 @@ class _MeditatePersonAppState() extends State<MeditatePersonApp> {
return PracticeCubit(person.settings, person.sittings)..open();
},
),
// The person's dreams, for the Journal, Today and Profile (D14).
BlocProvider(
create: (context) => JournalCubit(
PersonScope.of<MeditatePerson>(context).dreams,
)..open(),
),
// Where sync stands: Settings' card and the dot on the cog.
BlocProvider(
create: (context) =>

View file

@ -9,6 +9,8 @@ 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';
import 'package:halkyon_meditate_flutter/src/core/sound/bell_player.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/data/dream_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/data/sync_dream_gateway.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/data/sitting_repository.dart';
@ -21,8 +23,8 @@ import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// A signed-in person's dependencies: who they are, their settings and
/// sittings on their own local database in their folder, which the sync
/// A signed-in person's dependencies: who they are, their settings,
/// sittings and dreams on their own local database in their folder, which the sync
/// engine keeps in step with their other devices, every row sealed with
/// their key (ADR 0038, D7), and the device's bells and screen for sitting
/// (D5, D6). halkyon_app's `PersonScope` opens and disposes them with the
@ -40,6 +42,9 @@ class MeditatePerson(
/// The sittings they kept, sealed and synced.
required final SittingRepository sittings,
/// The dreams they wrote down, sealed and synced (D14).
required final DreamRepository dreams,
/// The device's bells; none in tests that don't say.
final BellPlayer bells = const NoBells(),
@ -259,7 +264,7 @@ class MeditateDependencies({
}
/// Opens [person]'s own local database in [folder] (see [PersonFolders]), its
/// sync, their key, settings and sittings; [onClosed] runs once all is closed again.
/// sync, their key, settings, sittings and dreams; [onClosed] runs once all is closed again.
Future<MeditatePerson> _openPersonData(
Client client,
AppCompatibility compatibility,
@ -300,17 +305,20 @@ Future<MeditatePerson> _openPersonData(
await keys.refresh();
final settings = SettingsRepository(SyncSettingsGateway(db, sync), keys);
final sittings = SittingRepository(SyncSittingGateway(db, sync), keys);
final dreams = DreamRepository(SyncDreamGateway(db, sync), keys);
return MeditatePerson(
person,
sync: sync,
settings: settings,
sittings: sittings,
dreams: dreams,
bells: bells,
screen: screen,
onDispose: () async {
try {
await settings.dispose();
await sittings.dispose();
await dreams.dispose();
await sync.dispose();
await db.close();
} finally {

View file

@ -1,5 +1,5 @@
/// The app's locations (ADR 0038, D2): the three tabs, a practice's page in
/// Explore, and the sitting and Settings over the tabs.
/// The app's locations (ADR 0038, D2, D14): the four tabs, a practice's
/// page in Explore, and the sitting, a dream and Settings over the tabs.
abstract final class MeditatePaths() {
/// Today, where the app opens.
static const today = '/';
@ -10,6 +10,15 @@ abstract final class MeditatePaths() {
/// A practice's page, in Explore.
static String practice(String id) => '/explore/practices/$id';
/// The Journal: the dreams the person wrote down (D14).
static const journal = '/journal';
/// A new dream to write, over the tabs.
static const newDream = '/dream';
/// The dream [id], to read and edit, over the tabs.
static String dream(String id) => '/dream/$id';
/// Profile: every Halkyon app's, then the person's practice.
static const profile = '/profile';

View file

@ -1,19 +1,22 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_paths.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/widget/dream_screen.dart';
import 'package:halkyon_meditate_flutter/src/feature/practice/widget/practice_screen.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/sitting_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/widget/sitting_screen.dart';
import 'package:halkyon_meditate_flutter/src/settings/settings_screen.dart';
import 'package:halkyon_meditate_flutter/src/tabs/explore_tab.dart';
import 'package:halkyon_meditate_flutter/src/tabs/journal_tab.dart';
import 'package:halkyon_meditate_flutter/src/tabs/meditate_shell.dart';
import 'package:halkyon_meditate_flutter/src/tabs/profile_tab.dart';
import 'package:halkyon_meditate_flutter/src/tabs/today_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// The router of one person's app (ADR 0038, D1, D2), as Learn's and
/// Train's: the three tabs in a shell, each with its own stack of screens,
/// and the sitting and Settings over them. Every route is built inside the
/// person's scope, so screens read their repositories from `PersonScope`.
/// The router of one person's app (ADR 0038, D1, D2, D14), as Learn's and
/// Train's: the four tabs in a shell, each with its own stack of screens,
/// and the sitting, a dream and Settings over them. Every route is built
/// inside the person's scope, so screens read their repositories from
/// `PersonScope`.
/// A sitting is handed its plan as it is (`extra`); without one (a link
/// from elsewhere) it goes back to Today.
GoRouter buildMeditateRouter() => GoRouter(
@ -46,6 +49,14 @@ GoRouter buildMeditateRouter() => GoRouter(
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: MeditatePaths.journal,
builder: (context, state) => const JournalTab(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
@ -63,6 +74,20 @@ GoRouter buildMeditateRouter() => GoRouter(
builder: (context, state) =>
SittingScreen(plan: state.extra! as SittingPlan),
),
// A dream, new or kept (D14), in the frame without the rail, as
// Settings: writing on waking wants the whole screen.
GoRoute(
path: MeditatePaths.newDream,
builder: (context, state) => const FramedScreen(child: DreamScreen()),
routes: [
GoRoute(
path: ':id',
builder: (context, state) => FramedScreen(
child: DreamScreen(dreamId: state.pathParameters['id']),
),
),
],
),
// In the frame without the rail from medium windows on, as Learn's.
GoRoute(
path: MeditatePaths.settings,

View file

@ -0,0 +1,73 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_core/halkyon_core.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/data/dream_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.dart';
/// The person's dreams, the newest night first, once read.
final class const JournalState({
final bool loaded = false,
final List<StoredDream> dreams = const [],
}) with Equatable {
/// The dream [id], where it is kept and opens here.
Dream? dreamOf(String id) {
for (final stored in dreams) {
if (stored.id == id) {
return stored.dream;
}
}
return null;
}
@override
List<Object?> get props => [loaded, dreams];
}
/// Holds the person's dreams for the Journal, Today's morning card and
/// Profile's figures (ADR 0038, D14), read again whenever they change, here
/// or on another device; writes go through it.
class JournalCubit(final DreamRepository _dreams) extends Cubit<JournalState> {
this : super(const JournalState());
StreamSubscription<void>? _changes;
/// Reads them again when asked while a read runs, once after it.
late final _reads = CoalescingRunner(
_read,
onError: (error, stackTrace) =>
log('The journal not read: $error', name: 'Journal'),
);
/// Reads them, and follows them.
void open() {
_changes = _dreams.changes.listen((_) => unawaited(_reads.run()));
unawaited(_reads.run());
}
Future<void> _read() async {
final dreams = await _dreams.all();
if (!isClosed) {
emit(JournalState(loaded: true, dreams: dreams));
}
}
/// Keeps a new [dream] and returns its ID; throws `KeysMissing` where
/// this device can't seal.
Future<String> add(Dream dream) => _dreams.add(dream);
/// Keeps the dream [id] as [dream]; throws `KeysMissing` where this
/// device can't seal.
Future<void> update(String id, Dream dream) => _dreams.update(id, dream);
/// Deletes the dream [id].
Future<void> delete(String id) => _dreams.delete(id);
@override
Future<void> close() {
unawaited(_changes?.cancel());
return super.close();
}
}

View file

@ -0,0 +1,63 @@
import 'dart:typed_data';
import 'package:halkyon_meditate_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.dart';
/// The schema of a dream's payload this app writes and reads.
const dreamSchemaVersion = 1;
/// [dream] as its row's payload, before sealing: what it leaves empty left
/// out, the night as its date, marks and mood by name.
ByteData encodeDream(Dream dream) => encodeJson(dreamSchemaVersion, {
'night': _date(dream.night),
if (dream.title.isNotEmpty) 'title': dream.title,
'text': dream.text,
if (dream.marks.isNotEmpty)
'marks': [
for (final mark in DreamMark.values)
if (dream.has(mark)) mark.name,
],
'clarity': ?dream.clarity,
'mood': ?dream.mood?.name,
if (dream.labels.isNotEmpty) 'labels': dream.labels,
'writtenAt': dream.writtenAt.toUtc().toIso8601String(),
});
/// The dream [payload] holds, opened; null for a newer schema or one it
/// can't read. A mark, mood, clarity or label this version doesn't know
/// reads as none.
Dream? decodeDream(ByteData payload) => decodeJson(
payload,
version: dreamSchemaVersion,
name: 'Dream',
read: (json) => Dream(
night: DateTime.parse('${json['night']! as String}T00:00:00Z'),
title: json['title'] as String? ?? '',
text: json['text']! as String,
marks: switch (json['marks']) {
final List<Object?> names => {
for (final name in names) ?DreamMark.values.asNameMap()[name],
},
_ => const {},
},
clarity: switch (json['clarity']) {
final int clarity when clarity >= 1 && clarity <= maxClarity => clarity,
_ => null,
},
mood: WakingMood.values.asNameMap()[json['mood']],
labels: switch (json['labels']) {
final List<Object?> labels => [
for (final label in labels)
if (label is String) label,
],
_ => const [],
},
writtenAt: DateTime.parse(json['writtenAt']! as String).toUtc(),
),
);
/// [day] as its date alone, `2026-11-03`.
String _date(DateTime day) =>
'${day.year.toString().padLeft(4, '0')}-'
'${day.month.toString().padLeft(2, '0')}-'
'${day.day.toString().padLeft(2, '0')}';

View file

@ -0,0 +1,45 @@
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/core/sealed/sealed_row_gateway.dart';
import 'package:halkyon_meditate_flutter/src/core/sealed/sealed_rows.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/data/dream_payload.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/journal.dart';
/// The dreams the person wrote down (ADR 0038, D14): a row each, sealed with
/// their key for its row ([SealedRows]), as the sittings, changed and
/// deleted by the person alone. One that doesn't open here is left out.
class DreamRepository(
final SealedRowGateway _gateway,
final MeditateKeys _keys,
) {
late final _rows = SealedRows<Dream>(
_gateway,
_keys,
table: 'dream',
encode: encodeDream,
decode: decodeDream,
);
/// Fires when the dreams changed, here or on another device.
Stream<void> get changes => _rows.changes;
/// Every dream that opens here, the newest night first.
Future<List<StoredDream>> all() async => [
for (final row in await _rows.all())
if (row.content case final dream?) (id: row.id, dream: dream),
]..sort(newestDreamFirst);
/// Keeps a new [dream] and returns its ID; throws [KeysMissing] where
/// this device can't seal.
Future<String> add(Dream dream) => _rows.insert(dream);
/// Replaces the dream [id] with [dream]; throws [KeysMissing] where this
/// device can't seal.
Future<void> update(String id, Dream dream) => _rows.update(id, dream);
/// Deletes the dream [id].
Future<void> delete(String id) => _rows.delete([id]);
/// Closes what it holds open.
Future<void> dispose() => _rows.dispose();
}

View file

@ -0,0 +1,100 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
as protocol;
import 'package:halkyon_meditate_flutter/src/core/sealed/sealed_row_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// The person's dreams on their local database ([SealedRowGateway]),
/// which the sync engine keeps in step with their other devices.
class SyncDreamGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements SealedRowGateway {
@override
Future<List<SealedRow>> rows() async => [
for (final row in await protocol.Dream.db.find(
_db,
orderBy: (t) => t.id,
))
(
id: row.id!.uuid,
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
),
];
@override
Future<void> insert(SealedRow row) async {
await protocol.Dream.db.insertRow(
_db,
protocol.Dream(
id: protocol.UuidValue.withValidation(row.id),
keyId: row.envelope.keyId,
nonce: row.envelope.nonce,
payload: row.envelope.payload,
),
);
_sync.requestSync();
}
@override
Future<void> update(String id, Envelope envelope) async {
if (await protocol.Dream.db.findById(
_db,
protocol.UuidValue.withValidation(id),
)
case final row?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.Dream.db.updateRow(
_db,
row.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> delete(Iterable<String> ids) async {
final uuids = <protocol.UuidValue>{
for (final id in ids) protocol.UuidValue.withValidation(id),
};
if (uuids.isEmpty) {
return;
}
await _db.db.transaction((transaction) async {
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
// they last were, until the space goes.
for (final row in await protocol.Dream.db.find(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
)) {
await protocol.Dream.db.updateRow(
_db,
protocol.Dream(
id: row.id,
spaceId: row.spaceId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
}
await protocol.Dream.db.deleteWhere(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
);
});
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,81 @@
import 'package:equatable/equatable.dart';
/// What a dream may be marked as (ADR 0038, D14), each optional: their
/// names are kept in its payload, so never renamed once released.
enum DreamMark() {
/// The dreamer knew they dreamt.
lucid,
/// A frightening one.
nightmare,
/// One dreamt before.
recurring,
}
/// How the person felt on waking (D14): their names are kept in a dream's
/// payload, so never renamed once released.
enum WakingMood() {
glad,
calm,
puzzled,
sad,
uneasy,
}
/// The best clarity a dream can be given; the least is 1.
const maxClarity = 5;
/// A dream the person wrote down (ADR 0038, D14): the [night] it belongs
/// to, as the date of the morning after it (midnight UTC, as every day the
/// app counts), its [title] (empty where none) and [text], its [marks], how
/// clearly it was remembered (1 to [maxClarity], or none), the mood on
/// waking, free [labels] (people, places, themes) and when it was first
/// written (UTC). All of it sealed: the instance sees only that it exists.
final class const Dream({
required final DateTime night,
final String title = '',
required final String text,
final Set<DreamMark> marks = const {},
final int? clarity,
final WakingMood? mood,
final List<String> labels = const [],
required final DateTime writtenAt,
}) with Equatable {
/// Whether it carries [mark].
bool has(DreamMark mark) => marks.contains(mark);
Dream copyWith({
DateTime? night,
String? title,
String? text,
Set<DreamMark>? marks,
int? Function()? clarity,
WakingMood? Function()? mood,
List<String>? labels,
}) => Dream(
night: night ?? this.night,
title: title ?? this.title,
text: text ?? this.text,
marks: marks ?? this.marks,
clarity: clarity != null ? clarity() : this.clarity,
mood: mood != null ? mood() : this.mood,
labels: labels ?? this.labels,
writtenAt: writtenAt,
);
@override
List<Object?> get props => [
night,
title,
text,
marks,
clarity,
mood,
labels,
writtenAt,
];
}
/// A dream as kept: its row's ID and what it holds.
typedef StoredDream = ({String id, Dream dream});

View file

@ -0,0 +1,131 @@
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.dart';
import 'package:halkyon_meditate_flutter/src/feature/practice/model/practice.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/model/sitting.dart';
/// The hours of the morning Today asks for last night's dream (ADR 0038,
/// D14): from 4 o'clock, until 12.
const dreamPromptHours = (from: 4, until: 12);
/// The night a dream written at [now] (the wall clock) belongs to: last
/// night, named by the morning after it, so today's date at every hour (a
/// dream written at 1 o'clock woke the person from the night before
/// today's morning too).
DateTime defaultNight(DateTime now) => dayOf(now);
/// Whether Today asks for last night's dream at [now] (the wall clock):
/// where the person has it [on], in the morning ([dreamPromptHours]), while
/// none of [dreams] belongs to last night.
bool dreamPromptDue({
required DateTime now,
required bool on,
required Iterable<Dream> dreams,
}) {
if (!on ||
now.hour < dreamPromptHours.from ||
now.hour >= dreamPromptHours.until) {
return false;
}
final night = defaultNight(now);
return !dreams.any((dream) => dream.night == night);
}
/// The [dreams] whose title, text or labels hold every word of [query]
/// (without case or accents, as Explore searches) and which carry every
/// one of [marks], in their order.
List<StoredDream> findDreams(
List<StoredDream> dreams, {
String query = '',
Set<DreamMark> marks = const {},
}) => [
for (final stored in dreams)
if (stored.dream.marks.containsAll(marks) &&
matchesSearch(
[
stored.dream.title,
stored.dream.text,
...stored.dream.labels,
].join(' '),
query,
))
stored,
];
/// A month of the journal: its first day (midnight UTC) and its dreams.
typedef DreamMonth = ({DateTime month, List<StoredDream> dreams});
/// [dreams] by the month of their night, the newest month first, each
/// month's dreams the newest night first (the latest written first within
/// a night).
List<DreamMonth> dreamsByMonth(List<StoredDream> dreams) {
final sorted = [...dreams]..sort(newestDreamFirst);
final months = <DreamMonth>[];
for (final stored in sorted) {
final month = DateTime.utc(
stored.dream.night.year,
stored.dream.night.month,
);
if (months.isEmpty || months.last.month != month) {
months.add((month: month, dreams: []));
}
months.last.dreams.add(stored);
}
return months;
}
/// Orders dreams the newest night first, the latest written first within
/// a night.
int newestDreamFirst(StoredDream a, StoredDream b) {
final byNight = b.dream.night.compareTo(a.dream.night);
return byNight != 0
? byNight
: b.dream.writtenAt.compareTo(a.dream.writtenAt);
}
/// Profile's figures of the journal (D14): the dreams of [today]'s month
/// and the lucid ones of all.
({int thisMonth, int lucid}) journalFigures(
Iterable<Dream> dreams, {
required DateTime today,
}) => (
thisMonth: dreams
.where(
(dream) =>
dream.night.year == today.year && dream.night.month == today.month,
)
.length,
lucid: dreams.where((dream) => dream.has(DreamMark.lucid)).length,
);
/// The labels [dreams] carry, each once whatever its case (as first met),
/// the most used first: the ones the editor offers.
List<String> labelsUsed(Iterable<Dream> dreams) {
final first = <String, String>{};
final counts = <String, int>{};
for (final dream in dreams) {
for (final label in dream.labels) {
final key = label.toLowerCase();
first.putIfAbsent(key, () => label);
counts[key] = (counts[key] ?? 0) + 1;
}
}
// Those used alike keep the order first met: Dart's sort isn't stable, so
// the order met breaks ties.
final keys = first.keys.toList();
final order = {for (final (index, key) in keys.indexed) key: index};
keys.sort((a, b) {
final byCount = counts[b]!.compareTo(counts[a]!);
return byCount != 0 ? byCount : order[a]!.compareTo(order[b]!);
});
return [for (final key in keys) first[key]!];
}
/// [labels] with [label] added, trimmed, unless it is empty or there
/// already, whatever its case.
List<String> withLabel(List<String> labels, String label) {
final trimmed = label.trim();
if (trimmed.isEmpty ||
labels.any((known) => known.toLowerCase() == trimmed.toLowerCase())) {
return labels;
}
return [...labels, trimmed];
}

View file

@ -0,0 +1,497 @@
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_meditate_flutter/l10n/app_localizations.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';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/journal.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/widget/journal_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// How many of the labels used before the editor offers.
const _labelsOffered = 8;
/// A dream, over the tabs (ADR 0038, D14): a new one opens to write,
/// 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.
class const DreamScreen({
/// The dream kept under this ID; none for a new one.
final String? dreamId,
super.key,
}) extends StatefulWidget {
@override
State<DreamScreen> createState() => _DreamScreenState();
}
class _DreamScreenState() extends State<DreamScreen> {
final _text = TextEditingController();
final _title = TextEditingController();
final _label = TextEditingController();
late bool _editing = widget.dreamId == null;
late DateTime _night = defaultNight(clock.now());
var _marks = <DreamMark>{};
int? _clarity;
WakingMood? _mood;
var _labels = <String>[];
/// What the editor started from, to tell whether anything changed.
Dream? _started;
@override
void initState() {
super.initState();
_text.addListener(_changed);
_title.addListener(_changed);
if (_editing) {
_started = _draft();
}
}
@override
void dispose() {
_text.dispose();
_title.dispose();
_label.dispose();
super.dispose();
}
void _changed() => setState(() {});
/// The dream as the editor holds it, written at [writtenAt] (the first
/// writing's, for one kept).
Dream _draft({DateTime? writtenAt}) => Dream(
night: _night,
title: _title.text.trim(),
text: _text.text.trim(),
marks: _marks,
clarity: _clarity,
mood: _mood,
labels: withLabel(_labels, _label.text),
writtenAt: writtenAt ?? _started?.writtenAt ?? DateTime.utc(0),
);
bool get _unsaved => _editing && _draft() != _started;
/// Starts editing [dream].
void _edit(Dream dream) => setState(() {
_night = dream.night;
_title.text = dream.title;
_text.text = dream.text;
_marks = {...dream.marks};
_clarity = dream.clarity;
_mood = dream.mood;
_labels = [...dream.labels];
_label.clear();
_started = dream;
_editing = true;
});
Future<void> _save() async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final journal = context.read<JournalCubit>();
try {
if (widget.dreamId case final id?) {
await journal.update(id, _draft());
if (mounted) {
setState(() => _editing = false);
}
} else {
await journal.add(_draft(writtenAt: clock.now().toUtc()));
// Popped as asked, past the question about leaving unsaved.
if (mounted) {
context.pop();
}
}
} on KeysMissing {
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
}
}
Future<void> _delete(String id) async {
final l10n = AppLocalizations.of(context);
final journal = context.read<JournalCubit>();
if (await confirmDestructiveAction(
context,
title: l10n.dreamDeleteTitle,
message: l10n.dreamDeleteMessage,
confirmLabel: l10n.dreamDelete,
)) {
await journal.delete(id);
if (mounted) {
context.pop();
}
}
}
/// Leaving with a dream not saved asks first.
Future<void> _leave() async {
final l10n = AppLocalizations.of(context);
if (await confirmDestructiveAction(
context,
title: l10n.dreamDiscardTitle,
message: l10n.dreamDiscardMessage,
confirmLabel: l10n.dreamDiscard,
) &&
mounted) {
if (widget.dreamId != null) {
setState(() => _editing = false);
} else {
context.pop();
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final journal = context.watch<JournalCubit>().state;
final kept = switch (widget.dreamId) {
final id? => journal.dreamOf(id),
null => null,
};
final id = widget.dreamId;
return PopScope(
canPop: !_unsaved,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
unawaited(_leave());
}
},
child: TopGradient(
background: theme.colorScheme.surface,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(
id == null
? l10n.dreamNew
: nightText(context, _shownNight(kept)),
),
actions: [
if (_editing)
TextButton(
onPressed: _text.text.trim().isEmpty ? null : _save,
child: Text(l10n.dreamSave),
)
else if (kept != null && id != null) ...[
IconButton(
tooltip: l10n.dreamEdit,
icon: const Icon(LucideIcons.pencil),
onPressed: () => _edit(kept),
),
IconButton(
tooltip: l10n.dreamDelete,
icon: const Icon(LucideIcons.trash2),
onPressed: () => _delete(id),
),
],
const SizedBox(width: 4),
],
),
body: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: _editing
? _editor(context, journal)
: switch (kept) {
final dream? => _DreamReading(dream),
// Not read yet, or gone (deleted on another device).
null => const SizedBox.shrink(),
},
),
),
),
),
);
}
DateTime _shownNight(Dream? kept) =>
_editing ? _night : kept?.night ?? _night;
Widget _editor(BuildContext context, JournalState journal) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final offered = [
for (final label in labelsUsed([
for (final stored in journal.dreams) stored.dream,
]))
if (!_labels.any((own) => own.toLowerCase() == label.toLowerCase()))
label,
].take(_labelsOffered);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
TextField(
controller: _text,
autofocus: widget.dreamId == null,
minLines: 6,
maxLines: null,
maxLength: TextLimits.passage,
buildCounter: nearLimitCounter,
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: l10n.dreamTextHint,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
// Folded for a new dream: the text is what matters on waking.
ExpansionTile(
initiallyExpanded: widget.dreamId != null,
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
expandedCrossAxisAlignment: CrossAxisAlignment.stretch,
title: Text(l10n.dreamMore),
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.moon),
title: Text(l10n.dreamNightLabel),
subtitle: Text(nightText(context, _night)),
onTap: _pickNight,
),
TextField(
controller: _title,
maxLength: TextLimits.title,
buildCounter: nearLimitCounter,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: l10n.dreamTitleLabel,
border: const OutlineInputBorder(),
),
),
_Heading(l10n.dreamMarksLabel),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final mark in DreamMark.values)
FilterChip(
avatar: Icon(markIcon(mark)),
label: Text(markText(l10n, mark)),
selected: _marks.contains(mark),
onSelected: (on) => setState(
() => _marks = on
? {..._marks, mark}
: ({..._marks}..remove(mark)),
),
),
],
),
_Heading(l10n.dreamClarityLabel),
Text(
l10n.dreamClarityHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (var clarity = 1; clarity <= maxClarity; clarity++)
ChoiceChip(
label: Text('$clarity'),
selected: _clarity == clarity,
// Chosen again, it lets go.
onSelected: (_) => setState(
() => _clarity = _clarity == clarity ? null : clarity,
),
),
],
),
_Heading(l10n.dreamMoodLabel),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final mood in WakingMood.values)
ChoiceChip(
label: Text(moodText(l10n, mood)),
selected: _mood == mood,
onSelected: (_) =>
setState(() => _mood = _mood == mood ? null : mood),
),
],
),
_Heading(l10n.dreamLabelsLabel),
if (_labels.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final label in _labels)
InputChip(
label: Text(label),
onDeleted: () => setState(
() => _labels = [
for (final own in _labels)
if (own != label) own,
],
),
),
],
),
),
TextField(
controller: _label,
maxLength: TextLimits.name,
buildCounter: nearLimitCounter,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
hintText: l10n.dreamLabelsHint,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: l10n.dreamLabelAdd,
icon: const Icon(LucideIcons.plus),
onPressed: _addLabel,
),
),
onSubmitted: (_) => _addLabel(),
),
if (offered.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final label in offered)
ActionChip(
avatar: const Icon(LucideIcons.tag),
label: Text(label),
onPressed: () =>
setState(() => _labels = withLabel(_labels, label)),
),
],
),
],
),
],
);
}
void _addLabel() => setState(() {
_labels = withLabel(_labels, _label.text);
_label.clear();
});
Future<void> _pickNight() async {
final today = defaultNight(clock.now());
final picked = await showDatePicker(
context: context,
initialDate: DateTime(_night.year, _night.month, _night.day),
firstDate: DateTime(1900),
lastDate: DateTime(today.year, today.month, today.day),
);
if (picked != null && mounted) {
setState(
() => _night = DateTime.utc(picked.year, picked.month, picked.day),
);
}
}
}
/// A kept dream, to read.
class const _DreamReading(final Dream dream) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final locale = Localizations.localeOf(context).toString();
final details = [
if (dream.clarity case final clarity?) l10n.dreamClarityValue(clarity),
if (dream.mood case final mood?) moodText(l10n, mood),
];
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
if (dream.title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(dream.title, style: theme.textTheme.headlineSmall),
),
if (dream.marks.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final mark in DreamMark.values)
if (dream.has(mark))
Chip(
avatar: Icon(markIcon(mark)),
label: Text(markText(l10n, mark)),
),
],
),
),
if (details.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
details.join(' · '),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
SelectableText(dream.text, style: theme.textTheme.bodyLarge),
if (dream.labels.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final label in dream.labels)
Chip(
avatar: const Icon(LucideIcons.tag),
label: Text(label),
),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
l10n.dreamWritten(
DateFormat.yMMMd(
locale,
).add_Hm().format(dream.writtenAt.toLocal()),
),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
);
}
}
/// A part of the editor's heading.
class const _Heading(final String text) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Text(text, style: Theme.of(context).textTheme.titleSmall),
);
}

View file

@ -0,0 +1,50 @@
import 'package:clock/clock.dart';
import 'package:flutter/widgets.dart';
import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/journal.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
/// A mark's name.
String markText(AppLocalizations l10n, DreamMark mark) => switch (mark) {
DreamMark.lucid => l10n.markLucid,
DreamMark.nightmare => l10n.markNightmare,
DreamMark.recurring => l10n.markRecurring,
};
/// A mark's icon.
IconData markIcon(DreamMark mark) => switch (mark) {
DreamMark.lucid => LucideIcons.sparkles,
DreamMark.nightmare => LucideIcons.cloudLightning,
DreamMark.recurring => LucideIcons.repeat,
};
/// A mood's name.
String moodText(AppLocalizations l10n, WakingMood mood) => switch (mood) {
WakingMood.glad => l10n.moodGlad,
WakingMood.calm => l10n.moodCalm,
WakingMood.puzzled => l10n.moodPuzzled,
WakingMood.sad => l10n.moodSad,
WakingMood.uneasy => l10n.moodUneasy,
};
/// The [night] a dream belongs to, as the person reads it: *Last night*
/// for today's, else the night before its morning's date.
String nightText(BuildContext context, DateTime night) {
final l10n = AppLocalizations.of(context);
if (night == defaultNight(clock.now())) {
return l10n.dreamLastNight;
}
final locale = Localizations.localeOf(context).toString();
final now = clock.now();
final date = night.year == now.year
? DateFormat.MMMMd(locale).format(night)
: DateFormat.yMMMMd(locale).format(night);
return l10n.dreamNight(date);
}
/// What a dream is called in a list: its title, else its text's first
/// line.
String dreamHeading(Dream dream) =>
dream.title.isNotEmpty ? dream.title : dream.text.trim().split('\n').first;

Some files were not shown because too many files have changed in this diff Show more