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:
parent
83edaeabe0
commit
86c9f4522a
26 changed files with 2762 additions and 41 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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')}';
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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});
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue