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

View file

@ -0,0 +1,173 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/data/dream_payload.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/data/dream_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_sealed_row_gateway.dart';
/// The dreams the person wrote down (ADR 0038, D14): each a row sealed with
/// their key for its row, as the sittings, the newest night first, none
/// readable to the instance, changed and deleted by the person alone.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late MemoryContentKeyGateway keyRows;
late MemorySealedRowGateway rows;
final opened = <MeditateKeys>[];
final lighthouse = Dream(
night: DateTime.utc(2026, 11, 3),
title: 'The lighthouse',
text: 'I flew over the sea to my grandmother.',
marks: const {DreamMark.lucid, DreamMark.recurring},
clarity: 4,
mood: WakingMood.glad,
labels: const ['Grandmother', 'Sea'],
writtenAt: DateTime.utc(2026, 11, 3, 6, 40),
);
final exam = Dream(
night: DateTime.utc(2026, 11),
text: 'Late for an exam.',
writtenAt: DateTime.utc(2026, 11, 3, 7),
);
Future<DreamRepository> device(UnlockedAccountKeys? account) async {
final keys = MeditateKeys(
keyRows,
SodiumContentCipher(),
() async => account,
);
opened.add(keys);
await keys.refresh();
return DreamRepository(rows, keys);
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
bob = (await crypto.newAccount('bob password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemorySealedRowGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('a dream written reads back whole, the newest night first, on every '
'device of the person', () async {
final phone = await device(alice);
await phone.add(exam);
await phone.add(lighthouse);
final laptop = await device(alice);
expect(
[for (final stored in await laptop.all()) stored.dream],
[lighthouse, exam],
);
});
test('sealed: the instance reads neither its words, its labels nor its '
'marks', () async {
final phone = await device(alice);
await phone.add(lighthouse);
final envelope = rows.byId.values.single;
expect(envelope.keyId, isNotNull);
final bytes = String.fromCharCodes(envelope.payload.buffer.asUint8List());
for (final word in ['lighthouse', 'grandmother', 'Sea', 'lucid']) {
expect(bytes, isNot(contains(word)));
}
});
test("another person's dreams never open, and are left out", () async {
final phone = await device(alice);
await phone.add(lighthouse);
final bobs = await device(bob);
expect(await bobs.all(), isEmpty);
});
test('a device without the keys writes nothing', () async {
final keyless = await device(null);
await expectLater(keyless.add(lighthouse), throwsA(isA<KeysMissing>()));
expect(rows.byId, isEmpty);
});
test('a dream changed keeps its row; deleted, it goes', () async {
final phone = await device(alice);
final id = await phone.add(exam);
final changed = exam.copyWith(title: 'Exams again', clarity: () => 2);
await phone.update(id, changed);
expect(await phone.all(), [(id: id, dream: changed)]);
await phone.delete(id);
expect(await phone.all(), isEmpty);
expect(rows.byId, isEmpty);
});
test("a row the instance moved to another's ID doesn't open", () async {
final phone = await device(alice);
final id = await phone.add(lighthouse);
rows.byId['019a0000-0000-7000-8000-000000000000'] = rows.byId.remove(id)!;
expect(await phone.all(), isEmpty);
});
group('its payload (schema 1)', () {
Map<String, Object?> json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
ByteData payload(Map<String, Object?> json) => ByteData.sublistView(
Uint8List.fromList(utf8.encode(jsonEncode(json))),
);
test('a bare dream is its night, text and time alone', () {
expect(json(encodeDream(exam)), {
'schemaVersion': dreamSchemaVersion,
'night': '2026-11-01',
'text': 'Late for an exam.',
'writtenAt': '2026-11-03T07:00:00.000Z',
});
});
test('a value this version does not know reads as none', () {
expect(
decodeDream(
payload({
'schemaVersion': 1,
'night': '2026-11-01',
'text': 'Late for an exam.',
'writtenAt': '2026-11-03T07:00:00.000Z',
'marks': ['lucid', 'flying'],
'clarity': 9,
'mood': 'elated',
'labels': ['School', 3],
}),
),
exam.copyWith(marks: {DreamMark.lucid}, labels: ['School']),
);
});
test('a newer schema is not read half', () {
expect(
decodeDream(payload({'schemaVersion': dreamSchemaVersion + 1})),
isNull,
);
});
});
}

View file

@ -0,0 +1,190 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/journal.dart';
/// The journal's reading of the dreams (ADR 0038, D14): the night a new one
/// belongs to, Today's morning card, the search and the marks' filters, the
/// months, the figures and the labels offered.
void main() {
Dream dream(
DateTime night, {
String title = '',
String text = 'A house by the sea.',
Set<DreamMark> marks = const {},
List<String> labels = const [],
DateTime? writtenAt,
}) => Dream(
night: night,
title: title,
text: text,
marks: marks,
labels: labels,
writtenAt: writtenAt ?? night.add(const Duration(hours: 7)),
);
StoredDream stored(String id, Dream dream) => (id: id, dream: dream);
group('a new dream', () {
test("belongs to last night: the morning's date, at any hour", () {
expect(
defaultNight(DateTime(2026, 11, 3, 6, 30)),
DateTime.utc(2026, 11, 3),
);
expect(
defaultNight(DateTime(2026, 11, 3, 23)),
DateTime.utc(2026, 11, 3),
);
expect(
defaultNight(DateTime(2026, 11, 3, 0, 15)),
DateTime.utc(2026, 11, 3),
);
});
});
group("Today's morning card", () {
final night = DateTime.utc(2026, 11, 3);
test("shows from 4 to 12 o'clock while last night has no dream", () {
for (final hour in [4, 8, 11]) {
expect(
dreamPromptDue(
now: DateTime(2026, 11, 3, hour),
on: true,
dreams: [],
),
isTrue,
reason: "$hour o'clock",
);
}
});
test('shows neither before 4 nor from 12 on', () {
for (final hour in [0, 3, 12, 18]) {
expect(
dreamPromptDue(
now: DateTime(2026, 11, 3, hour),
on: true,
dreams: [],
),
isFalse,
reason: "$hour o'clock",
);
}
});
test(
"goes once last night has a dream; an older night's doesn't count",
() {
final now = DateTime(2026, 11, 3, 8);
expect(
dreamPromptDue(now: now, on: true, dreams: [dream(night)]),
isFalse,
);
expect(
dreamPromptDue(
now: now,
on: true,
dreams: [dream(DateTime.utc(2026, 11, 2))],
),
isTrue,
);
},
);
test('never shows where it was switched off in Settings', () {
expect(
dreamPromptDue(now: DateTime(2026, 11, 3, 8), on: false, dreams: []),
isFalse,
);
});
});
group('the search and the filters', () {
final sea = stored(
'a',
dream(
DateTime.utc(2026, 11, 3),
title: 'The lighthouse',
text: 'I flew over the sea.',
marks: {DreamMark.lucid},
labels: ['Grandmother', 'Sea'],
),
);
final school = stored(
'b',
dream(
DateTime.utc(2026, 11),
title: 'Exams again',
text: 'Late for an exam at my old school.',
marks: {DreamMark.nightmare, DreamMark.recurring},
labels: ['School'],
),
);
final all = [sea, school];
test('searches the title, the text and the labels, without case or '
'accents', () {
expect(findDreams(all, query: 'LIGHTHOUSE'), [sea]);
expect(findDreams(all, query: 'exam school'), [school]);
expect(findDreams(all, query: 'grandmother'), [sea]);
expect(findDreams(all, query: 'séa'), [sea]);
expect(findDreams(all, query: 'mountain'), isEmpty);
expect(findDreams(all), all);
});
test(
'a dream shows under the marks chosen only where it has every one',
() {
expect(findDreams(all, marks: {DreamMark.lucid}), [sea]);
expect(findDreams(all, marks: {DreamMark.recurring}), [school]);
expect(
findDreams(all, marks: {DreamMark.lucid, DreamMark.nightmare}),
isEmpty,
);
},
);
});
test('the months, newest first, each with its dreams newest first', () {
final early = stored('a', dream(DateTime.utc(2026, 10, 2)));
final late = stored('b', dream(DateTime.utc(2026, 10, 28)));
final now = stored('c', dream(DateTime.utc(2026, 11, 3)));
final months = dreamsByMonth([now, late, early]);
expect(
[for (final month in months) month.month],
[
DateTime.utc(2026, 11),
DateTime.utc(2026, 10),
],
);
expect(months.last.dreams, [late, early]);
});
test("the figures: this month's dreams and the lucid ones of all", () {
final dreams = [
dream(DateTime.utc(2026, 11, 3), marks: {DreamMark.lucid}),
dream(DateTime.utc(2026, 11)),
dream(DateTime.utc(2026, 10, 30), marks: {DreamMark.lucid}),
];
expect(
journalFigures(dreams, today: DateTime.utc(2026, 11, 3)),
(thisMonth: 2, lucid: 2),
);
});
test('the labels offered: those used, the most used first, each once '
'whatever its case', () {
final dreams = [
dream(DateTime.utc(2026, 11, 3), labels: ['Sea', 'Grandmother']),
dream(DateTime.utc(2026, 11, 2), labels: ['sea']),
dream(DateTime.utc(2026, 11), labels: ['School']),
];
expect(labelsUsed(dreams), ['Sea', 'Grandmother', 'School']);
});
test('a label is added trimmed, once whatever its case, never empty', () {
expect(withLabel(['Sea'], ' school '), ['Sea', 'school']);
expect(withLabel(['Sea'], 'SEA'), ['Sea']);
expect(withLabel(['Sea'], ' '), ['Sea']);
});
}

View file

@ -31,6 +31,7 @@ void main() {
timerMinutes: 25,
favourites: ['body-scan', 'breath-counting'],
showStreak: false,
dreamPrompt: false,
);
expect(decodeSettings(encodeSettings(changed)), changed);
});