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
26 changed files with 2762 additions and 41 deletions

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;

View file

@ -24,6 +24,8 @@ ByteData encodeSettings(MeditateSettings settings) => encodeJson(
if (settings.favourites.isNotEmpty) 'favourites': settings.favourites,
if (settings.showStreak != _first.showStreak)
'showStreak': settings.showStreak,
if (settings.dreamPrompt != _first.dreamPrompt)
'dreamPrompt': settings.dreamPrompt,
},
);
@ -58,6 +60,7 @@ MeditateSettings? decodeSettings(ByteData payload) => decodeJson(
_ => const [],
},
showStreak: _bool(json['showStreak'], _first.showStreak),
dreamPrompt: _bool(json['dreamPrompt'], _first.dreamPrompt),
),
);

View file

@ -19,8 +19,9 @@ enum Bell(
/// The person's settings of Meditate's (ADR 0038, D11), the same on each of
/// their devices: the bell, whether a sitting starts with it, how often it
/// rings between (0: never), whether the screen stays on while sitting, the
/// silent timer's length, the favourite practices and whether the streak
/// shows.
/// silent timer's length, the favourite practices, whether the streak
/// shows, and whether Today asks for last night's dream in the morning
/// (D14).
final class const MeditateSettings({
final Bell bell = Bell.bowl,
final bool startBell = true,
@ -29,6 +30,7 @@ final class const MeditateSettings({
final int timerMinutes = 10,
final List<String> favourites = const [],
final bool showStreak = true,
final bool dreamPrompt = true,
}) with Equatable {
/// The silent timer's shortest length, in minutes.
static const minTimerMinutes = 1;
@ -60,6 +62,7 @@ final class const MeditateSettings({
int? timerMinutes,
List<String>? favourites,
bool? showStreak,
bool? dreamPrompt,
}) => MeditateSettings(
bell: bell ?? this.bell,
startBell: startBell ?? this.startBell,
@ -68,6 +71,7 @@ final class const MeditateSettings({
timerMinutes: timerMinutes ?? this.timerMinutes,
favourites: favourites ?? this.favourites,
showStreak: showStreak ?? this.showStreak,
dreamPrompt: dreamPrompt ?? this.dreamPrompt,
);
@override
@ -79,5 +83,6 @@ final class const MeditateSettings({
timerMinutes,
favourites,
showStreak,
dreamPrompt,
];
}

View file

@ -135,6 +135,31 @@ class const PracticeSettingsCard({super.key}) extends StatelessWidget {
}
}
/// Settings' *Journal* (D14): whether Today asks for last night's dream in
/// the morning.
class const JournalSettingsCard({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final settings = context.select<PracticeCubit, MeditateSettings>(
(cubit) => cubit.state.settings,
);
return _Card(
title: l10n.settingsJournal,
children: [
SwitchListTile(
secondary: const Icon(LucideIcons.sunrise),
title: Text(l10n.dreamPromptTitle),
subtitle: Text(l10n.dreamPromptHint),
value: settings.dreamPrompt,
onChanged: (on) =>
_write(context, settings.copyWith(dreamPrompt: on)),
),
],
);
}
}
/// A group of Settings under its [title].
class const _Card({
required final String title,

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