feat(app): rich content, its editor and one read-only view

RichContent (lib/src/core/content) keeps the editor's document JSON as a deeply unmodifiable copy plus a formatVersion; it is pure Dart and compared by value (equatable), so models and later the server can use it. fromJson refuses formats newer than the app knows. The name avoids a clash with Flutter's RichText widget.

lib/src/core/editor converts it to and from the editor's Document (a mutable deep copy on the way in, since the editor keeps and changes parts of the JSON it gets) and holds RichContentEditor, which reports every document change as new RichContent and opens empty content with a paragraph to type into, and RichContentView, the one read-only renderer, sized to its content with IntrinsicHeight as the editor's own example does for lists.

A placeholder screen edits a document and shows it again after storing it as JSON and reading it back. It keeps its state with setState because it is temporary; blocs come with the first real feature. AGENTS.md describes RichContent and equatable.

Tests: equality, immutability, the JSON round trip with Japanese text and marks, format checks, the round trip through the editor document including an edit, and the view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 08:38:08 +02:00
commit 76130d311c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 542 additions and 2 deletions

View file

@ -74,6 +74,8 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Manual dependency injection.** One `Dependencies` object is the composition root, built at startup and exposed through an `InheritedWidget` scope. No service locator, no DI framework, no globals (the scaffold's global `client` moves into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic. - **Manual dependency injection.** One `Dependencies` object is the composition root, built at startup and exposed through an `InheritedWidget` scope. No service locator, no DI framework, no globals (the scaffold's global `client` moves into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's. - **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
- **State: `flutter_bloc`, uniformly.** Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one global `BlocObserver`. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point. - **State: `flutter_bloc`, uniformly.** Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one global `BlocObserver`. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point.
- **Value equality comes from `equatable`**, mixed in (`with Equatable`), for models and states alike; `leancode_lint`'s `missing_equatable_props` catches a field left out of `props`.
- **Rich text is `RichContent`** (`lib/src/core/content/`): the editor's document JSON plus a `formatVersion`, pure Dart, so models and the server can use it. `lib/src/core/editor/` converts it to and from the editor's `Document` and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`).
- **Time comes from `clock.now()`** (`package:clock`), never `DateTime.now()`, in server and app. Why: scheduling, streaks and expiry are deterministic under test (`withClock`, `fakeAsync`), and because Serverpod itself reads `clock.now()` (e.g. session expiry), `withClock` controls its behaviour too. - **Time comes from `clock.now()`** (`package:clock`), never `DateTime.now()`, in server and app. Why: scheduling, streaks and expiry are deterministic under test (`withClock`, `fakeAsync`), and because Serverpod itself reads `clock.now()` (e.g. session expiry), `withClock` controls its behaviour too.
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries. - **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever. - **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.

View file

@ -1,4 +1,5 @@
import 'package:halkyon_learn_flutter/client.dart'; import 'package:halkyon_learn_flutter/client.dart';
import 'package:halkyon_learn_flutter/src/feature/editor_playground/widget/editor_playground_screen.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
void main() async { void main() async {
@ -30,7 +31,7 @@ class const HalkyonLearnApp({super.key}) extends StatelessWidget {
// bridge is deprecated on purpose, as a reminder: remove it once they have moved. // bridge is deprecated on purpose, as a reminder: remove it once they have moved.
// ignore: deprecated_member_use // ignore: deprecated_member_use
builder: (context, child) => MaterialUiCompatibilityBridge(child: child!), builder: (context, child) => MaterialUiCompatibilityBridge(child: child!),
home: Scaffold(appBar: AppBar(title: const Text('Halkyon Learn'))), home: const EditorPlaygroundScreen(),
); );
} }
} }

View file

@ -0,0 +1,69 @@
import 'package:equatable/equatable.dart';
/// Rich text as the editor stores it: the editor's document JSON plus the
/// version of that format, so stored content can be migrated when it changes.
///
/// Pure Dart on purpose: models hold it, the data layer encodes it into the
/// content payload, and the server can read it without Flutter. The editor
/// converts it to and from its own document type in `core/editor`.
///
/// Made from the JSON the editor writes for a document, which it keeps as a
/// deeply unmodifiable copy.
final class RichContent(
Map<String, Object?> editorJson, {
/// The version of the format [editorJson] is in.
final int formatVersion = currentFormatVersion,
}) with Equatable {
/// A document without any blocks.
factory empty() => RichContent(const {
'document': {'type': 'page'},
});
/// Reads the JSON written by [toJson].
///
/// Throws a [FormatException] for anything else, including content in a
/// newer format than this app knows.
factory fromJson(Map<String, Object?> json) {
final formatVersion = json['formatVersion'];
final editorJson = json['editor'];
if (formatVersion is! int || editorJson is! Map<String, Object?>) {
throw FormatException('Not rich content.', json);
}
if (formatVersion > currentFormatVersion) {
throw FormatException(
'Rich content in format $formatVersion, but this app only knows '
'formats up to $currentFormatVersion.',
json,
);
}
return RichContent(editorJson, formatVersion: formatVersion);
}
/// The format this app writes.
static const currentFormatVersion = 1;
/// The editor's document JSON, deeply unmodifiable.
final Map<String, Object?> editorJson = _unmodifiableMap(editorJson);
@override
List<Object?> get props => [formatVersion, editorJson];
/// The JSON stored in the content payload.
Map<String, Object?> toJson() => {
'formatVersion': formatVersion,
'editor': editorJson,
};
}
Map<String, Object?> _unmodifiableMap(Map<String, Object?> map) =>
Map.unmodifiable({
for (final MapEntry(:key, :value) in map.entries)
key: _unmodifiableValue(value),
});
Object? _unmodifiableValue(Object? value) => switch (value) {
Map<String, Object?>() => _unmodifiableMap(value),
List<Object?>() => List<Object?>.unmodifiable(value.map(_unmodifiableValue)),
_ => value,
};

View file

@ -0,0 +1,29 @@
import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
/// Opens [RichContent] in the editor.
extension RichContentToDocument on RichContent {
/// A new editor document with this content.
///
/// Content in an older format gets migrated here once there is a second
/// format; until then, only the current one is accepted.
Document toDocument() {
if (formatVersion != RichContent.currentFormatVersion) {
throw UnsupportedError(
'No migration for rich content in format $formatVersion.',
);
}
// A mutable deep copy: the editor keeps parts of the JSON it is given and
// may change them in place, while RichContent's JSON is unmodifiable.
final json = jsonDecode(jsonEncode(editorJson)) as Map<String, Object?>;
return Document.fromJson(json);
}
}
/// Stores the editor's document as [RichContent].
extension DocumentToRichContent on Document {
/// This document's content, in the current format.
RichContent toRichContent() => RichContent(toJson());
}

View file

@ -0,0 +1,60 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/widgets.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
/// Edits [initialContent] and reports each change as new [RichContent].
///
/// Empty content opens with an empty paragraph, so there is somewhere to type.
class const RichContentEditor({
/// The content the editor opens with; later changes to it are ignored.
required final RichContent initialContent,
/// Called with the whole content after every change to the document.
required final ValueChanged<RichContent> onChanged,
super.key,
}) extends StatefulWidget {
@override
State<RichContentEditor> createState() => _RichContentEditorState();
}
class _RichContentEditorState() extends State<RichContentEditor> {
late final EditorState _editorState = EditorState(
document: _editable(widget.initialContent.toDocument()),
);
late final StreamSubscription<EditorTransactionValue> _changes;
@override
void initState() {
super.initState();
_changes = _editorState.transactionStream.listen(_onTransaction);
}
@override
void dispose() {
unawaited(_changes.cancel());
_editorState.dispose();
super.dispose();
}
void _onTransaction(EditorTransactionValue value) {
final (time, transaction, _) = value;
// Selection changes arrive as transactions too; only report the document.
if (time == TransactionTime.after && transaction.operations.isNotEmpty) {
widget.onChanged(_editorState.document.toRichContent());
}
}
@override
Widget build(BuildContext context) =>
AppFlowyEditor(editorState: _editorState);
}
Document _editable(Document document) {
if (document.root.children.isEmpty) {
document.insert([0], [paragraphNode()]);
}
return document;
}

View file

@ -0,0 +1,54 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/widgets.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
/// Shows [content] read-only: the one renderer for rich text wherever it is
/// shown rather than edited, so every block looks the same everywhere.
///
/// Takes the height of its content, so it fits into scrolling lists.
class const RichContentView({
/// The content to show.
required final RichContent content,
super.key,
}) extends StatefulWidget {
@override
State<RichContentView> createState() => _RichContentViewState();
}
class _RichContentViewState() extends State<RichContentView> {
late EditorState _editorState = _readOnly(widget.content);
@override
void didUpdateWidget(RichContentView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.content != oldWidget.content) {
// The old editor is still attached until this frame is built.
final previous = _editorState;
WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose());
_editorState = _readOnly(widget.content);
}
}
@override
void dispose() {
_editorState.dispose();
super.dispose();
}
// The editor lays itself out in an overlay that needs a bounded height;
// IntrinsicHeight gives it its content's height, as the editor's own example
// does for editors in a list.
@override
Widget build(BuildContext context) => IntrinsicHeight(
child: AppFlowyEditor(
key: ObjectKey(_editorState),
editorState: _editorState,
editable: false,
shrinkWrap: true,
),
);
}
EditorState _readOnly(RichContent content) =>
EditorState(document: content.toDocument())..editable = false;

View file

@ -0,0 +1,51 @@
import 'dart:convert';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:material_ui/material_ui.dart';
/// A placeholder until the first real content screen (step 7): edits a
/// document and shows it again after storing it as JSON and reading it back.
///
/// Keeps its state with setState instead of a bloc because it is temporary;
/// blocs arrive with the first real feature.
class const EditorPlaygroundScreen({super.key}) extends StatefulWidget {
@override
State<EditorPlaygroundScreen> createState() => _EditorPlaygroundScreenState();
}
class _EditorPlaygroundScreenState() extends State<EditorPlaygroundScreen> {
RichContent _stored = RichContent.empty();
void _store(RichContent content) {
final json = jsonEncode(content.toJson());
setState(() {
_stored = RichContent.fromJson(jsonDecode(json) as Map<String, Object?>);
});
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: const Text('Halkyon Learn')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Editor', style: textTheme.titleMedium),
SizedBox(
height: 240,
child: RichContentEditor(
initialContent: RichContent.empty(),
onChanged: _store,
),
),
const Divider(height: 32),
Text('Stored as JSON and read back', style: textTheme.titleMedium),
RichContentView(content: _stored),
],
),
);
}
}

View file

@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
void main() {
/// The editor's JSON for a page with one paragraph per text.
Map<String, Object?> paragraphs(List<String> texts) => {
'document': {
'type': 'page',
'children': [
for (final text in texts)
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
};
group('RichContent', () {
test('equals content with the same JSON, whatever the key order', () {
final a = RichContent({
'document': {'type': 'page', 'children': <Object?>[]},
});
final b = RichContent({
'document': {'children': <Object?>[], 'type': 'page'},
});
expect(a, b);
expect(a.hashCode, b.hashCode);
});
test('differs when the text differs', () {
expect(
RichContent(paragraphs(['猫'])),
isNot(RichContent(paragraphs(['犬']))),
);
});
test('differs when the format version differs', () {
expect(
RichContent(paragraphs(['猫'])),
isNot(RichContent(paragraphs(['猫']), formatVersion: 0)),
);
});
test('is not affected by later changes to the JSON it was made from', () {
final json = paragraphs(['猫']);
final content = RichContent(json);
final document = json['document']! as Map<String, Object?>;
(document['children']! as List<Object?>).clear();
expect(content, RichContent(paragraphs(['猫'])));
});
test('cannot be changed through its JSON', () {
final content = RichContent(paragraphs(['猫']));
final document = content.editorJson['document']! as Map<String, Object?>;
final children = document['children']! as List<Object?>;
expect(() => document['type'] = 'heading', throwsUnsupportedError);
expect(children.clear, throwsUnsupportedError);
});
test('survives a JSON round trip, Japanese text and marks included', () {
final content = RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'heading',
'data': {
'level': 2,
'delta': [
{'insert': '日本語'},
],
},
},
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': '猫',
'attributes': {'bold': true},
},
{'insert': 'は動物です。'},
],
},
},
],
},
});
final stored = jsonEncode(content.toJson());
final read = RichContent.fromJson(
jsonDecode(stored) as Map<String, Object?>,
);
expect(read, content);
});
test('rejects content in a newer format than this app knows', () {
expect(
() => RichContent.fromJson({
'formatVersion': RichContent.currentFormatVersion + 1,
'editor': RichContent.empty().editorJson,
}),
throwsFormatException,
);
});
test('rejects JSON that is not rich content', () {
expect(
() => RichContent.fromJson({'formatVersion': 1, 'editor': 'text'}),
throwsFormatException,
);
expect(
() => RichContent.fromJson({'editor': RichContent.empty().editorJson}),
throwsFormatException,
);
});
});
}

View file

@ -0,0 +1,84 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final content = RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'heading',
'data': {
'level': 2,
'delta': [
{'insert': '日本語'},
],
},
},
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': '猫',
'attributes': {'bold': true},
},
{'insert': 'は'},
{
'insert': '動物',
'attributes': {'href': 'https://ja.wikipedia.org/wiki/動物'},
},
{'insert': 'です。'},
],
},
},
{
'type': 'bulleted_list',
'data': {
'delta': [
{'insert': 'ねこ'},
],
},
},
],
},
});
group('RichContent and the editor document', () {
test('a round trip through the editor document changes nothing', () {
expect(content.toDocument().toRichContent(), content);
});
test('empty content is a blank document', () {
expect(RichContent.empty().toDocument().root.children, isEmpty);
expect(Document.blank().toRichContent(), RichContent.empty());
});
test('edits made in the editor are kept', () async {
final editorState = EditorState(document: content.toDocument());
addTearDown(editorState.dispose);
final paragraph = editorState.document.root.children[1];
await editorState.apply(
editorState.transaction..insertText(paragraph, 0, 'ほら、'),
);
final edited = editorState.document.toRichContent();
expect(edited, isNot(content));
expect(
edited.toDocument().root.children[1].delta?.toPlainText(),
'ほら、猫は動物です。',
);
});
test('refuses content in a format it has no migration for', () {
final older = RichContent(content.editorJson, formatVersion: 0);
expect(older.toDocument, throwsUnsupportedError);
});
});
}

View file

@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:material_ui/material_ui.dart';
void main() {
RichContent paragraph(String text) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
});
Future<void> show(WidgetTester tester, RichContent content) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ListView(children: [RichContentView(content: content)]),
),
),
);
// Flushes the editor's timers.
await tester.pump(const Duration(seconds: 1));
}
testWidgets('shows the content', (tester) async {
await show(tester, paragraph('猫は動物です。'));
expect(find.textContaining('猫は動物です。', findRichText: true), findsOneWidget);
});
testWidgets('shows new content when it changes', (tester) async {
await show(tester, paragraph('猫'));
await show(tester, paragraph('犬'));
expect(find.textContaining('犬', findRichText: true), findsOneWidget);
expect(find.textContaining('猫', findRichText: true), findsNothing);
});
}

View file

@ -1,10 +1,22 @@
import 'dart:ui' show Size;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/main.dart'; import 'package:halkyon_learn_flutter/main.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
void main() { void main() {
testWidgets('shows the app name', (tester) async { testWidgets('shows the app name and the editor playground', (tester) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(const HalkyonLearnApp()); await tester.pumpWidget(const HalkyonLearnApp());
// Flushes the editor's timers.
await tester.pump(const Duration(seconds: 1));
expect(find.text('Halkyon Learn'), findsOneWidget); expect(find.text('Halkyon Learn'), findsOneWidget);
expect(find.byType(RichContentEditor), findsOneWidget);
expect(find.byType(RichContentView), findsOneWidget);
}); });
} }