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

View file

@ -1,14 +1,18 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_app.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_dependencies.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.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 'package:halkyon_meditate_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/model/meditate_settings.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/data/sitting_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/model/sitting.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/widget/sitting_screen.dart';
import 'package:halkyon_meditate_flutter/src/tabs/journal_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -26,6 +30,7 @@ void main() {
late SyncService sync;
late MemorySealedRowGateway settingsRows;
late MemorySealedRowGateway sittingRows;
late MemorySealedRowGateway dreamRows;
late RecordingBells bells;
late RecordingScreen screen;
@ -48,6 +53,7 @@ void main() {
);
settingsRows = MemorySealedRowGateway();
sittingRows = MemorySealedRowGateway();
dreamRows = MemorySealedRowGateway();
bells = RecordingBells();
screen = RecordingScreen();
store = MemoryInstanceStore();
@ -59,6 +65,9 @@ void main() {
/// The person's sittings, as another device of theirs reads them.
SittingRepository sittings() => SittingRepository(sittingRows, keys);
/// The person's dreams, as another device of theirs reads them.
DreamRepository dreams() => DreamRepository(dreamRows, keys);
/// The person's settings, as another device of theirs reads them.
SettingsRepository settings() => SettingsRepository(settingsRows, keys);
@ -138,6 +147,7 @@ void main() {
sync: sync,
settings: settings(),
sittings: sittings(),
dreams: dreams(),
bells: bells,
screen: screen,
);
@ -250,19 +260,24 @@ void main() {
});
group("Meditate's frame (ADR 0038, D1 to D3)", () {
testWidgets('a phone has Today, Explore and Profile, and opens on Today', (
tester,
) async {
await start(tester, usedBefore: true);
testWidgets(
'a phone has Today, Explore, Journal and Profile, and opens on Today',
(
tester,
) async {
await start(tester, usedBefore: true);
expect(opened.single, FakeAuthGateway.person);
expect(tabs(tester), ['Today', 'Explore', 'Profile']);
expect(
tester.widget<NavigationBar>(find.byType(NavigationBar)).selectedIndex,
0,
);
expect(find.widgetWithText(AppBar, 'Today'), findsOneWidget);
});
expect(opened.single, FakeAuthGateway.person);
expect(tabs(tester), ['Today', 'Explore', 'Journal', 'Profile']);
expect(
tester
.widget<NavigationBar>(find.byType(NavigationBar))
.selectedIndex,
0,
);
expect(find.widgetWithText(AppBar, 'Today'), findsOneWidget);
},
);
testWidgets('the tabs wash down from the top in fern', (tester) async {
await start(tester, usedBefore: true);
@ -305,7 +320,7 @@ void main() {
});
testWidgets(
'Settings has Sync first, then Sitting and Practice, the account, '
'Settings has Sync first, then Sitting, Practice and Journal, the account, '
'About Halkyon Meditate last and the version small at the very foot',
(tester) async {
await start(tester, usedBefore: true);
@ -316,6 +331,7 @@ void main() {
'Sitting',
'Bell',
'Show the days in a row',
"Ask for last night's dream",
'Account',
'Sign out',
'About Halkyon Meditate',
@ -648,6 +664,273 @@ void main() {
});
});
group('the Journal (ADR 0038, D14)', () {
final lighthouse = Dream(
night: DateTime.utc(2026, 11, 2),
title: 'The lighthouse',
text: 'I flew over the sea to my grandmother.',
marks: const {DreamMark.lucid},
labels: const ['Grandmother'],
writtenAt: DateTime.utc(2026, 11, 2, 6),
);
final exam = Dream(
night: DateTime.utc(2026, 10, 20),
text: 'Late for an exam at my old school.',
marks: const {DreamMark.nightmare},
writtenAt: DateTime.utc(2026, 10, 20, 7),
);
/// The Journal's dreams as their tiles show them, top to bottom.
List<String> tiles(WidgetTester tester) => [
for (final tile in tester.widgetList<ListTile>(
find.descendant(
of: find.byType(JournalTab),
matching: find.byType(ListTile),
),
))
(tile.title! as Text).data!,
];
/// Every dream kept, as another device of the person reads them.
Future<List<Dream>> kept() async => [
for (final stored in await dreams().all()) stored.dream,
];
testWidgets('a fourth tab between Explore and Profile; empty, it says '
'that dreams are sealed on the device', (tester) async {
await start(tester, usedBefore: true);
expect(tabs(tester), ['Today', 'Explore', 'Journal', 'Profile']);
await goTab(tester, 'Journal');
expect(find.text('Your dream journal'), findsOneWidget);
expect(
find.textContaining('sealed on this device before it syncs'),
findsOneWidget,
);
});
testWidgets('+ opens a new dream with its text first and focused and '
"the rest folded; saved, it is last night's, sealed", (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Journal');
await tester.tap(find.byTooltip('Write down a dream'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'New dream'), findsOneWidget);
final text = find.byType(TextField).first;
expect(
tester
.widget<EditableText>(
find.descendant(of: text, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
expect(find.text('Night, title, marks and more'), findsOneWidget);
expect(find.text('Title (optional)'), findsNothing);
expect(
tester
.widget<TextButton>(find.widgetWithText(TextButton, 'Save'))
.onPressed,
isNull,
reason: 'nothing written yet',
);
await tester.enterText(text, 'A house by the sea.');
await tester.pump();
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
final dream = (await kept()).single;
expect(dream.text, 'A house by the sea.');
expect(dream.night, dayOf(DateTime.now()));
expect(dreamRows.byId.values.single.keyId, isNotNull);
expect(find.byType(JournalTab), findsOneWidget);
expect(tiles(tester), ['A house by the sea.']);
expect(find.textContaining('Last night'), findsOneWidget);
});
testWidgets('leaving a dream written but not saved asks first', (
tester,
) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Journal');
await tester.tap(find.byTooltip('Write down a dream'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'A house.');
await tester.pump();
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Discard this dream?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('A house.'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('Discard'));
await tester.pumpAndSettle();
expect(find.byType(JournalTab), findsOneWidget);
expect(await kept(), isEmpty);
});
testWidgets('the newest month first; the search reads titles, texts and '
'labels, the marks filter', (tester) async {
await keys.refresh();
await dreams().add(exam);
await dreams().add(lighthouse);
await start(tester, usedBefore: true);
await goTab(tester, 'Journal');
expect(tiles(tester), ['The lighthouse', exam.text]);
expect(
tester.getTopLeft(find.text('November 2026')).dy,
lessThan(tester.getTopLeft(find.text('October 2026')).dy),
);
await tester.enterText(find.byType(TextField), 'grandmother');
await tester.pumpAndSettle();
expect(tiles(tester), ['The lighthouse']);
await tester.enterText(find.byType(TextField), 'SCHOOL');
await tester.pumpAndSettle();
expect(tiles(tester), [exam.text]);
await tester.enterText(find.byType(TextField), 'mountain');
await tester.pumpAndSettle();
expect(find.text('No dream matches.'), findsOneWidget);
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.widgetWithText(FilterChip, 'Lucid'));
await tester.pumpAndSettle();
expect(tiles(tester), ['The lighthouse']);
await tester.tap(find.widgetWithText(FilterChip, 'Nightmare'));
await tester.pumpAndSettle();
expect(find.text('No dream matches.'), findsOneWidget);
});
testWidgets('a dream opens to read; Edit changes it; Delete asks first', (
tester,
) async {
await keys.refresh();
await dreams().add(lighthouse);
await start(tester, usedBefore: true);
await goTab(tester, 'Journal');
await tester.tap(find.text('The lighthouse'));
await tester.pumpAndSettle();
expect(find.text(lighthouse.text), findsOneWidget);
expect(find.text('Grandmother'), findsOneWidget);
expect(find.byType(TextField), findsNothing, reason: 'read, not edited');
await tester.tap(find.byTooltip('Edit'));
await tester.pumpAndSettle();
// A kept dream's editor shows everything, unfolded.
await tester.enterText(
find.widgetWithText(TextField, 'Title (optional)'),
'The tall lighthouse',
);
await tapVisible(tester, find.widgetWithText(FilterChip, 'Recurring'));
await tapVisible(tester, find.widgetWithText(ChoiceChip, '4'));
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
expect(find.text('The tall lighthouse'), findsOneWidget);
expect(find.text('Clarity 4/5'), findsOneWidget);
expect(
(await kept()).single,
lighthouse.copyWith(
title: 'The tall lighthouse',
marks: {DreamMark.lucid, DreamMark.recurring},
clarity: () => 4,
),
);
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
expect(find.text('Delete this dream?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(await kept(), hasLength(1));
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete').last);
await tester.pumpAndSettle();
expect(await kept(), isEmpty);
expect(dreamRows.byId, isEmpty);
expect(find.text('Your dream journal'), findsOneWidget);
});
testWidgets("in the morning, Today asks for last night's dream and "
'leads to writing it', (tester) async {
await withClock(Clock.fixed(DateTime(2026, 11, 3, 8)), () async {
await start(tester, usedBefore: true);
expect(find.text("Write down last night's dream"), findsOneWidget);
await tester.tap(find.text("Write down last night's dream"));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'New dream'), findsOneWidget);
});
});
/// Starts Meditate signed in, the wall clock standing at [now].
Future<void> startAt(WidgetTester tester, DateTime now) =>
withClock(Clock.fixed(now), () => start(tester, usedBefore: true));
const prompt = "Write down last night's dream";
testWidgets("Today doesn't ask in the afternoon", (tester) async {
await startAt(tester, DateTime(2026, 11, 3, 14));
expect(find.text(prompt), findsNothing);
});
testWidgets("Today doesn't ask once last night has a dream", (
tester,
) async {
await keys.refresh();
await dreams().add(lighthouse.copyWith(night: DateTime.utc(2026, 11, 3)));
await startAt(tester, DateTime(2026, 11, 3, 8));
expect(find.text(prompt), findsNothing);
});
testWidgets("Today doesn't ask where Settings switched it off", (
tester,
) async {
await keys.refresh();
await settings().write(const MeditateSettings(dreamPrompt: false));
await startAt(tester, DateTime(2026, 11, 3, 8));
expect(find.text(prompt), findsNothing);
});
testWidgets("Settings' Journal switches Today's question off, in the "
"person's sealed settings", (tester) async {
await start(tester, usedBefore: true);
await openSettings(tester);
await tapInSettings(tester, "Ask for last night's dream");
expect(
await settings().read(),
const MeditateSettings(dreamPrompt: false),
);
});
testWidgets("Profile's Practice counts this month's dreams and the lucid "
'ones', (tester) async {
await keys.refresh();
final now = DateTime.now();
await dreams().add(lighthouse.copyWith(night: dayOf(now)));
await dreams().add(exam.copyWith(night: DateTime.utc(2000)));
await start(tester, usedBefore: true);
await goTab(tester, 'Profile');
await tester.tap(find.text('Practice'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Dream journal'));
expect(find.text('This month'), findsOneWidget);
expect(find.text('Lucid'), findsOneWidget);
});
});
testWidgets('signed out, it asks how to start, with an account to create '
'where passwords are offered (ADR 0017), and no GitHub (ADR 0020, '
'D36)', (tester) async {