From fa5ccdaa96435d92f99e028c4b303d3729bcfd86 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 29 Mar 2026 17:59:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20annotations=20subsystem=20=E2=80=94=20c?= =?UTF-8?q?ore=20models,=20drift=20DB,=20cubits,=20and=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the complete annotations feature for marking and annotating script text: Core models (horatio_core): - TextMark, LineNote, AnnotationSnapshot, MarkType, NoteCategory - Script.id field + UUID generation in text_parser Database layer (horatio_app): - Drift tables: text_marks, line_notes, annotation_snapshots - AppDatabase with AnnotationDao (full CRUD + streams + bulk replace) State management: - AnnotationCubit: mark/note CRUD, line selection, editing context - AnnotationHistoryCubit: snapshot save/restore with stream updates UI components: - MarkOverlay: colored span rendering for text marks - NoteIndicator: per-line note count badge - MarkTypePicker: 6-type ActionChip selector - NoteEditorSheet: category dropdown + text field bottom sheet - AnnotationEditorScreen: full editor with long-press marks + note editing - AnnotationHistoryScreen: snapshot timeline with restore dialog Wiring: - main.dart: async DB init with path_provider - app.dart: RepositoryProvider - router.dart: /annotations + /annotation-history routes - role_selection_screen: Annotate Script option - run.sh: app_codegen step + coverage filtering for generated code 352 tests (105 core + 247 app), 100% branch coverage, zero dead code. --- .../plans/2026-03-29-annotations.md | 2980 +++++++++++++++++ .../specs/2026-03-29-annotations-design.md | 491 +++ horatio/horatio_app/lib/app.dart | 10 +- .../lib/bloc/annotation/annotation_cubit.dart | 143 + .../annotation/annotation_history_cubit.dart | 67 + .../annotation/annotation_history_state.dart | 29 + .../lib/bloc/annotation/annotation_state.dart | 78 + .../lib/database/app_database.dart | 23 + .../lib/database/app_database.g.dart | 1918 +++++++++++ .../lib/database/daos/annotation_dao.dart | 188 ++ .../lib/database/daos/annotation_dao.g.dart | 32 + .../tables/annotation_snapshots_table.dart | 15 + .../lib/database/tables/line_notes_table.dart | 17 + .../lib/database/tables/text_marks_table.dart | 18 + horatio/horatio_app/lib/main.dart | 15 +- horatio/horatio_app/lib/router.dart | 30 + .../lib/screens/annotation_editor_screen.dart | 204 ++ .../screens/annotation_history_screen.dart | 102 + .../lib/screens/role_selection_screen.dart | 9 + .../horatio_app/lib/widgets/mark_overlay.dart | 87 + .../lib/widgets/mark_type_picker.dart | 53 + .../lib/widgets/note_editor_sheet.dart | 121 + .../lib/widgets/note_indicator.dart | 42 + horatio/horatio_app/pubspec.lock | 146 +- horatio/horatio_app/pubspec.yaml | 3 + horatio/horatio_app/test/app_test.dart | 42 +- .../test/bloc/annotation_cubit_test.dart | 275 ++ .../bloc/annotation_history_cubit_test.dart | 169 + .../test/bloc/rehearsal_cubit_test.dart | 1 + .../test/bloc/script_import_cubit_test.dart | 1 + .../test/database/annotation_dao_test.dart | 175 + .../test/helpers/test_database.dart | 5 + horatio/horatio_app/test/router_test.dart | 105 + .../annotation_editor_screen_test.dart | 395 +++ .../annotation_history_screen_test.dart | 228 ++ .../test/screens/home_screen_test.dart | 4 + .../test/screens/import_screen_test.dart | 1 + .../test/screens/rehearsal_screen_test.dart | 1 + .../screens/role_selection_screen_test.dart | 36 + .../test/screens/schedule_screen_test.dart | 2 + .../test/widgets/mark_overlay_test.dart | 129 + .../test/widgets/mark_type_picker_test.dart | 60 + .../test/widgets/note_editor_sheet_test.dart | 158 + .../test/widgets/note_indicator_test.dart | 53 + .../lib/src/models/annotation_snapshot.dart | 62 + .../lib/src/models/line_note.dart | 55 + .../lib/src/models/mark_type.dart | 20 + .../horatio_core/lib/src/models/models.dart | 5 + .../lib/src/models/note_category.dart | 20 + .../horatio_core/lib/src/models/script.dart | 4 + .../lib/src/models/text_mark.dart | 67 + .../lib/src/parser/text_parser.dart | 2 + horatio/horatio_core/pubspec.yaml | 6 +- .../horatio_core/test/models/model_test.dart | 304 ++ .../test/parser/text_parser_test.dart | 6 + .../test/planner/planner_test.dart | 2 + horatio/run.sh | 25 +- 57 files changed, 9226 insertions(+), 13 deletions(-) create mode 100644 horatio/docs/superpowers/plans/2026-03-29-annotations.md create mode 100644 horatio/docs/superpowers/specs/2026-03-29-annotations-design.md create mode 100644 horatio/horatio_app/lib/bloc/annotation/annotation_cubit.dart create mode 100644 horatio/horatio_app/lib/bloc/annotation/annotation_history_cubit.dart create mode 100644 horatio/horatio_app/lib/bloc/annotation/annotation_history_state.dart create mode 100644 horatio/horatio_app/lib/bloc/annotation/annotation_state.dart create mode 100644 horatio/horatio_app/lib/database/app_database.dart create mode 100644 horatio/horatio_app/lib/database/app_database.g.dart create mode 100644 horatio/horatio_app/lib/database/daos/annotation_dao.dart create mode 100644 horatio/horatio_app/lib/database/daos/annotation_dao.g.dart create mode 100644 horatio/horatio_app/lib/database/tables/annotation_snapshots_table.dart create mode 100644 horatio/horatio_app/lib/database/tables/line_notes_table.dart create mode 100644 horatio/horatio_app/lib/database/tables/text_marks_table.dart create mode 100644 horatio/horatio_app/lib/screens/annotation_editor_screen.dart create mode 100644 horatio/horatio_app/lib/screens/annotation_history_screen.dart create mode 100644 horatio/horatio_app/lib/widgets/mark_overlay.dart create mode 100644 horatio/horatio_app/lib/widgets/mark_type_picker.dart create mode 100644 horatio/horatio_app/lib/widgets/note_editor_sheet.dart create mode 100644 horatio/horatio_app/lib/widgets/note_indicator.dart create mode 100644 horatio/horatio_app/test/bloc/annotation_cubit_test.dart create mode 100644 horatio/horatio_app/test/bloc/annotation_history_cubit_test.dart create mode 100644 horatio/horatio_app/test/database/annotation_dao_test.dart create mode 100644 horatio/horatio_app/test/helpers/test_database.dart create mode 100644 horatio/horatio_app/test/screens/annotation_editor_screen_test.dart create mode 100644 horatio/horatio_app/test/screens/annotation_history_screen_test.dart create mode 100644 horatio/horatio_app/test/widgets/mark_overlay_test.dart create mode 100644 horatio/horatio_app/test/widgets/mark_type_picker_test.dart create mode 100644 horatio/horatio_app/test/widgets/note_editor_sheet_test.dart create mode 100644 horatio/horatio_app/test/widgets/note_indicator_test.dart create mode 100644 horatio/horatio_core/lib/src/models/annotation_snapshot.dart create mode 100644 horatio/horatio_core/lib/src/models/line_note.dart create mode 100644 horatio/horatio_core/lib/src/models/mark_type.dart create mode 100644 horatio/horatio_core/lib/src/models/note_category.dart create mode 100644 horatio/horatio_core/lib/src/models/text_mark.dart diff --git a/horatio/docs/superpowers/plans/2026-03-29-annotations.md b/horatio/docs/superpowers/plans/2026-03-29-annotations.md new file mode 100644 index 0000000..e2a4c40 --- /dev/null +++ b/horatio/docs/superpowers/plans/2026-03-29-annotations.md @@ -0,0 +1,2980 @@ +# Annotations Subsystem Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development +> (if subagents available) or superpowers:executing-plans to implement this plan. +> Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add text-level marks and line-level notes to script lines with drift +persistence and change history via snapshots. + +**Architecture:** Core annotation models (TextMark, LineNote, AnnotationSnapshot) +in `horatio_core`. Drift database, DAOs, cubits, and UI screens in `horatio_app`. +Two cubits: `AnnotationCubit` for CRUD, `AnnotationHistoryCubit` for snapshots. + +**Tech Stack:** Dart 3.11, Flutter 3.x, drift 2.22, flutter_bloc 9, equatable 2, +uuid, build_runner + drift_dev (codegen) + +**Spec:** `docs/superpowers/specs/2026-03-29-annotations-design.md` + +--- + +## Chunk 1: Core Models + Script Identity + +### Task 1: Add `uuid` and `meta` dependencies to horatio_core + +**Files:** + +- Modify: `horatio_core/pubspec.yaml:11-14` + +- [ ] **Step 1: Add uuid and meta dependencies** + +Add `uuid` and `meta` to the dependencies section of `horatio_core/pubspec.yaml`. +`meta` is needed for `@immutable` on `TextMark` and `AnnotationSnapshot`: + +```yaml +dependencies: + collection: ^1.18.0 + meta: ^1.16.0 + uuid: ^4.5.1 + xml: ^6.5.0 + archive: ^4.0.0 +``` + +- [ ] **Step 2: Run pub get to verify** + +```bash +cd horatio_core && dart pub get +``` + +Expected: resolves successfully, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add horatio_core/pubspec.yaml horatio_core/pubspec.lock +git commit -m "feat(core): add uuid and meta dependencies for annotations" +``` + +--- + +### Task 2: Add `id` field to Script model + +**Files:** + +- Modify: `horatio_core/lib/src/models/script.dart` +- Modify: `horatio_core/test/models/model_test.dart` + +- [ ] **Step 1: Write failing test for Script.id** + +Add to `horatio_core/test/models/model_test.dart` in the `Script` group: + +```dart + test('id field is accessible', () { + const script = Script( + id: 'test-uuid-123', + title: 'Test', + roles: [], + scenes: [], + ); + expect(script.id, 'test-uuid-123'); + }); + + // NOTE: 'toString includes title, role count, scene count' test already + // exists in model_test.dart. Update testScript to include `id:` parameter + // instead of adding a duplicate test. +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd horatio_core && dart test test/models/model_test.dart -v +``` + +Expected: compilation error — `Script` doesn't have an `id` parameter. + +- [ ] **Step 3: Add id field to Script** + +Modify `horatio_core/lib/src/models/script.dart`: + +```dart +import 'package:horatio_core/src/models/role.dart'; +import 'package:horatio_core/src/models/scene.dart'; + +/// A fully parsed script with metadata, roles, and scenes. +final class Script { + /// Creates a [Script] from parsed data. + const Script({ + required this.id, + required this.title, + required this.roles, + required this.scenes, + }); + + /// Unique identifier (UUID) for this script. + final String id; + + /// The title of the script. + final String title; + + /// All character roles detected in the script. + final List roles; + + /// Scenes in order. + final List scenes; + + /// Returns all lines in the script across all scenes. + int get totalLineCount => + scenes.fold(0, (sum, scene) => sum + scene.lines.length); + + /// Returns the number of lines for a specific [role]. + int lineCountForRole(Role role) => scenes.fold( + 0, + (sum, scene) => sum + scene.lines.where((line) => line.role == role).length, + ); + + @override + String toString() => + 'Script($title, ${roles.length} roles, ${scenes.length} scenes)'; +} +``` + +- [ ] **Step 4: Fix all callers that create Script instances** + +Every `Script(...)` constructor call now needs an `id:` parameter. Update these +files (find all sites with `grep -rn 'Script(' horatio/`): + +1. `horatio_core/test/models/model_test.dart` — add `id: 'test-id'` to all + `Script(...)` calls +2. `horatio_core/lib/src/parser/text_parser.dart` — add UUID import and + generate ID at parse time. Add to the top of the file: + ```dart + import 'package:uuid/uuid.dart'; + ``` + Replace the existing `return Script(...)` in the `parse()` method with: + ```dart + return Script( + id: const Uuid().v4(), + title: title, + roles: List.unmodifiable(roles.values.toList()), + scenes: List.unmodifiable(scenes), + ); + ``` +3. `horatio_core/test/parser/text_parser_test.dart` — update any hardcoded + Script assertions to allow any `id` +4. `horatio_core/test/planner/planner_test.dart` — add `id:` to test scripts +5. `horatio_core/test/srs/srs_test.dart` — add `id:` if Script is constructed +6. `horatio_app/test/` — add `id:` to ALL test files that create Script objects. + Search for `Script(` across the test directory. +7. `horatio_app/lib/bloc/script_import/script_import_cubit.dart` — ensure + parser-created scripts already have IDs (parser generates them) +8. Asset-loaded scripts (`importFromAsset`) — parse via TextParser which + generates UUID, so no change needed +9. Any demo/fixture scripts in test helpers + +Run a workspace-wide search for `Script(` to find every site. + +- [ ] **Step 5: Run all tests to verify** + +```bash +cd horatio_core && dart test +cd ../horatio_app && flutter test +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(core): add id (UUID) field to Script model" +``` + +--- + +### Task 3: Add MarkType and NoteCategory enums + +**Files:** + +- Create: `horatio_core/lib/src/models/mark_type.dart` +- Create: `horatio_core/lib/src/models/note_category.dart` +- Modify: `horatio_core/lib/src/models/models.dart` (barrel exports) + +- [ ] **Step 1: Create MarkType enum** + +Create `horatio_core/lib/src/models/mark_type.dart`: + +```dart +/// Types of text-level delivery marks an actor can place on script text. +enum MarkType { + /// Stress / emphasize this word. + stress, + + /// Pause before this span. + pause, + + /// Take a breath here. + breath, + + /// General emphasis. + emphasis, + + /// Deliver this span slower. + slowDown, + + /// Deliver this span faster. + speedUp, +} +``` + +- [ ] **Step 2: Create NoteCategory enum** + +Create `horatio_core/lib/src/models/note_category.dart`: + +```dart +/// Categories for line-level interpretive notes. +enum NoteCategory { + /// "What does the character want here?" + intention, + + /// "What are they really saying?" + subtext, + + /// "Cross downstage on this line." + blocking, + + /// "Suppressed anger building." + emotion, + + /// "Whisper this line." + delivery, + + /// Catch-all for uncategorized notes. + general, +} +``` + +- [ ] **Step 3: Add barrel exports** + +Add to `horatio_core/lib/src/models/models.dart`: + +```dart +export 'mark_type.dart'; +export 'note_category.dart'; +export 'role.dart'; +export 'scene.dart'; +export 'script.dart'; +export 'script_line.dart'; +export 'srs_card.dart'; +export 'stage_direction.dart'; +``` + +- [ ] **Step 4: Run analysis** + +```bash +cd horatio_core && dart analyze --fatal-infos +``` + +Expected: no issues. + +> **Note (S1):** Enum value count tests (`expect(MarkType.values.length, 6)`) +> are not needed separately — the serialization round-trip tests in Tasks 4 +> and 5 iterate all enum values, catching any accidental additions or removals. + +- [ ] **Step 5: Commit** + +```bash +git add horatio_core/lib/src/models/mark_type.dart \ + horatio_core/lib/src/models/note_category.dart \ + horatio_core/lib/src/models/models.dart +git commit -m "feat(core): add MarkType and NoteCategory enums" +``` + +--- + +### Task 4: Add TextMark model + +**Files:** + +- Create: `horatio_core/lib/src/models/text_mark.dart` +- Modify: `horatio_core/lib/src/models/models.dart` +- Modify: `horatio_core/test/models/model_test.dart` + +- [ ] **Step 1: Write failing tests** + +Add to `horatio_core/test/models/model_test.dart`: + +```dart + group('TextMark', () { + test('construction with valid offsets', () { + final mark = TextMark( + id: 'mark-1', + lineIndex: 0, + startOffset: 5, + endOffset: 10, + type: MarkType.stress, + createdAt: DateTime.utc(2026, 3, 29), + ); + expect(mark.id, 'mark-1'); + expect(mark.lineIndex, 0); + expect(mark.startOffset, 5); + expect(mark.endOffset, 10); + expect(mark.type, MarkType.stress); + }); + + test('equality uses id only', () { + final a = TextMark( + id: 'mark-1', + lineIndex: 0, + startOffset: 5, + endOffset: 10, + type: MarkType.stress, + createdAt: DateTime.utc(2026, 3, 29), + ); + final b = TextMark( + id: 'mark-1', + lineIndex: 99, + startOffset: 0, + endOffset: 1, + type: MarkType.pause, + createdAt: DateTime.utc(2026, 1, 1), + ); + final c = TextMark( + id: 'mark-2', + lineIndex: 0, + startOffset: 5, + endOffset: 10, + type: MarkType.stress, + createdAt: DateTime.utc(2026, 3, 29), + ); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + expect(a == a, isTrue); // identical + }); + + test('hashCode consistent with equality', () { + final a = TextMark( + id: 'mark-1', + lineIndex: 0, + startOffset: 5, + endOffset: 10, + type: MarkType.stress, + createdAt: DateTime.utc(2026, 3, 29), + ); + final b = TextMark( + id: 'mark-1', + lineIndex: 99, + startOffset: 0, + endOffset: 1, + type: MarkType.pause, + createdAt: DateTime.utc(2026, 1, 1), + ); + expect(a.hashCode, b.hashCode); + }); + + test('assert fails for negative startOffset', () { + expect( + () => TextMark( + id: 'x', + lineIndex: 0, + startOffset: -1, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ), + throwsA(isA()), + ); + }); + + test('assert fails when endOffset <= startOffset', () { + expect( + () => TextMark( + id: 'x', + lineIndex: 0, + startOffset: 5, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ), + throwsA(isA()), + ); + }); + + test('toJson roundtrip', () { + final original = TextMark( + id: 'mark-1', + lineIndex: 3, + startOffset: 0, + endOffset: 7, + type: MarkType.breath, + createdAt: DateTime.utc(2026, 3, 29, 12, 30), + ); + final json = original.toJson(); + final restored = TextMark.fromJson(json); + expect(restored.id, original.id); + expect(restored.lineIndex, original.lineIndex); + expect(restored.startOffset, original.startOffset); + expect(restored.endOffset, original.endOffset); + expect(restored.type, original.type); + expect(restored.createdAt, original.createdAt); + }); + + test('fromJson with invalid type throws ArgumentError', () { + final json = { + 'id': 'x', + 'lineIndex': 0, + 'startOffset': 0, + 'endOffset': 1, + 'type': 'nonexistent', + 'createdAt': '2026-03-29T00:00:00.000Z', + }; + expect(() => TextMark.fromJson(json), throwsArgumentError); + }); + + test('toJson serializes all MarkType values', () { + for (final type in MarkType.values) { + final mark = TextMark( + id: 'id-${type.name}', + lineIndex: 0, + startOffset: 0, + endOffset: 1, + type: type, + createdAt: DateTime.utc(2026), + ); + final json = mark.toJson(); + expect(json['type'], type.name); + final restored = TextMark.fromJson(json); + expect(restored.type, type); + } + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd horatio_core && dart test test/models/model_test.dart -v +``` + +Expected: compilation error — `TextMark` not defined. + +- [ ] **Step 3: Create TextMark model** + +Create `horatio_core/lib/src/models/text_mark.dart`: + +```dart +import 'package:meta/meta.dart'; + +import 'package:horatio_core/src/models/mark_type.dart'; + +/// A span-based delivery mark on text within a script line. +@immutable +final class TextMark { + /// Creates a [TextMark] with validated offsets. + TextMark({ + required this.id, + required this.lineIndex, + required this.startOffset, + required this.endOffset, + required this.type, + required this.createdAt, + }) { + assert(startOffset >= 0, 'startOffset must be non-negative'); + assert(endOffset > startOffset, 'endOffset must be greater than startOffset'); + } + + /// Unique identifier (UUID). + final String id; + + /// Index of the [ScriptLine] this mark applies to. + final int lineIndex; + + /// Start character offset in the line text (inclusive). + final int startOffset; + + /// End character offset in the line text (exclusive). + final int endOffset; + + /// The type of delivery mark. + final MarkType type; + + /// When this mark was created. + final DateTime createdAt; + + @override + bool operator ==(Object other) => + identical(this, other) || other is TextMark && id == other.id; + + @override + int get hashCode => id.hashCode; + + /// Serializes to a JSON-compatible map. + Map toJson() => { + 'id': id, + 'lineIndex': lineIndex, + 'startOffset': startOffset, + 'endOffset': endOffset, + 'type': type.name, + 'createdAt': createdAt.toUtc().toIso8601String(), + }; + + /// Deserializes from a JSON map. + /// + /// Throws [ArgumentError] if [type] is not a valid [MarkType] name. + factory TextMark.fromJson(Map json) => TextMark( + id: json['id'] as String, + lineIndex: json['lineIndex'] as int, + startOffset: json['startOffset'] as int, + endOffset: json['endOffset'] as int, + type: MarkType.values.byName(json['type'] as String), + createdAt: DateTime.parse(json['createdAt'] as String), + ); +} +``` + +- [ ] **Step 4: Add export to models.dart** + +Add `export 'text_mark.dart';` to `horatio_core/lib/src/models/models.dart`. + +- [ ] **Step 5: Run tests** + +```bash +cd horatio_core && dart test test/models/model_test.dart -v +``` + +Expected: all TextMark tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add horatio_core/lib/src/models/text_mark.dart \ + horatio_core/lib/src/models/models.dart \ + horatio_core/test/models/model_test.dart +git commit -m "feat(core): add TextMark model with serialization" +``` + +--- + +### Task 5: Add LineNote model + +**Files:** + +- Create: `horatio_core/lib/src/models/line_note.dart` +- Modify: `horatio_core/lib/src/models/models.dart` +- Modify: `horatio_core/test/models/model_test.dart` + +- [ ] **Step 1: Write failing tests** + +Add to `horatio_core/test/models/model_test.dart`: + +```dart + group('LineNote', () { + test('construction fields accessible', () { + final note = LineNote( + id: 'note-1', + lineIndex: 5, + category: NoteCategory.intention, + text: 'Character hiding anger', + createdAt: DateTime.utc(2026, 3, 29), + ); + expect(note.id, 'note-1'); + expect(note.lineIndex, 5); + expect(note.category, NoteCategory.intention); + expect(note.text, 'Character hiding anger'); + expect(note.createdAt, DateTime.utc(2026, 3, 29)); + }); + + test('equality uses id only', () { + final a = LineNote( + id: 'note-1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'text a', + createdAt: DateTime.utc(2026), + ); + final b = LineNote( + id: 'note-1', + lineIndex: 99, + category: NoteCategory.blocking, + text: 'text b', + createdAt: DateTime.utc(2020), + ); + final c = LineNote( + id: 'note-2', + lineIndex: 0, + category: NoteCategory.intention, + text: 'text a', + createdAt: DateTime.utc(2026), + ); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + expect(a == a, isTrue); // identical + }); + + test('hashCode consistent with equality', () { + final a = LineNote( + id: 'note-1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'x', + createdAt: DateTime.utc(2026), + ); + final b = LineNote( + id: 'note-1', + lineIndex: 99, + category: NoteCategory.subtext, + text: 'y', + createdAt: DateTime.utc(2020), + ); + expect(a.hashCode, b.hashCode); + }); + + test('toJson roundtrip', () { + final original = LineNote( + id: 'note-1', + lineIndex: 3, + category: NoteCategory.subtext, + text: 'Hidden meaning here', + createdAt: DateTime.utc(2026, 3, 29, 14), + ); + final json = original.toJson(); + final restored = LineNote.fromJson(json); + expect(restored.id, original.id); + expect(restored.lineIndex, original.lineIndex); + expect(restored.category, original.category); + expect(restored.text, original.text); + expect(restored.createdAt, original.createdAt); + }); + + test('fromJson with invalid category throws ArgumentError', () { + final json = { + 'id': 'x', + 'lineIndex': 0, + 'category': 'nonexistent', + 'text': 'note', + 'createdAt': '2026-03-29T00:00:00.000Z', + }; + expect(() => LineNote.fromJson(json), throwsArgumentError); + }); + + test('toJson serializes all NoteCategory values', () { + for (final cat in NoteCategory.values) { + final note = LineNote( + id: 'id-${cat.name}', + lineIndex: 0, + category: cat, + text: 'test', + createdAt: DateTime.utc(2026), + ); + final json = note.toJson(); + expect(json['category'], cat.name); + final restored = LineNote.fromJson(json); + expect(restored.category, cat); + } + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd horatio_core && dart test test/models/model_test.dart -v +``` + +- [ ] **Step 3: Create LineNote model** + +Create `horatio_core/lib/src/models/line_note.dart`: + +```dart +import 'package:meta/meta.dart'; + +import 'package:horatio_core/src/models/note_category.dart'; + +/// A free-text interpretive note attached to a whole script line. +@immutable +final class LineNote { + /// Creates a [LineNote]. + const LineNote({ + required this.id, + required this.lineIndex, + required this.category, + required this.text, + required this.createdAt, + }); + + /// Unique identifier (UUID). + final String id; + + /// Index of the [ScriptLine] this note is attached to. + final int lineIndex; + + /// The category of this note. + final NoteCategory category; + + /// Free-text note content. + final String text; + + /// When this note was created. + final DateTime createdAt; + + @override + bool operator ==(Object other) => + identical(this, other) || other is LineNote && id == other.id; + + @override + int get hashCode => id.hashCode; + + /// Serializes to a JSON-compatible map. + Map toJson() => { + 'id': id, + 'lineIndex': lineIndex, + 'category': category.name, + 'text': text, + 'createdAt': createdAt.toUtc().toIso8601String(), + }; + + /// Deserializes from a JSON map. + /// + /// Throws [ArgumentError] if [category] is not a valid [NoteCategory] name. + factory LineNote.fromJson(Map json) => LineNote( + id: json['id'] as String, + lineIndex: json['lineIndex'] as int, + category: NoteCategory.values.byName(json['category'] as String), + text: json['text'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + ); +} +``` + +- [ ] **Step 4: Add export and run tests** + +Add `export 'line_note.dart';` to `models.dart`, then: + +```bash +cd horatio_core && dart test test/models/model_test.dart -v +``` + +Expected: all LineNote tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add horatio_core/lib/src/models/line_note.dart \ + horatio_core/lib/src/models/models.dart \ + horatio_core/test/models/model_test.dart +git commit -m "feat(core): add LineNote model with serialization" +``` + +--- + +### Task 6: Add AnnotationSnapshot model + +**Files:** + +- Create: `horatio_core/lib/src/models/annotation_snapshot.dart` +- Modify: `horatio_core/lib/src/models/models.dart` +- Modify: `horatio_core/test/models/model_test.dart` + +- [ ] **Step 1: Write failing tests** + +Add to `horatio_core/test/models/model_test.dart`: + +```dart + group('AnnotationSnapshot', () { + test('construction with unmodifiable lists', () { + final marks = [ + TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ), + ]; + final notes = [ + LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'test', + createdAt: DateTime.utc(2026), + ), + ]; + final snapshot = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'script-uuid', + timestamp: DateTime.utc(2026, 3, 29), + marks: marks, + notes: notes, + ); + expect(snapshot.marks.length, 1); + expect(snapshot.notes.length, 1); + // Lists should be unmodifiable. + expect(() => snapshot.marks.add(marks.first), throwsUnsupportedError); + expect(() => snapshot.notes.add(notes.first), throwsUnsupportedError); + }); + + test('equality uses id only', () { + final a = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'script-a', + timestamp: DateTime.utc(2026), + marks: [], + notes: [], + ); + final b = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'script-b', + timestamp: DateTime.utc(2020), + marks: [], + notes: [], + ); + final c = AnnotationSnapshot( + id: 'snap-2', + scriptId: 'script-a', + timestamp: DateTime.utc(2026), + marks: [], + notes: [], + ); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + expect(a == a, isTrue); + }); + + test('hashCode consistent with equality', () { + final a = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'script-a', + timestamp: DateTime.utc(2026), + marks: [], + notes: [], + ); + final b = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'script-b', + timestamp: DateTime.utc(2020), + marks: [], + notes: [], + ); + expect(a.hashCode, b.hashCode); + }); + + test('toJson roundtrip with empty lists', () { + final original = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'script-uuid', + timestamp: DateTime.utc(2026, 3, 29), + marks: [], + notes: [], + ); + final json = original.toJson(); + final restored = AnnotationSnapshot.fromJson(json); + expect(restored.id, original.id); + expect(restored.scriptId, original.scriptId); + expect(restored.timestamp, original.timestamp); + expect(restored.marks, isEmpty); + expect(restored.notes, isEmpty); + }); + + test('toJson roundtrip with populated lists', () { + final mark = TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.emphasis, + createdAt: DateTime.utc(2026, 3, 29, 10), + ); + final note = LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.emotion, + text: 'angry', + createdAt: DateTime.utc(2026, 3, 29, 11), + ); + final original = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'script-uuid', + timestamp: DateTime.utc(2026, 3, 29, 12), + marks: [mark], + notes: [note], + ); + final json = original.toJson(); + final restored = AnnotationSnapshot.fromJson(json); + expect(restored.marks.length, 1); + expect(restored.marks.first.id, 'm1'); + expect(restored.marks.first.type, MarkType.emphasis); + expect(restored.notes.length, 1); + expect(restored.notes.first.id, 'n1'); + expect(restored.notes.first.category, NoteCategory.emotion); + }); + + test('fromJson with malformed DateTime throws FormatException', () { + final json = { + 'id': 'x', + 'scriptId': 'y', + 'timestamp': 'not-a-date', + 'marks': [], + 'notes': [], + }; + expect(() => AnnotationSnapshot.fromJson(json), throwsFormatException); + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd horatio_core && dart test test/models/model_test.dart -v +``` + +- [ ] **Step 3: Create AnnotationSnapshot model** + +Create `horatio_core/lib/src/models/annotation_snapshot.dart`: + +```dart +import 'package:meta/meta.dart'; + +import 'package:horatio_core/src/models/line_note.dart'; +import 'package:horatio_core/src/models/text_mark.dart'; + +/// A point-in-time record of all annotations for a script. +/// +/// Enables change history, undo, and viewing annotation evolution over time. +@immutable +final class AnnotationSnapshot { + /// Creates an [AnnotationSnapshot] with unmodifiable lists. + AnnotationSnapshot({ + required this.id, + required this.scriptId, + required this.timestamp, + required List marks, + required List notes, + }) : marks = List.unmodifiable(marks), + notes = List.unmodifiable(notes); + + /// Unique identifier (UUID). + final String id; + + /// The script these annotations belong to. + final String scriptId; + + /// When this snapshot was taken. + final DateTime timestamp; + + /// All text marks at snapshot time. + final List marks; + + /// All line notes at snapshot time. + final List notes; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AnnotationSnapshot && id == other.id; + + @override + int get hashCode => id.hashCode; + + /// Serializes to a JSON-compatible map. + Map toJson() => { + 'id': id, + 'scriptId': scriptId, + 'timestamp': timestamp.toUtc().toIso8601String(), + 'marks': marks.map((m) => m.toJson()).toList(), + 'notes': notes.map((n) => n.toJson()).toList(), + }; + + /// Deserializes from a JSON map. + factory AnnotationSnapshot.fromJson(Map json) => + AnnotationSnapshot( + id: json['id'] as String, + scriptId: json['scriptId'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + marks: (json['marks'] as List) + .map((e) => TextMark.fromJson(e as Map)) + .toList(), + notes: (json['notes'] as List) + .map((e) => LineNote.fromJson(e as Map)) + .toList(), + ); +} +``` + +- [ ] **Step 4: Add export and run tests** + +Add `export 'annotation_snapshot.dart';` to `models.dart`, then: + +```bash +cd horatio_core && dart test test/models/model_test.dart -v +``` + +Expected: all AnnotationSnapshot tests pass. + +- [ ] **Step 5: Run full core test suite with coverage** + +```bash +cd horatio_core && dart run coverage:test_with_coverage +``` + +Check coverage is still 100%. + +- [ ] **Step 6: Commit** + +```bash +git add horatio_core/lib/src/models/annotation_snapshot.dart \ + horatio_core/lib/src/models/models.dart \ + horatio_core/test/models/model_test.dart +git commit -m "feat(core): add AnnotationSnapshot model with serialization" +``` + +--- + +## Chunk 2: Drift Database + DAO + uuid app dep + +### Task 7: Add drift_dev, build_runner, and uuid to app dependencies + +**Files:** + +- Modify: `horatio_app/pubspec.yaml` + +- [ ] **Step 1: Add dev dependencies and uuid** + +Add `build_runner`, `drift_dev` (dev deps) and `uuid` (regular dep) to +`horatio_app/pubspec.yaml`. `uuid` is needed by the annotation cubits in +Chunk 3: + +```yaml +dependencies: + # ... existing deps ... + uuid: ^4.5.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + bloc_test: ^10.0.0 + mocktail: ^1.0.0 + plugin_platform_interface: any + build_runner: ^2.4.0 + drift_dev: ^2.22.0 +``` + +- [ ] **Step 2: Run flutter pub get** + +```bash +cd horatio_app && flutter pub get +``` + +Expected: resolves successfully. + +- [ ] **Step 3: Commit** + +```bash +git add horatio_app/pubspec.yaml horatio_app/pubspec.lock +git commit -m "chore(app): add build_runner, drift_dev, uuid dependencies" +``` + +--- + +### Task 8: Create drift database and annotation tables + +**Files:** + +- Create: `horatio_app/lib/database/tables/text_marks_table.dart` +- Create: `horatio_app/lib/database/tables/line_notes_table.dart` +- Create: `horatio_app/lib/database/tables/annotation_snapshots_table.dart` +- Create: `horatio_app/lib/database/app_database.dart` + +> **`.g.dart` strategy:** Generated files (`app_database.g.dart`) should be +> **committed** to the repository. This avoids requiring CI to install +> build_runner and ensures every commit compiles. The run.sh codegen step +> (Task 14) refreshes them, and the cache hash excludes `.g.dart` files. +> If strict lints flag generated code, add `analyzer: exclude: ['**/*.g.dart']` +> to `horatio_app/analysis_options.yaml`. + +- [ ] **Step 1: Create text_marks table** + +Create `horatio_app/lib/database/tables/text_marks_table.dart`: + +```dart +import 'package:drift/drift.dart'; + +/// Drift table for text-level delivery marks on script lines. +class TextMarksTable extends Table { + @override + String get tableName => 'text_marks'; + + TextColumn get id => text()(); + TextColumn get scriptId => text()(); + IntColumn get lineIndex => integer()(); + IntColumn get startOffset => integer()(); + IntColumn get endOffset => integer()(); + TextColumn get markType => text()(); + DateTimeColumn get createdAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} +``` + +- [ ] **Step 2: Create line_notes table** + +Create `horatio_app/lib/database/tables/line_notes_table.dart`: + +```dart +import 'package:drift/drift.dart'; + +/// Drift table for line-level interpretive notes. +class LineNotesTable extends Table { + @override + String get tableName => 'line_notes'; + + TextColumn get id => text()(); + TextColumn get scriptId => text()(); + IntColumn get lineIndex => integer()(); + TextColumn get category => text()(); + TextColumn get noteText => text()(); + DateTimeColumn get createdAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} +``` + +- [ ] **Step 3: Create annotation_snapshots table** + +Create `horatio_app/lib/database/tables/annotation_snapshots_table.dart`: + +```dart +import 'package:drift/drift.dart'; + +/// Drift table for annotation history snapshots. +class AnnotationSnapshotsTable extends Table { + @override + String get tableName => 'annotation_snapshots'; + + TextColumn get id => text()(); + TextColumn get scriptId => text()(); + DateTimeColumn get timestamp => dateTime()(); + TextColumn get snapshotJson => text()(); + + @override + Set get primaryKey => {id}; +} +``` + +- [ ] **Step 4: Create app database** + +Create `horatio_app/lib/database/app_database.dart`: + +```dart +import 'package:drift/drift.dart'; +import 'package:horatio_app/database/tables/annotation_snapshots_table.dart'; +import 'package:horatio_app/database/tables/line_notes_table.dart'; +import 'package:horatio_app/database/tables/text_marks_table.dart'; + +part 'app_database.g.dart'; + +/// Central drift database for Horatio. +/// +/// Schema version 1: annotation tables (text_marks, line_notes, +/// annotation_snapshots). +@DriftDatabase( + tables: [TextMarksTable, LineNotesTable, AnnotationSnapshotsTable], +) +class AppDatabase extends _$AppDatabase { + /// Creates an [AppDatabase] with the given [QueryExecutor]. + AppDatabase(super.e); + + @override + int get schemaVersion => 1; +} +``` + +> **Note:** The `daos: [AnnotationDao]` parameter is NOT added here because +> AnnotationDao doesn't exist yet (created in Task 9). Task 9 Step 1 will +> add the import and `daos:` list to AppDatabase. + +- [ ] **Step 5: Commit (before codegen — tables only)** + +```bash +git add horatio_app/lib/database/ +git commit -m "feat(app): add drift table definitions for annotations" +``` + +--- + +### Task 9: Create AnnotationDao + +**Files:** + +- Create: `horatio_app/lib/database/daos/annotation_dao.dart` + +- [ ] **Step 1: Register DAO in AppDatabase** + +Update `horatio_app/lib/database/app_database.dart` to add the import and DAO +registration: + +```dart +import 'package:horatio_app/database/daos/annotation_dao.dart'; +``` + +And update the `@DriftDatabase` annotation: + +```dart +@DriftDatabase( + tables: [TextMarksTable, LineNotesTable, AnnotationSnapshotsTable], + daos: [AnnotationDao], +) +``` + +- [ ] **Step 2: Create the DAO** + +Create `horatio_app/lib/database/daos/annotation_dao.dart`: + +```dart +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:horatio_app/database/tables/annotation_snapshots_table.dart'; +import 'package:horatio_app/database/tables/line_notes_table.dart'; +import 'package:horatio_app/database/tables/text_marks_table.dart'; +import 'package:horatio_core/horatio_core.dart'; + +part 'annotation_dao.g.dart'; + +/// Data access object for annotation persistence. +/// +/// [TextMark] and [LineNote] models do not carry a [scriptId] field — +/// the DAO binds it at the persistence boundary. +@DriftAccessor( + tables: [TextMarksTable, LineNotesTable, AnnotationSnapshotsTable], +) +class AnnotationDao extends DatabaseAccessor + with _$AnnotationDaoMixin { + /// Creates an [AnnotationDao]. + AnnotationDao(super.db); + + // -- TextMark CRUD -------------------------------------------------------- + + /// Watches all marks for a script. + Stream> watchMarksForScript(String scriptId) => + (select(textMarksTable) + ..where((t) => t.scriptId.equals(scriptId)) + ..orderBy([(t) => OrderingTerm.asc(t.lineIndex)])) + .watch() + .map((rows) => rows.map(_rowToMark).toList()); + + /// Gets marks for a specific line. + Future> getMarksForLine( + String scriptId, + int lineIndex, + ) async { + final rows = await (select(textMarksTable) + ..where( + (t) => + t.scriptId.equals(scriptId) & + t.lineIndex.equals(lineIndex), + )) + .get(); + return rows.map(_rowToMark).toList(); + } + + /// Inserts a text mark. + Future insertMark(String scriptId, TextMark mark) => into( + textMarksTable, + ).insert( + TextMarksTableCompanion.insert( + id: mark.id, + scriptId: scriptId, + lineIndex: mark.lineIndex, + startOffset: mark.startOffset, + endOffset: mark.endOffset, + markType: mark.type.name, + createdAt: mark.createdAt, + ), + ); + + /// Deletes a text mark by ID. + Future deleteMark(String id) => + (delete(textMarksTable)..where((t) => t.id.equals(id))).go(); + + TextMark _rowToMark(TextMarksTableData row) => TextMark( + id: row.id, + lineIndex: row.lineIndex, + startOffset: row.startOffset, + endOffset: row.endOffset, + type: MarkType.values.byName(row.markType), + createdAt: row.createdAt, + ); + + // -- LineNote CRUD -------------------------------------------------------- + + /// Watches all notes for a script. + Stream> watchNotesForScript(String scriptId) => + (select(lineNotesTable) + ..where((t) => t.scriptId.equals(scriptId)) + ..orderBy([(t) => OrderingTerm.asc(t.lineIndex)])) + .watch() + .map((rows) => rows.map(_rowToNote).toList()); + + /// Gets notes for a specific line. + Future> getNotesForLine( + String scriptId, + int lineIndex, + ) async { + final rows = await (select(lineNotesTable) + ..where( + (t) => + t.scriptId.equals(scriptId) & + t.lineIndex.equals(lineIndex), + )) + .get(); + return rows.map(_rowToNote).toList(); + } + + /// Inserts a line note. + Future insertNote(String scriptId, LineNote note) => into( + lineNotesTable, + ).insert( + LineNotesTableCompanion.insert( + id: note.id, + scriptId: scriptId, + lineIndex: note.lineIndex, + category: note.category.name, + noteText: note.text, + createdAt: note.createdAt, + ), + ); + + /// Updates the text of a note. + Future updateNoteText(String id, String text) => + (update(lineNotesTable)..where((t) => t.id.equals(id))) + .write(LineNotesTableCompanion(noteText: Value(text))); + + /// Deletes a note by ID. + Future deleteNote(String id) => + (delete(lineNotesTable)..where((t) => t.id.equals(id))).go(); + + LineNote _rowToNote(LineNotesTableData row) => LineNote( + id: row.id, + lineIndex: row.lineIndex, + category: NoteCategory.values.byName(row.category), + text: row.noteText, + createdAt: row.createdAt, + ); + + // -- Snapshot management -------------------------------------------------- + + /// Watches all snapshots for a script, newest first. + Stream> watchSnapshotsForScript( + String scriptId, + ) => + (select(annotationSnapshotsTable) + ..where((t) => t.scriptId.equals(scriptId)) + ..orderBy([(t) => OrderingTerm.desc(t.timestamp)])) + .watch() + .map((rows) => rows.map(_rowToSnapshot).toList()); + + /// Inserts a snapshot. + Future insertSnapshot(AnnotationSnapshot snapshot) => into( + annotationSnapshotsTable, + ).insert( + AnnotationSnapshotsTableCompanion.insert( + id: snapshot.id, + scriptId: snapshot.scriptId, + timestamp: snapshot.timestamp, + snapshotJson: json.encode(snapshot.toJson()), + ), + ); + + AnnotationSnapshot _rowToSnapshot(AnnotationSnapshotsTableData row) => + AnnotationSnapshot.fromJson( + json.decode(row.snapshotJson) as Map, + ); + // Note: scriptId and timestamp exist in both the table columns (for + // efficient WHERE/ORDER BY filtering) AND in the JSON blob (for complete + // deserialization). The columns are the source of truth for queries; + // the JSON is the source of truth for the full snapshot data. + + // -- Bulk operations (for snapshot restore) ------------------------------- + + /// Deletes ALL marks and notes for a script, then inserts the given ones. + /// Used by snapshot restore. + Future replaceAllAnnotations({ + required String scriptId, + required List marks, + required List notes, + }) => transaction(() async { + await (delete(textMarksTable) + ..where((t) => t.scriptId.equals(scriptId))) + .go(); + await (delete(lineNotesTable) + ..where((t) => t.scriptId.equals(scriptId))) + .go(); + for (final mark in marks) { + await insertMark(scriptId, mark); + } + for (final note in notes) { + await insertNote(scriptId, note); + } + }); +} +``` + +- [ ] **Step 3: Run drift codegen** + +```bash +cd horatio_app && dart run build_runner build --delete-conflicting-outputs +``` + +Expected: generates `app_database.g.dart` and `annotation_dao.g.dart`. + +- [ ] **Step 4: Run analysis** + +```bash +cd horatio_app && flutter analyze --fatal-infos +``` + +Expected: no issues. + +- [ ] **Step 5: Commit** + +```bash +git add horatio_app/lib/database/ +git commit -m "feat(app): add AnnotationDao with drift codegen" +``` + +--- + +### Task 10: Write DAO tests + +**Files:** + +- Create: `horatio_app/test/database/annotation_dao_test.dart` + +- [ ] **Step 1: Write comprehensive DAO tests** + +Create `horatio_app/test/database/annotation_dao_test.dart`: + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + late AppDatabase db; + late AnnotationDao dao; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + dao = db.annotationDao; + }); + + tearDown(() => db.close()); + + const scriptId = 'script-uuid-1'; + + TextMark makeMark({ + String id = 'm1', + int lineIndex = 0, + int startOffset = 0, + int endOffset = 5, + MarkType type = MarkType.stress, + }) => + TextMark( + id: id, + lineIndex: lineIndex, + startOffset: startOffset, + endOffset: endOffset, + type: type, + createdAt: DateTime.utc(2026, 3, 29), + ); + + LineNote makeNote({ + String id = 'n1', + int lineIndex = 0, + NoteCategory category = NoteCategory.intention, + String text = 'test note', + }) => + LineNote( + id: id, + lineIndex: lineIndex, + category: category, + text: text, + createdAt: DateTime.utc(2026, 3, 29), + ); + + group('TextMark CRUD', () { + test('insertMark and getMarksForLine', () async { + await dao.insertMark(scriptId, makeMark()); + final marks = await dao.getMarksForLine(scriptId, 0); + expect(marks.length, 1); + expect(marks.first.id, 'm1'); + expect(marks.first.type, MarkType.stress); + }); + + test('deleteMark removes mark', () async { + await dao.insertMark(scriptId, makeMark()); + await dao.deleteMark('m1'); + final marks = await dao.getMarksForLine(scriptId, 0); + expect(marks, isEmpty); + }); + + test('watchMarksForScript emits on insert', () async { + final stream = dao.watchMarksForScript(scriptId); + // First emission: empty + expectLater( + stream, + emitsInOrder([ + isEmpty, + hasLength(1), + ]), + ); + await dao.insertMark(scriptId, makeMark()); + }); + + test('getMarksForLine filters by scriptId', () async { + await dao.insertMark(scriptId, makeMark()); + await dao.insertMark('other-script', makeMark(id: 'm2')); + final marks = await dao.getMarksForLine(scriptId, 0); + expect(marks.length, 1); + expect(marks.first.id, 'm1'); + }); + }); + + group('LineNote CRUD', () { + test('insertNote and getNotesForLine', () async { + await dao.insertNote(scriptId, makeNote()); + final notes = await dao.getNotesForLine(scriptId, 0); + expect(notes.length, 1); + expect(notes.first.text, 'test note'); + }); + + test('updateNoteText modifies text', () async { + await dao.insertNote(scriptId, makeNote()); + await dao.updateNoteText('n1', 'updated text'); + final notes = await dao.getNotesForLine(scriptId, 0); + expect(notes.first.text, 'updated text'); + }); + + test('deleteNote removes note', () async { + await dao.insertNote(scriptId, makeNote()); + await dao.deleteNote('n1'); + final notes = await dao.getNotesForLine(scriptId, 0); + expect(notes, isEmpty); + }); + + test('watchNotesForScript emits on insert', () async { + final stream = dao.watchNotesForScript(scriptId); + expectLater( + stream, + emitsInOrder([isEmpty, hasLength(1)]), + ); + await dao.insertNote(scriptId, makeNote()); + }); + }); + + group('Snapshot management', () { + test('insertSnapshot and watch', () async { + final snapshot = AnnotationSnapshot( + id: 'snap-1', + scriptId: scriptId, + timestamp: DateTime.utc(2026, 3, 29), + marks: [makeMark()], + notes: [makeNote()], + ); + final stream = dao.watchSnapshotsForScript(scriptId); + expectLater( + stream, + emitsInOrder([isEmpty, hasLength(1)]), + ); + await dao.insertSnapshot(snapshot); + }); + }); + + group('replaceAllAnnotations', () { + test('deletes existing and inserts new', () async { + await dao.insertMark(scriptId, makeMark(id: 'old-m')); + await dao.insertNote(scriptId, makeNote(id: 'old-n')); + + await dao.replaceAllAnnotations( + scriptId: scriptId, + marks: [makeMark(id: 'new-m')], + notes: [makeNote(id: 'new-n', text: 'new note')], + ); + + final marks = await dao.getMarksForLine(scriptId, 0); + expect(marks.length, 1); + expect(marks.first.id, 'new-m'); + + final notes = await dao.getNotesForLine(scriptId, 0); + expect(notes.length, 1); + expect(notes.first.id, 'new-n'); + }); + + test('does not affect other scripts', () async { + await dao.insertMark('other-script', makeMark(id: 'keep-m')); + await dao.replaceAllAnnotations( + scriptId: scriptId, + marks: [], + notes: [], + ); + final marks = await dao.getMarksForLine('other-script', 0); + expect(marks.length, 1); + expect(marks.first.id, 'keep-m'); + }); + }); +} +``` + +- [ ] **Step 2: Run DAO tests** + +```bash +cd horatio_app && flutter test test/database/annotation_dao_test.dart -v +``` + +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add horatio_app/test/database/ +git commit -m "test(app): add comprehensive AnnotationDao tests" +``` + +--- + +## Chunk 3: Cubits + +### Task 11: Create AnnotationCubit + +**Files:** + +- Create: `horatio_app/lib/bloc/annotation/annotation_state.dart` +- Create: `horatio_app/lib/bloc/annotation/annotation_cubit.dart` +- Create: `horatio_app/test/bloc/annotation_cubit_test.dart` + +- [ ] **Step 1: Write test file** + +Create `horatio_app/test/bloc/annotation_cubit_test.dart`: + +```dart +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/bloc/annotation/annotation_cubit.dart'; +import 'package:horatio_app/bloc/annotation/annotation_state.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAnnotationDao extends Mock implements AnnotationDao {} + +void main() { + late MockAnnotationDao dao; + late StreamController> marksController; + late StreamController> notesController; + + const scriptId = 'script-1'; + + final testMark = TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ); + + final testNote = LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'test', + createdAt: DateTime.utc(2026), + ); + + setUp(() { + dao = MockAnnotationDao(); + marksController = StreamController>.broadcast(); + notesController = StreamController>.broadcast(); + + when(() => dao.watchMarksForScript(scriptId)) + .thenAnswer((_) => marksController.stream); + when(() => dao.watchNotesForScript(scriptId)) + .thenAnswer((_) => notesController.stream); + }); + + tearDown(() { + marksController.close(); + notesController.close(); + }); + + setUpAll(() { + registerFallbackValue(testMark); + registerFallbackValue(testNote); + }); + + group('AnnotationCubit', () { + test('initial state is AnnotationInitial', () { + final cubit = AnnotationCubit(dao: dao); + expect(cubit.state, isA()); + cubit.close(); + }); + + test('loadAnnotations subscribes and emits on marks stream', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([testMark]); + await Future.delayed(Duration.zero); + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationLoaded).marks, [testMark]); + await cubit.close(); + }); + + test('loadAnnotations subscribes and emits on notes stream', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + notesController.add([testNote]); + await Future.delayed(Duration.zero); + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationLoaded).notes, [testNote]); + await cubit.close(); + }); + + test('loadAnnotations double-emits on both streams', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([testMark]); + notesController.add([testNote]); + await Future.delayed(Duration.zero); + final state = cubit.state as AnnotationLoaded; + expect(state.marks, [testMark]); + expect(state.notes, [testNote]); + await cubit.close(); + }); + + test('selectLine updates selectedLineIndex', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([]); + await Future.delayed(Duration.zero); + cubit.selectLine(3); + expect((cubit.state as AnnotationLoaded).selectedLineIndex, 3); + await cubit.close(); + }); + + test('selectLine is no-op when state is AnnotationInitial', () { + final cubit = AnnotationCubit(dao: dao); + cubit.selectLine(3); // Should not throw + expect(cubit.state, isA()); + cubit.close(); + }); + + test('startEditing / cancelEditing toggle EditingContext', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([]); + await Future.delayed(Duration.zero); + + cubit.startEditing(lineIndex: 2, isAddingMark: true); + final editing = (cubit.state as AnnotationLoaded).editing; + expect(editing, isNotNull); + expect(editing!.lineIndex, 2); + expect(editing.isAddingMark, isTrue); + + cubit.cancelEditing(); + expect((cubit.state as AnnotationLoaded).editing, isNull); + await cubit.close(); + }); + + test('startEditing is no-op when state is AnnotationInitial', () { + final cubit = AnnotationCubit(dao: dao); + cubit.startEditing(lineIndex: 0, isAddingMark: true); + expect(cubit.state, isA()); + cubit.close(); + }); + + test('cancelEditing is no-op when state is AnnotationInitial', () { + final cubit = AnnotationCubit(dao: dao); + cubit.cancelEditing(); + expect(cubit.state, isA()); + cubit.close(); + }); + + test('selectedLineIndex preserved across stream updates', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([]); + await Future.delayed(Duration.zero); + cubit.selectLine(5); + marksController.add([testMark]); // stream update + await Future.delayed(Duration.zero); + expect((cubit.state as AnnotationLoaded).selectedLineIndex, 5); + await cubit.close(); + }); + + test('addMark calls dao.insertMark', () async { + when(() => dao.insertMark(any(), any())).thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([]); + await Future.delayed(Duration.zero); + + await cubit.addMark( + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + ); + verify(() => dao.insertMark(scriptId, any())).called(1); + await cubit.close(); + }); + + test('addMark is no-op when scriptId is null', () async { + final cubit = AnnotationCubit(dao: dao); + await cubit.addMark( + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + ); + verifyNever(() => dao.insertMark(any(), any())); + await cubit.close(); + }); + + test('removeMark calls dao.deleteMark', () async { + when(() => dao.deleteMark('m1')).thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.removeMark('m1'); + verify(() => dao.deleteMark('m1')).called(1); + await cubit.close(); + }); + + test('addNote calls dao.insertNote', () async { + when(() => dao.insertNote(any(), any())).thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([]); + await Future.delayed(Duration.zero); + + await cubit.addNote( + lineIndex: 0, + category: NoteCategory.intention, + text: 'test note', + ); + verify(() => dao.insertNote(scriptId, any())).called(1); + await cubit.close(); + }); + + test('addNote is no-op when scriptId is null', () async { + final cubit = AnnotationCubit(dao: dao); + await cubit.addNote( + lineIndex: 0, + category: NoteCategory.intention, + text: 'test', + ); + verifyNever(() => dao.insertNote(any(), any())); + await cubit.close(); + }); + + test('updateNote calls dao.updateNoteText', () async { + when(() => dao.updateNoteText('n1', 'new')) + .thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.updateNote('n1', 'new'); + verify(() => dao.updateNoteText('n1', 'new')).called(1); + await cubit.close(); + }); + + test('removeNote calls dao.deleteNote', () async { + when(() => dao.deleteNote('n1')).thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.removeNote('n1'); + verify(() => dao.deleteNote('n1')).called(1); + await cubit.close(); + }); + + test('loadAnnotations with new scriptId cancels previous streams', + () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([testMark]); + await Future.delayed(Duration.zero); + + final marks2 = StreamController>.broadcast(); + final notes2 = StreamController>.broadcast(); + when(() => dao.watchMarksForScript('script-2')) + .thenAnswer((_) => marks2.stream); + when(() => dao.watchNotesForScript('script-2')) + .thenAnswer((_) => notes2.stream); + + cubit.loadAnnotations('script-2'); + marks2.add([]); + notes2.add([]); + await Future.delayed(Duration.zero); + + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationLoaded).scriptId, 'script-2'); + expect(state.marks, isEmpty); + + await cubit.close(); + await marks2.close(); + await notes2.close(); + }); + + test('close cancels stream subscriptions', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + await cubit.close(); + // Adding to controller after close should not cause errors. + marksController.add([]); + notesController.add([]); + }); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd horatio_app && flutter test test/bloc/annotation_cubit_test.dart -v +``` + +- [ ] **Step 3: Create annotation_state.dart** + +Create `horatio_app/lib/bloc/annotation/annotation_state.dart`: + +```dart +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// State for [AnnotationCubit]. +sealed class AnnotationState extends Equatable { + const AnnotationState(); +} + +/// No annotations loaded. +final class AnnotationInitial extends AnnotationState { + const AnnotationInitial(); + + @override + List get props => []; +} + +/// Annotations loaded for a script. +final class AnnotationLoaded extends AnnotationState { + const AnnotationLoaded({ + required this.scriptId, + required this.marks, + required this.notes, + this.selectedLineIndex, + this.editing, + }); + + /// The script these annotations belong to. + final String scriptId; + + /// All text marks for this script. + final List marks; + + /// All line notes for this script. + final List notes; + + /// Currently selected line index (nullable). + final int? selectedLineIndex; + + /// Non-null when actively editing. + final EditingContext? editing; + + /// Creates a copy with specified fields replaced. + AnnotationLoaded copyWith({ + List? marks, + List? notes, + int? Function()? selectedLineIndex, + EditingContext? Function()? editing, + }) => AnnotationLoaded( + scriptId: scriptId, + marks: marks ?? this.marks, + notes: notes ?? this.notes, + selectedLineIndex: selectedLineIndex != null + ? selectedLineIndex() + : this.selectedLineIndex, + editing: editing != null ? editing() : this.editing, + ); + + @override + List get props => + [scriptId, marks, notes, selectedLineIndex, editing]; +} + +/// Context for an active annotation edit. +@immutable +final class EditingContext extends Equatable { + const EditingContext({ + required this.lineIndex, + required this.isAddingMark, + }); + + /// The line being edited. + final int lineIndex; + + /// Whether placing a mark (true) or writing a note (false). + final bool isAddingMark; + + @override + List get props => [lineIndex, isAddingMark]; +} +``` + +- [ ] **Step 4: Create annotation_cubit.dart** + +Create `horatio_app/lib/bloc/annotation/annotation_cubit.dart`: + +```dart +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/annotation/annotation_state.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:uuid/uuid.dart'; + +/// Manages annotation CRUD for a script. +class AnnotationCubit extends Cubit { + /// Creates an [AnnotationCubit]. + AnnotationCubit({required AnnotationDao dao}) + : _dao = dao, + super(const AnnotationInitial()); + + final AnnotationDao _dao; + StreamSubscription>? _marksSub; + StreamSubscription>? _notesSub; + String? _scriptId; + + static const _uuid = Uuid(); + + /// Subscribes to annotation streams for a script. + void loadAnnotations(String scriptId) { + _scriptId = scriptId; + _marksSub?.cancel(); + _notesSub?.cancel(); + + List latestMarks = []; + List latestNotes = []; + + _marksSub = _dao.watchMarksForScript(scriptId).listen((marks) { + latestMarks = marks; + _emitLoaded(scriptId, latestMarks, latestNotes); + }); + + _notesSub = _dao.watchNotesForScript(scriptId).listen((notes) { + latestNotes = notes; + _emitLoaded(scriptId, latestMarks, latestNotes); + }); + } + + void _emitLoaded( + String scriptId, + List marks, + List notes, + ) { + final current = state; + emit(AnnotationLoaded( + scriptId: scriptId, + marks: marks, + notes: notes, + selectedLineIndex: + current is AnnotationLoaded ? current.selectedLineIndex : null, + editing: current is AnnotationLoaded ? current.editing : null, + )); + } + + /// Focuses a line for annotation. + void selectLine(int? lineIndex) { + final current = state; + if (current is AnnotationLoaded) { + emit(current.copyWith(selectedLineIndex: () => lineIndex)); + } + } + + /// Enters editing mode. + void startEditing({required int lineIndex, required bool isAddingMark}) { + final current = state; + if (current is AnnotationLoaded) { + emit(current.copyWith( + selectedLineIndex: () => lineIndex, + editing: () => EditingContext( + lineIndex: lineIndex, + isAddingMark: isAddingMark, + ), + )); + } + } + + /// Exits editing mode. + void cancelEditing() { + final current = state; + if (current is AnnotationLoaded) { + emit(current.copyWith(editing: () => null)); + } + } + + /// Adds a text mark. + Future addMark({ + required int lineIndex, + required int startOffset, + required int endOffset, + required MarkType type, + }) async { + final scriptId = _scriptId; + if (scriptId == null) return; + final mark = TextMark( + id: _uuid.v4(), + lineIndex: lineIndex, + startOffset: startOffset, + endOffset: endOffset, + type: type, + createdAt: DateTime.now().toUtc(), + ); + await _dao.insertMark(scriptId, mark); + } + + /// Removes a text mark. + Future removeMark(String id) => _dao.deleteMark(id); + + /// Adds a line note. + Future addNote({ + required int lineIndex, + required NoteCategory category, + required String text, + }) async { + final scriptId = _scriptId; + if (scriptId == null) return; + final note = LineNote( + id: _uuid.v4(), + lineIndex: lineIndex, + category: category, + text: text, + createdAt: DateTime.now().toUtc(), + ); + await _dao.insertNote(scriptId, note); + } + + /// Updates a note's text. + Future updateNote(String id, String text) => + _dao.updateNoteText(id, text); + + /// Removes a note. + Future removeNote(String id) => _dao.deleteNote(id); + + @override + Future close() { + _marksSub?.cancel(); + _notesSub?.cancel(); + return super.close(); + } +} +``` + +- [ ] **Step 5: Run tests** + +```bash +cd horatio_app && flutter test test/bloc/annotation_cubit_test.dart -v +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add horatio_app/lib/bloc/annotation/ horatio_app/test/bloc/annotation_cubit_test.dart +git commit -m "feat(app): add AnnotationCubit with CRUD operations" +``` + +--- + +### Task 12: Create AnnotationHistoryCubit + +**Files:** + +- Create: `horatio_app/lib/bloc/annotation/annotation_history_state.dart` +- Create: `horatio_app/lib/bloc/annotation/annotation_history_cubit.dart` +- Create: `horatio_app/test/bloc/annotation_history_cubit_test.dart` + +- [ ] **Step 1: Write tests** + +Create `horatio_app/test/bloc/annotation_history_cubit_test.dart`: + +```dart +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/bloc/annotation/annotation_history_cubit.dart'; +import 'package:horatio_app/bloc/annotation/annotation_history_state.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAnnotationDao extends Mock implements AnnotationDao {} + +void main() { + late MockAnnotationDao dao; + late StreamController> snapshotsController; + + const scriptId = 'script-1'; + + final testMark = TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ); + + final testNote = LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'test', + createdAt: DateTime.utc(2026), + ); + + final testSnapshot = AnnotationSnapshot( + id: 'snap-1', + scriptId: scriptId, + timestamp: DateTime.utc(2026, 3, 29), + marks: [testMark], + notes: [testNote], + ); + + setUp(() { + dao = MockAnnotationDao(); + snapshotsController = + StreamController>.broadcast(); + when(() => dao.watchSnapshotsForScript(scriptId)) + .thenAnswer((_) => snapshotsController.stream); + }); + + tearDown(() => snapshotsController.close()); + + setUpAll(() { + registerFallbackValue(testSnapshot); + }); + + group('AnnotationHistoryCubit', () { + test('initial state is AnnotationHistoryInitial', () { + final cubit = AnnotationHistoryCubit(dao: dao); + expect(cubit.state, isA()); + cubit.close(); + }); + + test('loadSnapshots subscribes and emits AnnotationHistoryLoaded', + () async { + final cubit = AnnotationHistoryCubit(dao: dao) + ..loadSnapshots(scriptId); + snapshotsController.add([testSnapshot]); + await Future.delayed(Duration.zero); + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationHistoryLoaded).snapshots, [testSnapshot]); + await cubit.close(); + }); + + test('saveSnapshot calls dao.insertSnapshot with correct data', () async { + when(() => dao.insertSnapshot(any())).thenAnswer((_) async {}); + final cubit = AnnotationHistoryCubit(dao: dao) + ..loadSnapshots(scriptId); + snapshotsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.saveSnapshot(marks: [testMark], notes: [testNote]); + final captured = + verify(() => dao.insertSnapshot(captureAny())).captured.single + as AnnotationSnapshot; + expect(captured.scriptId, scriptId); + expect(captured.marks, [testMark]); + expect(captured.notes, [testNote]); + await cubit.close(); + }); + + test('saveSnapshot is no-op when scriptId is null', () async { + final cubit = AnnotationHistoryCubit(dao: dao); + await cubit.saveSnapshot(marks: [], notes: []); + verifyNever(() => dao.insertSnapshot(any())); + await cubit.close(); + }); + + test('restoreSnapshot calls dao.replaceAllAnnotations', () async { + when( + () => dao.replaceAllAnnotations( + scriptId: any(named: 'scriptId'), + marks: any(named: 'marks'), + notes: any(named: 'notes'), + ), + ).thenAnswer((_) async {}); + final cubit = AnnotationHistoryCubit(dao: dao) + ..loadSnapshots(scriptId); + snapshotsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.restoreSnapshot(testSnapshot); + verify( + () => dao.replaceAllAnnotations( + scriptId: scriptId, + marks: testSnapshot.marks, + notes: testSnapshot.notes, + ), + ).called(1); + await cubit.close(); + }); + + test('restoreSnapshot is no-op when scriptId is null', () async { + final cubit = AnnotationHistoryCubit(dao: dao); + await cubit.restoreSnapshot(testSnapshot); + verifyNever( + () => dao.replaceAllAnnotations( + scriptId: any(named: 'scriptId'), + marks: any(named: 'marks'), + notes: any(named: 'notes'), + ), + ); + await cubit.close(); + }); + + test('loadSnapshots with new scriptId cancels previous stream', () async { + final cubit = AnnotationHistoryCubit(dao: dao) + ..loadSnapshots(scriptId); + snapshotsController.add([testSnapshot]); + await Future.delayed(Duration.zero); + + final snapshots2 = StreamController>.broadcast(); + when(() => dao.watchSnapshotsForScript('script-2')) + .thenAnswer((_) => snapshots2.stream); + + cubit.loadSnapshots('script-2'); + snapshots2.add([]); + await Future.delayed(Duration.zero); + + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationHistoryLoaded).snapshots, isEmpty); + + await cubit.close(); + await snapshots2.close(); + }); + + test('close cancels stream subscription', () async { + final cubit = AnnotationHistoryCubit(dao: dao) + ..loadSnapshots(scriptId); + await cubit.close(); + snapshotsController.add([testSnapshot]); + // Should not cause errors. + }); + }); +} +``` + +- [ ] **Step 2: Create state file** + +Create `horatio_app/lib/bloc/annotation/annotation_history_state.dart`: + +```dart +import 'package:equatable/equatable.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// State for [AnnotationHistoryCubit]. +sealed class AnnotationHistoryState extends Equatable { + const AnnotationHistoryState(); +} + +/// No snapshots loaded. +final class AnnotationHistoryInitial extends AnnotationHistoryState { + const AnnotationHistoryInitial(); + + @override + List get props => []; +} + +/// Snapshots loaded for a script. +final class AnnotationHistoryLoaded extends AnnotationHistoryState { + const AnnotationHistoryLoaded({ + required this.scriptId, + required this.snapshots, + }); + + final String scriptId; + final List snapshots; + + @override + List get props => [scriptId, snapshots]; +} +``` + +- [ ] **Step 3: Create cubit** + +Create `horatio_app/lib/bloc/annotation/annotation_history_cubit.dart`: + +```dart +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/annotation/annotation_history_state.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:uuid/uuid.dart'; + +/// Manages annotation snapshot history for a script. +class AnnotationHistoryCubit extends Cubit { + /// Creates an [AnnotationHistoryCubit]. + AnnotationHistoryCubit({required AnnotationDao dao}) + : _dao = dao, + super(const AnnotationHistoryInitial()); + + final AnnotationDao _dao; + StreamSubscription>? _sub; + String? _scriptId; + + static const _uuid = Uuid(); + + /// Subscribes to snapshots for a script. + void loadSnapshots(String scriptId) { + _scriptId = scriptId; + _sub?.cancel(); + _sub = _dao.watchSnapshotsForScript(scriptId).listen((snapshots) { + emit(AnnotationHistoryLoaded( + scriptId: scriptId, + snapshots: snapshots, + )); + }); + } + + /// Saves current annotations as a snapshot. + Future saveSnapshot({ + required List marks, + required List notes, + }) async { + final scriptId = _scriptId; + if (scriptId == null) return; + final snapshot = AnnotationSnapshot( + id: _uuid.v4(), + scriptId: scriptId, + timestamp: DateTime.now().toUtc(), + marks: marks, + notes: notes, + ); + await _dao.insertSnapshot(snapshot); + } + + /// Restores annotations from a snapshot (destructive replace). + Future restoreSnapshot(AnnotationSnapshot snapshot) async { + final scriptId = _scriptId; + if (scriptId == null) return; + await _dao.replaceAllAnnotations( + scriptId: scriptId, + marks: snapshot.marks, + notes: snapshot.notes, + ); + } + + @override + Future close() { + _sub?.cancel(); + return super.close(); + } +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd horatio_app && flutter test test/bloc/annotation_history_cubit_test.dart -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add horatio_app/lib/bloc/annotation/ \ + horatio_app/test/bloc/annotation_history_cubit_test.dart +git commit -m "feat(app): add AnnotationHistoryCubit for snapshot management" +``` + +--- + +## Chunk 4: Wiring + Build Pipeline + +### Task 13: Wire database and cubits into app + +**Files:** + +- Modify: `horatio_app/lib/main.dart` +- Modify: `horatio_app/lib/app.dart` +- Modify: `horatio_app/test/app_test.dart` +- Modify: `horatio_app/test/widget_test.dart` (if present) +- Modify: any screen tests that call `pumpWidget(HoratioApp(...))` + +- [ ] **Step 1: Initialize database in main.dart** + +Replace `horatio_app/lib/main.dart` with: + +```dart +import 'dart:io'; + +import 'package:device_preview/device_preview.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:horatio_app/app.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + final dbFolder = await getApplicationDocumentsDirectory(); + final dbFile = File(p.join(dbFolder.path, 'horatio.sqlite')); + final database = AppDatabase(NativeDatabase(dbFile)); + + runApp( + DevicePreview( + builder: (_) => HoratioApp(database: database), + ), + ); +} +``` + +- [ ] **Step 2: Update app.dart to accept database and provide AnnotationDao** + +Replace `horatio_app/lib/app.dart` with: + +```dart +import 'package:device_preview/device_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/script_import/script_import_cubit.dart'; +import 'package:horatio_app/bloc/srs_review/srs_review_cubit.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_app/router.dart'; +import 'package:horatio_app/services/script_repository.dart'; +import 'package:horatio_app/theme/app_theme.dart'; + +/// Root widget for the Horatio app. +class HoratioApp extends StatelessWidget { + /// Creates the [HoratioApp]. + const HoratioApp({required this.database, super.key}); + + /// The drift database instance. + final AppDatabase database; + + @override + Widget build(BuildContext context) => MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (_) => ScriptRepository(), + ), + RepositoryProvider( + create: (_) => database.annotationDao, + ), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ScriptImportCubit( + repository: context.read(), + ), + ), + BlocProvider( + create: (_) => SrsReviewCubit(), + ), + ], + child: MaterialApp.router( + title: 'Horatio', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + locale: DevicePreview.locale(context), + builder: DevicePreview.appBuilder, + routerConfig: appRouter, + ), + ), + ); +} +``` + +Note: `HoratioApp` is no longer `const`-constructible because `AppDatabase` +is not const. All call sites must be updated. + +- [ ] **Step 3: Create test helper for in-memory database** + +Create `horatio_app/test/helpers/test_database.dart`: + +```dart +import 'package:drift/native.dart'; +import 'package:horatio_app/database/app_database.dart'; + +/// Creates an in-memory [AppDatabase] for tests. +AppDatabase createTestDatabase() => AppDatabase(NativeDatabase.memory()); +``` + +- [ ] **Step 4: Update app_test.dart** + +Replace all `const HoratioApp()` with `HoratioApp(database: createTestDatabase())`: + +```dart +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/app.dart'; +import 'package:horatio_app/router.dart'; +import 'package:horatio_core/horatio_core.dart'; + +import 'helpers/test_database.dart'; + +void main() { + testWidgets('HoratioApp builds without crashing', (tester) async { + await tester.pumpWidget(HoratioApp(database: createTestDatabase())); + await tester.pumpAndSettle(); + expect(find.text('Horatio'), findsOneWidget); + }); + + testWidgets('SrsReviewCubit is created when srs-review route is visited', + (tester) async { + await tester.pumpWidget(HoratioApp(database: createTestDatabase())); + await tester.pumpAndSettle(); + + unawaited(appRouter.push(RoutePaths.srsReview, extra: [ + SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans'), + ])); + await tester.pumpAndSettle(); + expect(find.text('No review session active.'), findsOneWidget); + }); +} +``` + +- [ ] **Step 5: Search and update all other test files that create HoratioApp** + +```bash +grep -rn 'HoratioApp()' horatio_app/test/ +grep -rn 'const HoratioApp' horatio_app/test/ +``` + +Update every occurrence to use `HoratioApp(database: createTestDatabase())` +and add the test_database import. + +- [ ] **Step 6: Run all app tests** + +```bash +cd horatio_app && flutter test +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add horatio_app/lib/main.dart horatio_app/lib/app.dart \ + horatio_app/test/ +git commit -m "feat(app): wire drift database and AnnotationDao into app" +``` + +--- + +### Task 14: Add build_runner step to run.sh + +**Files:** + +- Modify: `horatio/run.sh` + +- [ ] **Step 1: Add app_codegen function** + +Add this function after `app_get()` in the "App tasks" section of `run.sh`: + +```bash +app_codegen() { + local h + h=$(files_hash "$APP_DIR/lib/database" -name '*.dart' ! -name '*.g.dart') + if step_cached app_codegen "$h"; then + echo " [cached] app_codegen — skipping" + return + fi + heading "Running drift codegen" + cd "$APP_DIR" + dart run build_runner build --delete-conflicting-outputs + cache_step app_codegen "$h" +} +``` + +- [ ] **Step 2: Insert app_codegen into ALL pipeline functions that need .g.dart files** + +Codegen must run before `app_analyze`, `app_test`, `app_build`, and `do_dead_code`. +Insert `app_codegen` right after `app_get` in each of these pipelines: + +```bash +do_analyze() { + check_deps + core_get + core_format + core_analyze + ensure_flutter + app_get + app_codegen # NEW — before dead_code (scans .dart files) + do_dead_code +} + +do_test() { + check_deps + core_get + core_test + ensure_flutter + app_get + app_codegen # NEW — before app_test + app_test +} + +do_run() { + check_deps + ensure_flutter + ensure_whisper + core_get + app_get + app_codegen # NEW — before app_analyze and app_build + app_analyze + app_build + app_run +} + +do_web() { + check_deps + ensure_flutter + ensure_whisper + core_get + app_get + app_codegen # NEW — before app_analyze + app_analyze + app_web +} +``` + +- [ ] **Step 3: Test the run.sh change** + +```bash +cd horatio && bash run.sh test +``` + +Expected: codegen runs, then tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add horatio/run.sh +git commit -m "chore: add drift codegen step to run.sh with caching" +``` + +--- + +### Task 15: Run full pipeline and verify coverage + +- [ ] **Step 1: Run full test + analyze pipeline** + +```bash +cd horatio && bash run.sh -f test +cd horatio && bash run.sh -f analyze +``` + +Expected: all analysis clean, all tests pass, 100% coverage on both packages. + +- [ ] **Step 2: Run pre-commit** + +```bash +pre-commit run --files $(git diff --name-only HEAD~10) +``` + +Fix any issues found. + +- [ ] **Step 3: Final commit and push** + +```bash +git add -A +git commit -m "feat: annotations subsystem — core models, drift DB, cubits" +git push +``` + +--- + +## Chunk 5: UI (Annotation Editor Screen) + +> The UI tasks are deliberately less granular since widget code depends heavily +> on visual feedback during development. Each task below should be broken into +> finer TDD steps at implementation time. +> +> **Required branch coverage scenarios** (I5): Every widget test must cover +> these branch scenarios to maintain 100% coverage. +> +> **Deferred:** Showing marks/note badges during rehearsal (spec §UI Components) +> will be a follow-up task after the annotation editor is complete. + +### Task 16: Add annotation routes + +**Files:** + +- Modify: `horatio_app/lib/router.dart` + +Add routes for `/annotations` and `/annotation-history`. Both take +`extra: {'script': Script}`. + +**Branch tests:** Route resolves correctly, route with null extra shows error. + +--- + +### Task 17: Create mark overlay widget + +**Files:** + +- Create: `horatio_app/lib/widgets/mark_overlay.dart` +- Create: `horatio_app/test/widgets/mark_overlay_test.dart` + +A widget that renders colored highlights on a `Text` widget based on a +`List`. Each `MarkType` gets a distinct color. + +**Branch tests:** + +- Empty marks list renders plain text +- Single mark renders colored span +- Multiple overlapping marks render correctly +- Each `MarkType` maps to a distinct color (iterate all values) +- Mark outside text bounds is handled gracefully + +--- + +### Task 18: Create note indicator widget + +**Files:** + +- Create: `horatio_app/lib/widgets/note_indicator.dart` +- Create: `horatio_app/test/widgets/note_indicator_test.dart` + +A small icon badge showing count of notes on a line. Tappable to expand. + +**Branch tests:** + +- Zero notes: indicator hidden +- One note: shows "1" badge +- Multiple notes: shows count badge +- Tap triggers callback + +--- + +### Task 19: Create mark type picker widget + +**Files:** + +- Create: `horatio_app/lib/widgets/mark_type_picker.dart` +- Create: `horatio_app/test/widgets/mark_type_picker_test.dart` + +Popup with `MarkType` options shown after user selects a text span. + +**Branch tests:** + +- All 6 `MarkType` values displayed +- Tap on each type calls onSelected with correct type +- Dismissing without selection calls onCancelled + +--- + +### Task 20: Create note editor bottom sheet + +**Files:** + +- Create: `horatio_app/lib/widgets/note_editor_sheet.dart` +- Create: `horatio_app/test/widgets/note_editor_sheet_test.dart` + +Bottom sheet with category picker and text field, shown on long-press of a line. + +**Branch tests:** + +- All 6 `NoteCategory` values in picker +- Submit with text calls onSave +- Submit with empty text is disabled / shows validation +- Cancel dismisses sheet +- Pre-filled text when editing existing note + +--- + +### Task 21: Create annotation editor screen + +**Files:** + +- Create: `horatio_app/lib/screens/annotation_editor_screen.dart` +- Create: `horatio_app/test/screens/annotation_editor_screen_test.dart` + +Full editor screen composing the widgets above, wired to both cubits. + +**Branch tests:** + +- Renders lines with marks overlay +- Renders note indicators on lines with notes +- Text selection shows mark type picker +- Long-press shows note editor sheet +- Line tap emits `selectLine` +- AnnotationInitial state shows loading indicator +- AnnotationLoaded state shows script lines + +--- + +### Task 22: Create annotation history screen + +**Files:** + +- Create: `horatio_app/lib/screens/annotation_history_screen.dart` +- Create: `horatio_app/test/screens/annotation_history_screen_test.dart` + +Timeline list of snapshots with restore buttons (confirmation dialog). + +**Branch tests:** + +- Empty snapshot list shows "No history yet" message +- Snapshots rendered with timestamp and mark/note counts +- Restore button shows confirmation dialog +- Confirm restore calls `restoreSnapshot` on cubit +- Cancel restore dismisses dialog +- Save snapshot button calls `saveSnapshot` + +--- + +### Task 23: Add annotation entry point to role selection + +**Files:** + +- Modify: `horatio_app/lib/screens/role_selection_screen.dart` + +Add an "Annotate Script" option to the bottom sheet in `_navigateWithRole`. + +**Branch tests:** + +- "Annotate Script" option visible +- Tap navigates to annotation editor route + +--- + +### Task 24: Final integration test + coverage + +- [ ] **Step 1: Run full pipeline** + +```bash +cd horatio && bash run.sh -f full +``` + +- [ ] **Step 2: Run pre-commit on all changed files** + +```bash +pre-commit run --all-files +``` + +- [ ] **Step 3: Commit and push** + +```bash +git add -A +git commit -m "feat: annotation editor and history UI screens" +git push +``` diff --git a/horatio/docs/superpowers/specs/2026-03-29-annotations-design.md b/horatio/docs/superpowers/specs/2026-03-29-annotations-design.md new file mode 100644 index 0000000..e959877 --- /dev/null +++ b/horatio/docs/superpowers/specs/2026-03-29-annotations-design.md @@ -0,0 +1,491 @@ +# Horatio — Annotations Subsystem Design Spec + +## Overview + +Add two layers of annotations to script lines: + +1. **Text marks** — span-based delivery marks on words/syllables (stress, pause, + breath, emphasis, tempo changes) +2. **Line notes** — free-text interpretive notes attached to a whole line (intention, + subtext, blocking, emotion, delivery, general) + +Both types include full change history via snapshots, enabling undo and annotation +evolution tracking over time. + +## Approach + +Drift-backed persistence (dependencies already in `pubspec.yaml`). This establishes +the SQLite persistence layer that Recording and future subsystems will reuse. + +- Core models live in `horatio_core` (pure Dart, no Flutter dependency) +- Persistence, state management, and UI live in `horatio_app` +- Annotations bind to `ScriptLine` via `scriptId` (UUID) + `lineIndex` +- This spec also introduces a `scriptId` field on `Script` to provide a stable + unique identifier for annotation binding (titles can collide) + +## Data Models (horatio_core) + +### MarkType Enum + +```dart +enum MarkType { + stress, // Stress/emphasize this word + pause, // Pause before this span + breath, // Take a breath here + emphasis, // General emphasis + slowDown, // Deliver this span slower + speedUp, // Deliver this span faster +} +``` + +### NoteCategory Enum + +```dart +enum NoteCategory { + intention, // "What does the character want here?" + subtext, // "What are they really saying?" + blocking, // "Cross downstage on this line" + emotion, // "Suppressed anger building" + delivery, // "Whisper this line" + general, // Catch-all +} +``` + +### TextMark + +Span-based annotation on text within a line. + +```dart +@immutable +final class TextMark { + TextMark({ + required this.id, + required this.lineIndex, + required this.startOffset, + required this.endOffset, + required this.type, + required this.createdAt, + }) { + assert(startOffset >= 0, 'startOffset must be non-negative'); + assert(endOffset > startOffset, 'endOffset must be greater than startOffset'); + } + + final String id; // UUID + final int lineIndex; // Which ScriptLine + final int startOffset; // Start character offset in line text + final int endOffset; // End character offset (exclusive) + final MarkType type; + final DateTime createdAt; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TextMark && id == other.id; + + @override + int get hashCode => id.hashCode; + + Map toJson() => { + 'id': id, + 'lineIndex': lineIndex, + 'startOffset': startOffset, + 'endOffset': endOffset, + 'type': type.name, + 'createdAt': createdAt.toUtc().toIso8601String(), + }; + + factory TextMark.fromJson(Map json) => TextMark( + id: json['id'] as String, + lineIndex: json['lineIndex'] as int, + startOffset: json['startOffset'] as int, + endOffset: json['endOffset'] as int, + type: MarkType.values.byName(json['type'] as String), + createdAt: DateTime.parse(json['createdAt'] as String), + ); +} +``` + +### LineNote + +Free-text note attached to a whole line. + +```dart +@immutable +final class LineNote { + const LineNote({ + required this.id, + required this.lineIndex, + required this.category, + required this.text, + required this.createdAt, + }); + + final String id; // UUID + final int lineIndex; // Which ScriptLine + final NoteCategory category; + final String text; + final DateTime createdAt; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LineNote && id == other.id; + + @override + int get hashCode => id.hashCode; + + Map toJson() => { + 'id': id, + 'lineIndex': lineIndex, + 'category': category.name, + 'text': text, + 'createdAt': createdAt.toUtc().toIso8601String(), + }; + + factory LineNote.fromJson(Map json) => LineNote( + id: json['id'] as String, + lineIndex: json['lineIndex'] as int, + category: NoteCategory.values.byName(json['category'] as String), + text: json['text'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + ); +} +``` + +### AnnotationSnapshot + +Point-in-time record of all annotations for a script. Enables change history, +undo/redo, and viewing annotation evolution over time. + +```dart +@immutable +final class AnnotationSnapshot { + AnnotationSnapshot({ + required this.id, + required this.scriptId, + required this.timestamp, + required List marks, + required List notes, + }) : marks = List.unmodifiable(marks), + notes = List.unmodifiable(notes); + + final String id; // UUID + final String scriptId; + final DateTime timestamp; + final List marks; // Unmodifiable + final List notes; // Unmodifiable + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AnnotationSnapshot && id == other.id; + + @override + int get hashCode => id.hashCode; + + Map toJson() => { + 'id': id, + 'scriptId': scriptId, + 'timestamp': timestamp.toUtc().toIso8601String(), + 'marks': marks.map((m) => m.toJson()).toList(), + 'notes': notes.map((n) => n.toJson()).toList(), + }; + + factory AnnotationSnapshot.fromJson(Map json) => + AnnotationSnapshot( + id: json['id'] as String, + scriptId: json['scriptId'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + marks: (json['marks'] as List) + .map((e) => TextMark.fromJson(e as Map)) + .toList(), + notes: (json['notes'] as List) + .map((e) => LineNote.fromJson(e as Map)) + .toList(), + ); +} +``` + +### Serialization + +All `DateTime` values use UTC ISO 8601 format. Enums serialize by `name` (string). +Serialization is manual (no `json_serializable` dependency) to keep `horatio_core` +free of codegen. Invalid enum names in `fromJson` throw `ArgumentError` via +`EnumName.byName` — this is intentional; corrupted data should fail loudly. + +## Persistence (Drift) + +### Database Location + +`horatio_app/lib/database/app_database.dart` — central drift database class. + +This is the first use of drift in the app. Future subsystems (Recording, SRS +persistence) will add their tables to this same database. + +### Tables + +**text_marks:** + +| Column | Type | Notes | +| ----------- | -------- | ----------------------- | +| id | text PK | UUID | +| scriptId | text | FK-like, UUID of script | +| lineIndex | integer | | +| startOffset | integer | | +| endOffset | integer | | +| markType | text | Enum name string | +| createdAt | dateTime | | + +**line_notes:** + +| Column | Type | Notes | +| --------- | -------- | ----------------------- | +| id | text PK | UUID | +| scriptId | text | FK-like, UUID of script | +| lineIndex | integer | | +| category | text | Enum name string | +| noteText | text | | +| createdAt | dateTime | | + +**annotation_snapshots:** + +| Column | Type | Notes | +| ------------ | -------- | ----------------------------- | +| id | text PK | UUID | +| scriptId | text | UUID of script | +| timestamp | dateTime | | +| snapshotJson | text | JSON-serialized marks + notes | + +### DAO + +`AnnotationDao` providing methods below. Note: `TextMark` and `LineNote` models +do not carry a `scriptId` field — the DAO binds `scriptId` at the persistence +boundary (on insert, and as a filter on queries). Models are always loaded in the +context of a known script. + +Methods: + +- `watchMarksForScript(scriptId)` → `Stream>` +- `watchNotesForScript(scriptId)` → `Stream>` +- `watchSnapshotsForScript(scriptId)` → `Stream>` +- `insertMark(scriptId, TextMark)`, `deleteMark(id)` +- `insertNote(scriptId, LineNote)`, `updateNoteText(id, text)`, `deleteNote(id)` +- `insertSnapshot(AnnotationSnapshot)` (snapshot carries its own `scriptId`) +- `getMarksForLine(scriptId, lineIndex)` → `Future>` +- `getNotesForLine(scriptId, lineIndex)` → `Future>` + +## State Management + +### AnnotationCubit + +Handles annotation CRUD. Snapshot management is in a separate cubit (below). + +```dart +sealed class AnnotationState extends Equatable {} + +final class AnnotationInitial extends AnnotationState {} + +final class AnnotationLoaded extends AnnotationState { + final String scriptId; + final List marks; + final List notes; + final int? selectedLineIndex; // Currently focused line + final EditingContext? editing; // Non-null when actively editing +} + +@immutable +final class EditingContext { + const EditingContext({ + required this.lineIndex, + required this.isAddingMark, + }); + final int lineIndex; + final bool isAddingMark; // true = placing mark, false = writing note +} +``` + +**Methods:** + +- `loadAnnotations(scriptId)` — subscribe to drift watch streams +- `selectLine(lineIndex)` — focus a line for annotation +- `startEditing(lineIndex, isAddingMark)` — enter editing mode +- `cancelEditing()` — exit editing mode +- `addMark(lineIndex, startOffset, endOffset, MarkType)` — create TextMark +- `removeMark(id)` — delete a mark +- `addNote(lineIndex, NoteCategory, text)` — create LineNote +- `updateNote(id, text)` — edit note content +- `removeNote(id)` — delete a note + +### AnnotationHistoryCubit + +Separate cubit for snapshot management (SRP: CRUD vs history are independent +concerns). + +```dart +sealed class AnnotationHistoryState extends Equatable {} + +final class AnnotationHistoryInitial extends AnnotationHistoryState {} + +final class AnnotationHistoryLoaded extends AnnotationHistoryState { + final String scriptId; + final List snapshots; +} +``` + +**Methods:** + +- `loadSnapshots(scriptId)` — subscribe to drift watch stream +- `saveSnapshot()` — capture current marks + notes as a snapshot +- `restoreSnapshot(snapshotId)` — delete all current annotations for the + script and replace with snapshot contents. This is destructive; the UI + must show a confirmation dialog before calling this. + +## UI + +### Annotation Editor Screen + +Accessed from script detail (new route). Shows the full script text with: + +- **Mark overlay:** Colored highlights on text spans. Each `MarkType` has a distinct + color (e.g., stress = red underline, pause = blue caret, breath = green dot). +- **Note indicators:** Small icons next to lines that have notes. Tappable to + expand/collapse. +- **Interaction:** + - Tap-and-drag on text to select a span → choose mark type from popup + - Tap a mark to remove it + - Long-press a line → add/edit notes in a bottom sheet with category picker +- **Toolbar:** Mark type filter toggles, snapshot save button, history button + +### Rehearsal Screen Enhancement + +- During rehearsal, show text marks on the cue/expected lines as colored highlights +- Show note count badge next to lines that have notes +- Optional: tap badge to peek at notes without leaving rehearsal flow + +### History View + +- Timeline list of `AnnotationSnapshot` entries +- Each entry shows timestamp and diff summary (marks added/removed, notes changed) +- Tap to restore that snapshot (with confirmation) + +## Testing Strategy + +### horatio_core Tests + +- `TextMark`: construction, equality, immutability, offset validation (assert + failures for negative offsets, endOffset <= startOffset) +- `LineNote`: construction, equality, immutability +- `AnnotationSnapshot`: construction with unmodifiable lists, serialization + roundtrip, empty marks/notes roundtrip, invalid enum names in JSON + (expect `ArgumentError`), malformed DateTime strings (expect `FormatException`) +- `MarkType` / `NoteCategory`: all enum values covered in serialization +- `Script.id`: new field present, non-empty +- Target: 100% branch coverage + +### horatio_app Tests + +- **Drift DAO tests:** In-memory database, CRUD operations, reactive stream + emissions, snapshot save/restore, restore-as-destructive-replace behavior +- **AnnotationCubit tests:** State transitions for load, select, start/cancel + editing, add/remove marks and notes. Mock the DAO. +- **AnnotationHistoryCubit tests:** Load snapshots, save snapshot, restore + snapshot (verify destructive replace). Mock the DAO. +- **Widget tests:** Annotation overlay renders marks correctly, tap interactions + trigger cubit methods, note bottom sheet displays and submits, history timeline + renders snapshots, restore confirmation dialog +- Target: 100% branch coverage + +## File Structure + +``` +horatio_core/lib/src/models/ + text_mark.dart + line_note.dart + annotation_snapshot.dart + mark_type.dart + note_category.dart + +horatio_app/lib/ + database/ + app_database.dart + app_database.g.dart (drift codegen) + tables/ + text_marks_table.dart + line_notes_table.dart + annotation_snapshots_table.dart + daos/ + annotation_dao.dart + annotation_dao.g.dart (drift codegen) + bloc/annotation/ + annotation_cubit.dart + annotation_state.dart + screens/ + annotation_editor_screen.dart + annotation_history_screen.dart + widgets/ + mark_overlay.dart + note_indicator.dart + mark_type_picker.dart + note_editor_sheet.dart +``` + +## Dependencies + +**horatio_core** (new): + +- `uuid` — for generating annotation IDs at model creation time + +**horatio_app** (already present): + +- `drift: ^2.22.0` +- `sqlite3_flutter_libs: ^0.6.0` +- `path_provider: ^2.1.0` +- `equatable: ^2.0.7` + +**horatio_app** (new dev dependencies): + +- `build_runner` — drift codegen runner +- `drift_dev` — drift code generator + +**Build pipeline change:** Add `dart run build_runner build` step to `run.sh` +before the analyze/test steps (with caching so it only regenerates when +drift table definitions change). + +## Migration Path + +This is the first drift database in the app. Schema version starts at 1. Future +subsystems (Recording, SRS persistence) will add tables via drift schema migrations +(version 2, 3, etc.). + +## Script Identity Change + +This spec introduces a `scriptId` field (UUID) on the `Script` model in +`horatio_core`. Generated at parse time via `uuid` package. This provides a +stable unique identifier that annotations bind to. + +```dart +final class Script { + const Script({ + required this.id, + required this.title, + required this.roles, + required this.scenes, + }); + + final String id; // UUID, generated at parse time + final String title; + final List roles; + final List scenes; + // ... existing methods unchanged +} +``` + +All existing code that creates `Script` instances (parsers, tests, demo data) +must be updated to supply an `id`. + +## Open Decisions + +- **Mark rendering:** Exact visual design (colors, underline styles, icons) will be + finalized during implementation based on what looks readable. +- **Snapshot granularity:** Auto-snapshot on every edit vs manual-only. Starting with + manual to keep it simple; auto-snapshot can be added later. diff --git a/horatio/horatio_app/lib/app.dart b/horatio/horatio_app/lib/app.dart index 8e9ac3f..4319cf5 100644 --- a/horatio/horatio_app/lib/app.dart +++ b/horatio/horatio_app/lib/app.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:horatio_app/bloc/script_import/script_import_cubit.dart'; import 'package:horatio_app/bloc/srs_review/srs_review_cubit.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; import 'package:horatio_app/router.dart'; import 'package:horatio_app/services/script_repository.dart'; import 'package:horatio_app/theme/app_theme.dart'; @@ -10,7 +12,10 @@ import 'package:horatio_app/theme/app_theme.dart'; /// Root widget for the Horatio app. class HoratioApp extends StatelessWidget { /// Creates the [HoratioApp]. - const HoratioApp({super.key}); + const HoratioApp({required this.database, super.key}); + + /// The drift database instance. + final AppDatabase database; @override Widget build(BuildContext context) => MultiRepositoryProvider( @@ -18,6 +23,9 @@ class HoratioApp extends StatelessWidget { RepositoryProvider( create: (_) => ScriptRepository(), ), + RepositoryProvider( + create: (_) => database.annotationDao, + ), ], child: MultiBlocProvider( providers: [ diff --git a/horatio/horatio_app/lib/bloc/annotation/annotation_cubit.dart b/horatio/horatio_app/lib/bloc/annotation/annotation_cubit.dart new file mode 100644 index 0000000..583ea83 --- /dev/null +++ b/horatio/horatio_app/lib/bloc/annotation/annotation_cubit.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/annotation/annotation_state.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:uuid/uuid.dart'; + +/// Manages annotation CRUD for a script. +class AnnotationCubit extends Cubit { + /// Creates an [AnnotationCubit]. + AnnotationCubit({required AnnotationDao dao}) + : _dao = dao, + super(const AnnotationInitial()); + + final AnnotationDao _dao; + StreamSubscription>? _marksSub; + StreamSubscription>? _notesSub; + String? _scriptId; + + static const _uuid = Uuid(); + + /// Subscribes to annotation streams for a script. + void loadAnnotations(String scriptId) { + _scriptId = scriptId; + _marksSub?.cancel(); + _notesSub?.cancel(); + + var latestMarks = []; + var latestNotes = []; + + _marksSub = _dao.watchMarksForScript(scriptId).listen((marks) { + latestMarks = marks; + _emitLoaded(scriptId, latestMarks, latestNotes); + }); + + _notesSub = _dao.watchNotesForScript(scriptId).listen((notes) { + latestNotes = notes; + _emitLoaded(scriptId, latestMarks, latestNotes); + }); + } + + void _emitLoaded( + String scriptId, + List marks, + List notes, + ) { + final current = state; + emit(AnnotationLoaded( + scriptId: scriptId, + marks: marks, + notes: notes, + selectedLineIndex: + current is AnnotationLoaded ? current.selectedLineIndex : null, + editing: current is AnnotationLoaded ? current.editing : null, + )); + } + + /// Focuses a line for annotation. + void selectLine(int? lineIndex) { + final current = state; + if (current is AnnotationLoaded) { + emit(current.copyWith(selectedLineIndex: () => lineIndex)); + } + } + + /// Enters editing mode. + void startEditing({required int lineIndex, required bool isAddingMark}) { + final current = state; + if (current is AnnotationLoaded) { + emit(current.copyWith( + selectedLineIndex: () => lineIndex, + editing: () => EditingContext( + lineIndex: lineIndex, + isAddingMark: isAddingMark, + ), + )); + } + } + + /// Exits editing mode. + void cancelEditing() { + final current = state; + if (current is AnnotationLoaded) { + emit(current.copyWith(editing: () => null)); + } + } + + /// Adds a text mark. + Future addMark({ + required int lineIndex, + required int startOffset, + required int endOffset, + required MarkType type, + }) async { + final scriptId = _scriptId; + if (scriptId == null) return; + final mark = TextMark( + id: _uuid.v4(), + lineIndex: lineIndex, + startOffset: startOffset, + endOffset: endOffset, + type: type, + createdAt: DateTime.now().toUtc(), + ); + await _dao.insertMark(scriptId, mark); + } + + /// Removes a text mark. + Future removeMark(String id) => _dao.deleteMark(id); + + /// Adds a line note. + Future addNote({ + required int lineIndex, + required NoteCategory category, + required String text, + }) async { + final scriptId = _scriptId; + if (scriptId == null) return; + final note = LineNote( + id: _uuid.v4(), + lineIndex: lineIndex, + category: category, + text: text, + createdAt: DateTime.now().toUtc(), + ); + await _dao.insertNote(scriptId, note); + } + + /// Updates a note's text. + Future updateNote(String id, String text) => + _dao.updateNoteText(id, text); + + /// Removes a note. + Future removeNote(String id) => _dao.deleteNote(id); + + @override + Future close() { + _marksSub?.cancel(); + _notesSub?.cancel(); + return super.close(); + } +} diff --git a/horatio/horatio_app/lib/bloc/annotation/annotation_history_cubit.dart b/horatio/horatio_app/lib/bloc/annotation/annotation_history_cubit.dart new file mode 100644 index 0000000..2293fbb --- /dev/null +++ b/horatio/horatio_app/lib/bloc/annotation/annotation_history_cubit.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/annotation/annotation_history_state.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:uuid/uuid.dart'; + +/// Manages annotation snapshot history for a script. +class AnnotationHistoryCubit extends Cubit { + /// Creates an [AnnotationHistoryCubit]. + AnnotationHistoryCubit({required AnnotationDao dao}) + : _dao = dao, + super(const AnnotationHistoryInitial()); + + final AnnotationDao _dao; + StreamSubscription>? _sub; + String? _scriptId; + + static const _uuid = Uuid(); + + /// Subscribes to snapshots for a script. + void loadSnapshots(String scriptId) { + _scriptId = scriptId; + _sub?.cancel(); + _sub = _dao.watchSnapshotsForScript(scriptId).listen((snapshots) { + emit(AnnotationHistoryLoaded( + scriptId: scriptId, + snapshots: snapshots, + )); + }); + } + + /// Saves current annotations as a snapshot. + Future saveSnapshot({ + required List marks, + required List notes, + }) async { + final scriptId = _scriptId; + if (scriptId == null) return; + final snapshot = AnnotationSnapshot( + id: _uuid.v4(), + scriptId: scriptId, + timestamp: DateTime.now().toUtc(), + marks: marks, + notes: notes, + ); + await _dao.insertSnapshot(snapshot); + } + + /// Restores annotations from a snapshot (destructive replace). + Future restoreSnapshot(AnnotationSnapshot snapshot) async { + final scriptId = _scriptId; + if (scriptId == null) return; + await _dao.replaceAllAnnotations( + scriptId: scriptId, + marks: snapshot.marks, + notes: snapshot.notes, + ); + } + + @override + Future close() { + _sub?.cancel(); + return super.close(); + } +} diff --git a/horatio/horatio_app/lib/bloc/annotation/annotation_history_state.dart b/horatio/horatio_app/lib/bloc/annotation/annotation_history_state.dart new file mode 100644 index 0000000..d3810c9 --- /dev/null +++ b/horatio/horatio_app/lib/bloc/annotation/annotation_history_state.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// State for [AnnotationHistoryCubit]. +sealed class AnnotationHistoryState extends Equatable { + const AnnotationHistoryState(); +} + +/// No snapshots loaded. +final class AnnotationHistoryInitial extends AnnotationHistoryState { + const AnnotationHistoryInitial(); + + @override + List get props => []; +} + +/// Snapshots loaded for a script. +final class AnnotationHistoryLoaded extends AnnotationHistoryState { + const AnnotationHistoryLoaded({ + required this.scriptId, + required this.snapshots, + }); + + final String scriptId; + final List snapshots; + + @override + List get props => [scriptId, snapshots]; +} diff --git a/horatio/horatio_app/lib/bloc/annotation/annotation_state.dart b/horatio/horatio_app/lib/bloc/annotation/annotation_state.dart new file mode 100644 index 0000000..961ebf6 --- /dev/null +++ b/horatio/horatio_app/lib/bloc/annotation/annotation_state.dart @@ -0,0 +1,78 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// State for [AnnotationCubit]. +/// +/// Does not extend [Equatable] because [TextMark] and [LineNote] use +/// id-only equality. Extending [Equatable] would cause [Cubit.emit] +/// to silently drop state updates when only non-id fields change +/// (e.g. after [AnnotationDao.updateNoteText]). +sealed class AnnotationState { + const AnnotationState(); +} + +/// No annotations loaded. +final class AnnotationInitial extends AnnotationState { + const AnnotationInitial(); +} + +/// Annotations loaded for a script. +final class AnnotationLoaded extends AnnotationState { + const AnnotationLoaded({ + required this.scriptId, + required this.marks, + required this.notes, + this.selectedLineIndex, + this.editing, + }); + + /// The script these annotations belong to. + final String scriptId; + + /// All text marks for this script. + final List marks; + + /// All line notes for this script. + final List notes; + + /// Currently selected line index (nullable). + final int? selectedLineIndex; + + /// Non-null when actively editing. + final EditingContext? editing; + + /// Creates a copy with specified fields replaced. + AnnotationLoaded copyWith({ + List? marks, + List? notes, + int? Function()? selectedLineIndex, + EditingContext? Function()? editing, + }) => AnnotationLoaded( + scriptId: scriptId, + marks: marks ?? this.marks, + notes: notes ?? this.notes, + selectedLineIndex: selectedLineIndex != null + ? selectedLineIndex() + : this.selectedLineIndex, + editing: editing != null ? editing() : this.editing, + ); +} + +/// Context for an active annotation edit. +@immutable +final class EditingContext extends Equatable { + const EditingContext({ + required this.lineIndex, + required this.isAddingMark, + }); + + /// The line being edited. + final int lineIndex; + + /// Whether placing a mark (true) or writing a note (false). + final bool isAddingMark; + + @override + List get props => [lineIndex, isAddingMark]; +} diff --git a/horatio/horatio_app/lib/database/app_database.dart b/horatio/horatio_app/lib/database/app_database.dart new file mode 100644 index 0000000..3023332 --- /dev/null +++ b/horatio/horatio_app/lib/database/app_database.dart @@ -0,0 +1,23 @@ +import 'package:drift/drift.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_app/database/tables/annotation_snapshots_table.dart'; +import 'package:horatio_app/database/tables/line_notes_table.dart'; +import 'package:horatio_app/database/tables/text_marks_table.dart'; + +part 'app_database.g.dart'; + +/// Central drift database for Horatio. +/// +/// Schema version 1: annotation tables (text_marks, line_notes, +/// annotation_snapshots). +@DriftDatabase( + tables: [TextMarksTable, LineNotesTable, AnnotationSnapshotsTable], + daos: [AnnotationDao], +) +class AppDatabase extends _$AppDatabase { + /// Creates an [AppDatabase] with the given [QueryExecutor]. + AppDatabase(super.e); + + @override + int get schemaVersion => 1; +} diff --git a/horatio/horatio_app/lib/database/app_database.g.dart b/horatio/horatio_app/lib/database/app_database.g.dart new file mode 100644 index 0000000..7427087 --- /dev/null +++ b/horatio/horatio_app/lib/database/app_database.g.dart @@ -0,0 +1,1918 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_database.dart'; + +// ignore_for_file: type=lint +class $TextMarksTableTable extends TextMarksTable + with TableInfo<$TextMarksTableTable, TextMarksTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TextMarksTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _scriptIdMeta = const VerificationMeta( + 'scriptId', + ); + @override + late final GeneratedColumn scriptId = GeneratedColumn( + 'script_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lineIndexMeta = const VerificationMeta( + 'lineIndex', + ); + @override + late final GeneratedColumn lineIndex = GeneratedColumn( + 'line_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _startOffsetMeta = const VerificationMeta( + 'startOffset', + ); + @override + late final GeneratedColumn startOffset = GeneratedColumn( + 'start_offset', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _endOffsetMeta = const VerificationMeta( + 'endOffset', + ); + @override + late final GeneratedColumn endOffset = GeneratedColumn( + 'end_offset', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _markTypeMeta = const VerificationMeta( + 'markType', + ); + @override + late final GeneratedColumn markType = GeneratedColumn( + 'mark_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + scriptId, + lineIndex, + startOffset, + endOffset, + markType, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'text_marks'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('script_id')) { + context.handle( + _scriptIdMeta, + scriptId.isAcceptableOrUnknown(data['script_id']!, _scriptIdMeta), + ); + } else if (isInserting) { + context.missing(_scriptIdMeta); + } + if (data.containsKey('line_index')) { + context.handle( + _lineIndexMeta, + lineIndex.isAcceptableOrUnknown(data['line_index']!, _lineIndexMeta), + ); + } else if (isInserting) { + context.missing(_lineIndexMeta); + } + if (data.containsKey('start_offset')) { + context.handle( + _startOffsetMeta, + startOffset.isAcceptableOrUnknown( + data['start_offset']!, + _startOffsetMeta, + ), + ); + } else if (isInserting) { + context.missing(_startOffsetMeta); + } + if (data.containsKey('end_offset')) { + context.handle( + _endOffsetMeta, + endOffset.isAcceptableOrUnknown(data['end_offset']!, _endOffsetMeta), + ); + } else if (isInserting) { + context.missing(_endOffsetMeta); + } + if (data.containsKey('mark_type')) { + context.handle( + _markTypeMeta, + markType.isAcceptableOrUnknown(data['mark_type']!, _markTypeMeta), + ); + } else if (isInserting) { + context.missing(_markTypeMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + TextMarksTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TextMarksTableData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + scriptId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}script_id'], + )!, + lineIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}line_index'], + )!, + startOffset: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}start_offset'], + )!, + endOffset: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}end_offset'], + )!, + markType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $TextMarksTableTable createAlias(String alias) { + return $TextMarksTableTable(attachedDatabase, alias); + } +} + +class TextMarksTableData extends DataClass + implements Insertable { + final String id; + final String scriptId; + final int lineIndex; + final int startOffset; + final int endOffset; + final String markType; + final DateTime createdAt; + const TextMarksTableData({ + required this.id, + required this.scriptId, + required this.lineIndex, + required this.startOffset, + required this.endOffset, + required this.markType, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['script_id'] = Variable(scriptId); + map['line_index'] = Variable(lineIndex); + map['start_offset'] = Variable(startOffset); + map['end_offset'] = Variable(endOffset); + map['mark_type'] = Variable(markType); + map['created_at'] = Variable(createdAt); + return map; + } + + TextMarksTableCompanion toCompanion(bool nullToAbsent) { + return TextMarksTableCompanion( + id: Value(id), + scriptId: Value(scriptId), + lineIndex: Value(lineIndex), + startOffset: Value(startOffset), + endOffset: Value(endOffset), + markType: Value(markType), + createdAt: Value(createdAt), + ); + } + + factory TextMarksTableData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TextMarksTableData( + id: serializer.fromJson(json['id']), + scriptId: serializer.fromJson(json['scriptId']), + lineIndex: serializer.fromJson(json['lineIndex']), + startOffset: serializer.fromJson(json['startOffset']), + endOffset: serializer.fromJson(json['endOffset']), + markType: serializer.fromJson(json['markType']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'scriptId': serializer.toJson(scriptId), + 'lineIndex': serializer.toJson(lineIndex), + 'startOffset': serializer.toJson(startOffset), + 'endOffset': serializer.toJson(endOffset), + 'markType': serializer.toJson(markType), + 'createdAt': serializer.toJson(createdAt), + }; + } + + TextMarksTableData copyWith({ + String? id, + String? scriptId, + int? lineIndex, + int? startOffset, + int? endOffset, + String? markType, + DateTime? createdAt, + }) => TextMarksTableData( + id: id ?? this.id, + scriptId: scriptId ?? this.scriptId, + lineIndex: lineIndex ?? this.lineIndex, + startOffset: startOffset ?? this.startOffset, + endOffset: endOffset ?? this.endOffset, + markType: markType ?? this.markType, + createdAt: createdAt ?? this.createdAt, + ); + TextMarksTableData copyWithCompanion(TextMarksTableCompanion data) { + return TextMarksTableData( + id: data.id.present ? data.id.value : this.id, + scriptId: data.scriptId.present ? data.scriptId.value : this.scriptId, + lineIndex: data.lineIndex.present ? data.lineIndex.value : this.lineIndex, + startOffset: data.startOffset.present + ? data.startOffset.value + : this.startOffset, + endOffset: data.endOffset.present ? data.endOffset.value : this.endOffset, + markType: data.markType.present ? data.markType.value : this.markType, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('TextMarksTableData(') + ..write('id: $id, ') + ..write('scriptId: $scriptId, ') + ..write('lineIndex: $lineIndex, ') + ..write('startOffset: $startOffset, ') + ..write('endOffset: $endOffset, ') + ..write('markType: $markType, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + scriptId, + lineIndex, + startOffset, + endOffset, + markType, + createdAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TextMarksTableData && + other.id == this.id && + other.scriptId == this.scriptId && + other.lineIndex == this.lineIndex && + other.startOffset == this.startOffset && + other.endOffset == this.endOffset && + other.markType == this.markType && + other.createdAt == this.createdAt); +} + +class TextMarksTableCompanion extends UpdateCompanion { + final Value id; + final Value scriptId; + final Value lineIndex; + final Value startOffset; + final Value endOffset; + final Value markType; + final Value createdAt; + final Value rowid; + const TextMarksTableCompanion({ + this.id = const Value.absent(), + this.scriptId = const Value.absent(), + this.lineIndex = const Value.absent(), + this.startOffset = const Value.absent(), + this.endOffset = const Value.absent(), + this.markType = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + TextMarksTableCompanion.insert({ + required String id, + required String scriptId, + required int lineIndex, + required int startOffset, + required int endOffset, + required String markType, + required DateTime createdAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + scriptId = Value(scriptId), + lineIndex = Value(lineIndex), + startOffset = Value(startOffset), + endOffset = Value(endOffset), + markType = Value(markType), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? scriptId, + Expression? lineIndex, + Expression? startOffset, + Expression? endOffset, + Expression? markType, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (scriptId != null) 'script_id': scriptId, + if (lineIndex != null) 'line_index': lineIndex, + if (startOffset != null) 'start_offset': startOffset, + if (endOffset != null) 'end_offset': endOffset, + if (markType != null) 'mark_type': markType, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + TextMarksTableCompanion copyWith({ + Value? id, + Value? scriptId, + Value? lineIndex, + Value? startOffset, + Value? endOffset, + Value? markType, + Value? createdAt, + Value? rowid, + }) { + return TextMarksTableCompanion( + id: id ?? this.id, + scriptId: scriptId ?? this.scriptId, + lineIndex: lineIndex ?? this.lineIndex, + startOffset: startOffset ?? this.startOffset, + endOffset: endOffset ?? this.endOffset, + markType: markType ?? this.markType, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (scriptId.present) { + map['script_id'] = Variable(scriptId.value); + } + if (lineIndex.present) { + map['line_index'] = Variable(lineIndex.value); + } + if (startOffset.present) { + map['start_offset'] = Variable(startOffset.value); + } + if (endOffset.present) { + map['end_offset'] = Variable(endOffset.value); + } + if (markType.present) { + map['mark_type'] = Variable(markType.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TextMarksTableCompanion(') + ..write('id: $id, ') + ..write('scriptId: $scriptId, ') + ..write('lineIndex: $lineIndex, ') + ..write('startOffset: $startOffset, ') + ..write('endOffset: $endOffset, ') + ..write('markType: $markType, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $LineNotesTableTable extends LineNotesTable + with TableInfo<$LineNotesTableTable, LineNotesTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $LineNotesTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _scriptIdMeta = const VerificationMeta( + 'scriptId', + ); + @override + late final GeneratedColumn scriptId = GeneratedColumn( + 'script_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lineIndexMeta = const VerificationMeta( + 'lineIndex', + ); + @override + late final GeneratedColumn lineIndex = GeneratedColumn( + 'line_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _categoryMeta = const VerificationMeta( + 'category', + ); + @override + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _noteTextMeta = const VerificationMeta( + 'noteText', + ); + @override + late final GeneratedColumn noteText = GeneratedColumn( + 'note_text', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + scriptId, + lineIndex, + category, + noteText, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'line_notes'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('script_id')) { + context.handle( + _scriptIdMeta, + scriptId.isAcceptableOrUnknown(data['script_id']!, _scriptIdMeta), + ); + } else if (isInserting) { + context.missing(_scriptIdMeta); + } + if (data.containsKey('line_index')) { + context.handle( + _lineIndexMeta, + lineIndex.isAcceptableOrUnknown(data['line_index']!, _lineIndexMeta), + ); + } else if (isInserting) { + context.missing(_lineIndexMeta); + } + if (data.containsKey('category')) { + context.handle( + _categoryMeta, + category.isAcceptableOrUnknown(data['category']!, _categoryMeta), + ); + } else if (isInserting) { + context.missing(_categoryMeta); + } + if (data.containsKey('note_text')) { + context.handle( + _noteTextMeta, + noteText.isAcceptableOrUnknown(data['note_text']!, _noteTextMeta), + ); + } else if (isInserting) { + context.missing(_noteTextMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + LineNotesTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LineNotesTableData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + scriptId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}script_id'], + )!, + lineIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}line_index'], + )!, + category: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category'], + )!, + noteText: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}note_text'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $LineNotesTableTable createAlias(String alias) { + return $LineNotesTableTable(attachedDatabase, alias); + } +} + +class LineNotesTableData extends DataClass + implements Insertable { + final String id; + final String scriptId; + final int lineIndex; + final String category; + final String noteText; + final DateTime createdAt; + const LineNotesTableData({ + required this.id, + required this.scriptId, + required this.lineIndex, + required this.category, + required this.noteText, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['script_id'] = Variable(scriptId); + map['line_index'] = Variable(lineIndex); + map['category'] = Variable(category); + map['note_text'] = Variable(noteText); + map['created_at'] = Variable(createdAt); + return map; + } + + LineNotesTableCompanion toCompanion(bool nullToAbsent) { + return LineNotesTableCompanion( + id: Value(id), + scriptId: Value(scriptId), + lineIndex: Value(lineIndex), + category: Value(category), + noteText: Value(noteText), + createdAt: Value(createdAt), + ); + } + + factory LineNotesTableData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LineNotesTableData( + id: serializer.fromJson(json['id']), + scriptId: serializer.fromJson(json['scriptId']), + lineIndex: serializer.fromJson(json['lineIndex']), + category: serializer.fromJson(json['category']), + noteText: serializer.fromJson(json['noteText']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'scriptId': serializer.toJson(scriptId), + 'lineIndex': serializer.toJson(lineIndex), + 'category': serializer.toJson(category), + 'noteText': serializer.toJson(noteText), + 'createdAt': serializer.toJson(createdAt), + }; + } + + LineNotesTableData copyWith({ + String? id, + String? scriptId, + int? lineIndex, + String? category, + String? noteText, + DateTime? createdAt, + }) => LineNotesTableData( + id: id ?? this.id, + scriptId: scriptId ?? this.scriptId, + lineIndex: lineIndex ?? this.lineIndex, + category: category ?? this.category, + noteText: noteText ?? this.noteText, + createdAt: createdAt ?? this.createdAt, + ); + LineNotesTableData copyWithCompanion(LineNotesTableCompanion data) { + return LineNotesTableData( + id: data.id.present ? data.id.value : this.id, + scriptId: data.scriptId.present ? data.scriptId.value : this.scriptId, + lineIndex: data.lineIndex.present ? data.lineIndex.value : this.lineIndex, + category: data.category.present ? data.category.value : this.category, + noteText: data.noteText.present ? data.noteText.value : this.noteText, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('LineNotesTableData(') + ..write('id: $id, ') + ..write('scriptId: $scriptId, ') + ..write('lineIndex: $lineIndex, ') + ..write('category: $category, ') + ..write('noteText: $noteText, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, scriptId, lineIndex, category, noteText, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LineNotesTableData && + other.id == this.id && + other.scriptId == this.scriptId && + other.lineIndex == this.lineIndex && + other.category == this.category && + other.noteText == this.noteText && + other.createdAt == this.createdAt); +} + +class LineNotesTableCompanion extends UpdateCompanion { + final Value id; + final Value scriptId; + final Value lineIndex; + final Value category; + final Value noteText; + final Value createdAt; + final Value rowid; + const LineNotesTableCompanion({ + this.id = const Value.absent(), + this.scriptId = const Value.absent(), + this.lineIndex = const Value.absent(), + this.category = const Value.absent(), + this.noteText = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + LineNotesTableCompanion.insert({ + required String id, + required String scriptId, + required int lineIndex, + required String category, + required String noteText, + required DateTime createdAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + scriptId = Value(scriptId), + lineIndex = Value(lineIndex), + category = Value(category), + noteText = Value(noteText), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? scriptId, + Expression? lineIndex, + Expression? category, + Expression? noteText, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (scriptId != null) 'script_id': scriptId, + if (lineIndex != null) 'line_index': lineIndex, + if (category != null) 'category': category, + if (noteText != null) 'note_text': noteText, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + LineNotesTableCompanion copyWith({ + Value? id, + Value? scriptId, + Value? lineIndex, + Value? category, + Value? noteText, + Value? createdAt, + Value? rowid, + }) { + return LineNotesTableCompanion( + id: id ?? this.id, + scriptId: scriptId ?? this.scriptId, + lineIndex: lineIndex ?? this.lineIndex, + category: category ?? this.category, + noteText: noteText ?? this.noteText, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (scriptId.present) { + map['script_id'] = Variable(scriptId.value); + } + if (lineIndex.present) { + map['line_index'] = Variable(lineIndex.value); + } + if (category.present) { + map['category'] = Variable(category.value); + } + if (noteText.present) { + map['note_text'] = Variable(noteText.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LineNotesTableCompanion(') + ..write('id: $id, ') + ..write('scriptId: $scriptId, ') + ..write('lineIndex: $lineIndex, ') + ..write('category: $category, ') + ..write('noteText: $noteText, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $AnnotationSnapshotsTableTable extends AnnotationSnapshotsTable + with + TableInfo< + $AnnotationSnapshotsTableTable, + AnnotationSnapshotsTableData + > { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AnnotationSnapshotsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _scriptIdMeta = const VerificationMeta( + 'scriptId', + ); + @override + late final GeneratedColumn scriptId = GeneratedColumn( + 'script_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _timestampMeta = const VerificationMeta( + 'timestamp', + ); + @override + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _snapshotJsonMeta = const VerificationMeta( + 'snapshotJson', + ); + @override + late final GeneratedColumn snapshotJson = GeneratedColumn( + 'snapshot_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, scriptId, timestamp, snapshotJson]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'annotation_snapshots'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('script_id')) { + context.handle( + _scriptIdMeta, + scriptId.isAcceptableOrUnknown(data['script_id']!, _scriptIdMeta), + ); + } else if (isInserting) { + context.missing(_scriptIdMeta); + } + if (data.containsKey('timestamp')) { + context.handle( + _timestampMeta, + timestamp.isAcceptableOrUnknown(data['timestamp']!, _timestampMeta), + ); + } else if (isInserting) { + context.missing(_timestampMeta); + } + if (data.containsKey('snapshot_json')) { + context.handle( + _snapshotJsonMeta, + snapshotJson.isAcceptableOrUnknown( + data['snapshot_json']!, + _snapshotJsonMeta, + ), + ); + } else if (isInserting) { + context.missing(_snapshotJsonMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + AnnotationSnapshotsTableData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AnnotationSnapshotsTableData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + scriptId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}script_id'], + )!, + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}timestamp'], + )!, + snapshotJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}snapshot_json'], + )!, + ); + } + + @override + $AnnotationSnapshotsTableTable createAlias(String alias) { + return $AnnotationSnapshotsTableTable(attachedDatabase, alias); + } +} + +class AnnotationSnapshotsTableData extends DataClass + implements Insertable { + final String id; + final String scriptId; + final DateTime timestamp; + final String snapshotJson; + const AnnotationSnapshotsTableData({ + required this.id, + required this.scriptId, + required this.timestamp, + required this.snapshotJson, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['script_id'] = Variable(scriptId); + map['timestamp'] = Variable(timestamp); + map['snapshot_json'] = Variable(snapshotJson); + return map; + } + + AnnotationSnapshotsTableCompanion toCompanion(bool nullToAbsent) { + return AnnotationSnapshotsTableCompanion( + id: Value(id), + scriptId: Value(scriptId), + timestamp: Value(timestamp), + snapshotJson: Value(snapshotJson), + ); + } + + factory AnnotationSnapshotsTableData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AnnotationSnapshotsTableData( + id: serializer.fromJson(json['id']), + scriptId: serializer.fromJson(json['scriptId']), + timestamp: serializer.fromJson(json['timestamp']), + snapshotJson: serializer.fromJson(json['snapshotJson']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'scriptId': serializer.toJson(scriptId), + 'timestamp': serializer.toJson(timestamp), + 'snapshotJson': serializer.toJson(snapshotJson), + }; + } + + AnnotationSnapshotsTableData copyWith({ + String? id, + String? scriptId, + DateTime? timestamp, + String? snapshotJson, + }) => AnnotationSnapshotsTableData( + id: id ?? this.id, + scriptId: scriptId ?? this.scriptId, + timestamp: timestamp ?? this.timestamp, + snapshotJson: snapshotJson ?? this.snapshotJson, + ); + AnnotationSnapshotsTableData copyWithCompanion( + AnnotationSnapshotsTableCompanion data, + ) { + return AnnotationSnapshotsTableData( + id: data.id.present ? data.id.value : this.id, + scriptId: data.scriptId.present ? data.scriptId.value : this.scriptId, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + snapshotJson: data.snapshotJson.present + ? data.snapshotJson.value + : this.snapshotJson, + ); + } + + @override + String toString() { + return (StringBuffer('AnnotationSnapshotsTableData(') + ..write('id: $id, ') + ..write('scriptId: $scriptId, ') + ..write('timestamp: $timestamp, ') + ..write('snapshotJson: $snapshotJson') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, scriptId, timestamp, snapshotJson); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AnnotationSnapshotsTableData && + other.id == this.id && + other.scriptId == this.scriptId && + other.timestamp == this.timestamp && + other.snapshotJson == this.snapshotJson); +} + +class AnnotationSnapshotsTableCompanion + extends UpdateCompanion { + final Value id; + final Value scriptId; + final Value timestamp; + final Value snapshotJson; + final Value rowid; + const AnnotationSnapshotsTableCompanion({ + this.id = const Value.absent(), + this.scriptId = const Value.absent(), + this.timestamp = const Value.absent(), + this.snapshotJson = const Value.absent(), + this.rowid = const Value.absent(), + }); + AnnotationSnapshotsTableCompanion.insert({ + required String id, + required String scriptId, + required DateTime timestamp, + required String snapshotJson, + this.rowid = const Value.absent(), + }) : id = Value(id), + scriptId = Value(scriptId), + timestamp = Value(timestamp), + snapshotJson = Value(snapshotJson); + static Insertable custom({ + Expression? id, + Expression? scriptId, + Expression? timestamp, + Expression? snapshotJson, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (scriptId != null) 'script_id': scriptId, + if (timestamp != null) 'timestamp': timestamp, + if (snapshotJson != null) 'snapshot_json': snapshotJson, + if (rowid != null) 'rowid': rowid, + }); + } + + AnnotationSnapshotsTableCompanion copyWith({ + Value? id, + Value? scriptId, + Value? timestamp, + Value? snapshotJson, + Value? rowid, + }) { + return AnnotationSnapshotsTableCompanion( + id: id ?? this.id, + scriptId: scriptId ?? this.scriptId, + timestamp: timestamp ?? this.timestamp, + snapshotJson: snapshotJson ?? this.snapshotJson, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (scriptId.present) { + map['script_id'] = Variable(scriptId.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (snapshotJson.present) { + map['snapshot_json'] = Variable(snapshotJson.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AnnotationSnapshotsTableCompanion(') + ..write('id: $id, ') + ..write('scriptId: $scriptId, ') + ..write('timestamp: $timestamp, ') + ..write('snapshotJson: $snapshotJson, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $TextMarksTableTable textMarksTable = $TextMarksTableTable(this); + late final $LineNotesTableTable lineNotesTable = $LineNotesTableTable(this); + late final $AnnotationSnapshotsTableTable annotationSnapshotsTable = + $AnnotationSnapshotsTableTable(this); + late final AnnotationDao annotationDao = AnnotationDao(this as AppDatabase); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + textMarksTable, + lineNotesTable, + annotationSnapshotsTable, + ]; +} + +typedef $$TextMarksTableTableCreateCompanionBuilder = + TextMarksTableCompanion Function({ + required String id, + required String scriptId, + required int lineIndex, + required int startOffset, + required int endOffset, + required String markType, + required DateTime createdAt, + Value rowid, + }); +typedef $$TextMarksTableTableUpdateCompanionBuilder = + TextMarksTableCompanion Function({ + Value id, + Value scriptId, + Value lineIndex, + Value startOffset, + Value endOffset, + Value markType, + Value createdAt, + Value rowid, + }); + +class $$TextMarksTableTableFilterComposer + extends Composer<_$AppDatabase, $TextMarksTableTable> { + $$TextMarksTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scriptId => $composableBuilder( + column: $table.scriptId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lineIndex => $composableBuilder( + column: $table.lineIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get startOffset => $composableBuilder( + column: $table.startOffset, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get endOffset => $composableBuilder( + column: $table.endOffset, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get markType => $composableBuilder( + column: $table.markType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$TextMarksTableTableOrderingComposer + extends Composer<_$AppDatabase, $TextMarksTableTable> { + $$TextMarksTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scriptId => $composableBuilder( + column: $table.scriptId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lineIndex => $composableBuilder( + column: $table.lineIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get startOffset => $composableBuilder( + column: $table.startOffset, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get endOffset => $composableBuilder( + column: $table.endOffset, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get markType => $composableBuilder( + column: $table.markType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$TextMarksTableTableAnnotationComposer + extends Composer<_$AppDatabase, $TextMarksTableTable> { + $$TextMarksTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get scriptId => + $composableBuilder(column: $table.scriptId, builder: (column) => column); + + GeneratedColumn get lineIndex => + $composableBuilder(column: $table.lineIndex, builder: (column) => column); + + GeneratedColumn get startOffset => $composableBuilder( + column: $table.startOffset, + builder: (column) => column, + ); + + GeneratedColumn get endOffset => + $composableBuilder(column: $table.endOffset, builder: (column) => column); + + GeneratedColumn get markType => + $composableBuilder(column: $table.markType, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$TextMarksTableTableTableManager + extends + RootTableManager< + _$AppDatabase, + $TextMarksTableTable, + TextMarksTableData, + $$TextMarksTableTableFilterComposer, + $$TextMarksTableTableOrderingComposer, + $$TextMarksTableTableAnnotationComposer, + $$TextMarksTableTableCreateCompanionBuilder, + $$TextMarksTableTableUpdateCompanionBuilder, + ( + TextMarksTableData, + BaseReferences< + _$AppDatabase, + $TextMarksTableTable, + TextMarksTableData + >, + ), + TextMarksTableData, + PrefetchHooks Function() + > { + $$TextMarksTableTableTableManager( + _$AppDatabase db, + $TextMarksTableTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TextMarksTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TextMarksTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TextMarksTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value scriptId = const Value.absent(), + Value lineIndex = const Value.absent(), + Value startOffset = const Value.absent(), + Value endOffset = const Value.absent(), + Value markType = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => TextMarksTableCompanion( + id: id, + scriptId: scriptId, + lineIndex: lineIndex, + startOffset: startOffset, + endOffset: endOffset, + markType: markType, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String scriptId, + required int lineIndex, + required int startOffset, + required int endOffset, + required String markType, + required DateTime createdAt, + Value rowid = const Value.absent(), + }) => TextMarksTableCompanion.insert( + id: id, + scriptId: scriptId, + lineIndex: lineIndex, + startOffset: startOffset, + endOffset: endOffset, + markType: markType, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$TextMarksTableTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $TextMarksTableTable, + TextMarksTableData, + $$TextMarksTableTableFilterComposer, + $$TextMarksTableTableOrderingComposer, + $$TextMarksTableTableAnnotationComposer, + $$TextMarksTableTableCreateCompanionBuilder, + $$TextMarksTableTableUpdateCompanionBuilder, + ( + TextMarksTableData, + BaseReferences<_$AppDatabase, $TextMarksTableTable, TextMarksTableData>, + ), + TextMarksTableData, + PrefetchHooks Function() + >; +typedef $$LineNotesTableTableCreateCompanionBuilder = + LineNotesTableCompanion Function({ + required String id, + required String scriptId, + required int lineIndex, + required String category, + required String noteText, + required DateTime createdAt, + Value rowid, + }); +typedef $$LineNotesTableTableUpdateCompanionBuilder = + LineNotesTableCompanion Function({ + Value id, + Value scriptId, + Value lineIndex, + Value category, + Value noteText, + Value createdAt, + Value rowid, + }); + +class $$LineNotesTableTableFilterComposer + extends Composer<_$AppDatabase, $LineNotesTableTable> { + $$LineNotesTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scriptId => $composableBuilder( + column: $table.scriptId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lineIndex => $composableBuilder( + column: $table.lineIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get noteText => $composableBuilder( + column: $table.noteText, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$LineNotesTableTableOrderingComposer + extends Composer<_$AppDatabase, $LineNotesTableTable> { + $$LineNotesTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scriptId => $composableBuilder( + column: $table.scriptId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lineIndex => $composableBuilder( + column: $table.lineIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get noteText => $composableBuilder( + column: $table.noteText, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$LineNotesTableTableAnnotationComposer + extends Composer<_$AppDatabase, $LineNotesTableTable> { + $$LineNotesTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get scriptId => + $composableBuilder(column: $table.scriptId, builder: (column) => column); + + GeneratedColumn get lineIndex => + $composableBuilder(column: $table.lineIndex, builder: (column) => column); + + GeneratedColumn get category => + $composableBuilder(column: $table.category, builder: (column) => column); + + GeneratedColumn get noteText => + $composableBuilder(column: $table.noteText, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$LineNotesTableTableTableManager + extends + RootTableManager< + _$AppDatabase, + $LineNotesTableTable, + LineNotesTableData, + $$LineNotesTableTableFilterComposer, + $$LineNotesTableTableOrderingComposer, + $$LineNotesTableTableAnnotationComposer, + $$LineNotesTableTableCreateCompanionBuilder, + $$LineNotesTableTableUpdateCompanionBuilder, + ( + LineNotesTableData, + BaseReferences< + _$AppDatabase, + $LineNotesTableTable, + LineNotesTableData + >, + ), + LineNotesTableData, + PrefetchHooks Function() + > { + $$LineNotesTableTableTableManager( + _$AppDatabase db, + $LineNotesTableTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$LineNotesTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$LineNotesTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$LineNotesTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value scriptId = const Value.absent(), + Value lineIndex = const Value.absent(), + Value category = const Value.absent(), + Value noteText = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => LineNotesTableCompanion( + id: id, + scriptId: scriptId, + lineIndex: lineIndex, + category: category, + noteText: noteText, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String scriptId, + required int lineIndex, + required String category, + required String noteText, + required DateTime createdAt, + Value rowid = const Value.absent(), + }) => LineNotesTableCompanion.insert( + id: id, + scriptId: scriptId, + lineIndex: lineIndex, + category: category, + noteText: noteText, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$LineNotesTableTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $LineNotesTableTable, + LineNotesTableData, + $$LineNotesTableTableFilterComposer, + $$LineNotesTableTableOrderingComposer, + $$LineNotesTableTableAnnotationComposer, + $$LineNotesTableTableCreateCompanionBuilder, + $$LineNotesTableTableUpdateCompanionBuilder, + ( + LineNotesTableData, + BaseReferences<_$AppDatabase, $LineNotesTableTable, LineNotesTableData>, + ), + LineNotesTableData, + PrefetchHooks Function() + >; +typedef $$AnnotationSnapshotsTableTableCreateCompanionBuilder = + AnnotationSnapshotsTableCompanion Function({ + required String id, + required String scriptId, + required DateTime timestamp, + required String snapshotJson, + Value rowid, + }); +typedef $$AnnotationSnapshotsTableTableUpdateCompanionBuilder = + AnnotationSnapshotsTableCompanion Function({ + Value id, + Value scriptId, + Value timestamp, + Value snapshotJson, + Value rowid, + }); + +class $$AnnotationSnapshotsTableTableFilterComposer + extends Composer<_$AppDatabase, $AnnotationSnapshotsTableTable> { + $$AnnotationSnapshotsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scriptId => $composableBuilder( + column: $table.scriptId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get snapshotJson => $composableBuilder( + column: $table.snapshotJson, + builder: (column) => ColumnFilters(column), + ); +} + +class $$AnnotationSnapshotsTableTableOrderingComposer + extends Composer<_$AppDatabase, $AnnotationSnapshotsTableTable> { + $$AnnotationSnapshotsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scriptId => $composableBuilder( + column: $table.scriptId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get snapshotJson => $composableBuilder( + column: $table.snapshotJson, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$AnnotationSnapshotsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $AnnotationSnapshotsTableTable> { + $$AnnotationSnapshotsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get scriptId => + $composableBuilder(column: $table.scriptId, builder: (column) => column); + + GeneratedColumn get timestamp => + $composableBuilder(column: $table.timestamp, builder: (column) => column); + + GeneratedColumn get snapshotJson => $composableBuilder( + column: $table.snapshotJson, + builder: (column) => column, + ); +} + +class $$AnnotationSnapshotsTableTableTableManager + extends + RootTableManager< + _$AppDatabase, + $AnnotationSnapshotsTableTable, + AnnotationSnapshotsTableData, + $$AnnotationSnapshotsTableTableFilterComposer, + $$AnnotationSnapshotsTableTableOrderingComposer, + $$AnnotationSnapshotsTableTableAnnotationComposer, + $$AnnotationSnapshotsTableTableCreateCompanionBuilder, + $$AnnotationSnapshotsTableTableUpdateCompanionBuilder, + ( + AnnotationSnapshotsTableData, + BaseReferences< + _$AppDatabase, + $AnnotationSnapshotsTableTable, + AnnotationSnapshotsTableData + >, + ), + AnnotationSnapshotsTableData, + PrefetchHooks Function() + > { + $$AnnotationSnapshotsTableTableTableManager( + _$AppDatabase db, + $AnnotationSnapshotsTableTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$AnnotationSnapshotsTableTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$AnnotationSnapshotsTableTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$AnnotationSnapshotsTableTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value scriptId = const Value.absent(), + Value timestamp = const Value.absent(), + Value snapshotJson = const Value.absent(), + Value rowid = const Value.absent(), + }) => AnnotationSnapshotsTableCompanion( + id: id, + scriptId: scriptId, + timestamp: timestamp, + snapshotJson: snapshotJson, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String scriptId, + required DateTime timestamp, + required String snapshotJson, + Value rowid = const Value.absent(), + }) => AnnotationSnapshotsTableCompanion.insert( + id: id, + scriptId: scriptId, + timestamp: timestamp, + snapshotJson: snapshotJson, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$AnnotationSnapshotsTableTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $AnnotationSnapshotsTableTable, + AnnotationSnapshotsTableData, + $$AnnotationSnapshotsTableTableFilterComposer, + $$AnnotationSnapshotsTableTableOrderingComposer, + $$AnnotationSnapshotsTableTableAnnotationComposer, + $$AnnotationSnapshotsTableTableCreateCompanionBuilder, + $$AnnotationSnapshotsTableTableUpdateCompanionBuilder, + ( + AnnotationSnapshotsTableData, + BaseReferences< + _$AppDatabase, + $AnnotationSnapshotsTableTable, + AnnotationSnapshotsTableData + >, + ), + AnnotationSnapshotsTableData, + PrefetchHooks Function() + >; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$TextMarksTableTableTableManager get textMarksTable => + $$TextMarksTableTableTableManager(_db, _db.textMarksTable); + $$LineNotesTableTableTableManager get lineNotesTable => + $$LineNotesTableTableTableManager(_db, _db.lineNotesTable); + $$AnnotationSnapshotsTableTableTableManager get annotationSnapshotsTable => + $$AnnotationSnapshotsTableTableTableManager( + _db, + _db.annotationSnapshotsTable, + ); +} diff --git a/horatio/horatio_app/lib/database/daos/annotation_dao.dart b/horatio/horatio_app/lib/database/daos/annotation_dao.dart new file mode 100644 index 0000000..cfc0dcd --- /dev/null +++ b/horatio/horatio_app/lib/database/daos/annotation_dao.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:horatio_app/database/tables/annotation_snapshots_table.dart'; +import 'package:horatio_app/database/tables/line_notes_table.dart'; +import 'package:horatio_app/database/tables/text_marks_table.dart'; +import 'package:horatio_core/horatio_core.dart'; + +part 'annotation_dao.g.dart'; + +/// Data access object for annotation persistence. +/// +/// [TextMark] and [LineNote] models do not carry a [scriptId] field — +/// the DAO binds it at the persistence boundary. +@DriftAccessor( + tables: [TextMarksTable, LineNotesTable, AnnotationSnapshotsTable], +) +class AnnotationDao extends DatabaseAccessor + with _$AnnotationDaoMixin { + /// Creates an [AnnotationDao]. + AnnotationDao(super.db); + + // -- TextMark CRUD -------------------------------------------------------- + + /// Watches all marks for a script. + Stream> watchMarksForScript(String scriptId) => + (select(textMarksTable) + ..where((t) => t.scriptId.equals(scriptId)) + ..orderBy([(t) => OrderingTerm.asc(t.lineIndex)])) + .watch() + .map((rows) => rows.map(_rowToMark).toList()); + + /// Gets marks for a specific line. + Future> getMarksForLine( + String scriptId, + int lineIndex, + ) async { + final rows = await (select(textMarksTable) + ..where( + (t) => + t.scriptId.equals(scriptId) & + t.lineIndex.equals(lineIndex), + )) + .get(); + return rows.map(_rowToMark).toList(); + } + + /// Inserts a text mark. + Future insertMark(String scriptId, TextMark mark) => into( + textMarksTable, + ).insert( + TextMarksTableCompanion.insert( + id: mark.id, + scriptId: scriptId, + lineIndex: mark.lineIndex, + startOffset: mark.startOffset, + endOffset: mark.endOffset, + markType: mark.type.name, + createdAt: mark.createdAt, + ), + ); + + /// Deletes a text mark by ID. + Future deleteMark(String id) => + (delete(textMarksTable)..where((t) => t.id.equals(id))).go(); + + TextMark _rowToMark(TextMarksTableData row) => TextMark( + id: row.id, + lineIndex: row.lineIndex, + startOffset: row.startOffset, + endOffset: row.endOffset, + type: MarkType.values.byName(row.markType), + createdAt: row.createdAt, + ); + + // -- LineNote CRUD -------------------------------------------------------- + + /// Watches all notes for a script. + Stream> watchNotesForScript(String scriptId) => + (select(lineNotesTable) + ..where((t) => t.scriptId.equals(scriptId)) + ..orderBy([(t) => OrderingTerm.asc(t.lineIndex)])) + .watch() + .map((rows) => rows.map(_rowToNote).toList()); + + /// Gets notes for a specific line. + Future> getNotesForLine( + String scriptId, + int lineIndex, + ) async { + final rows = await (select(lineNotesTable) + ..where( + (t) => + t.scriptId.equals(scriptId) & + t.lineIndex.equals(lineIndex), + )) + .get(); + return rows.map(_rowToNote).toList(); + } + + /// Inserts a line note. + Future insertNote(String scriptId, LineNote note) => into( + lineNotesTable, + ).insert( + LineNotesTableCompanion.insert( + id: note.id, + scriptId: scriptId, + lineIndex: note.lineIndex, + category: note.category.name, + noteText: note.text, + createdAt: note.createdAt, + ), + ); + + /// Updates the text of a note. + Future updateNoteText(String id, String text) => + (update(lineNotesTable)..where((t) => t.id.equals(id))) + .write(LineNotesTableCompanion(noteText: Value(text))); + + /// Deletes a note by ID. + Future deleteNote(String id) => + (delete(lineNotesTable)..where((t) => t.id.equals(id))).go(); + + LineNote _rowToNote(LineNotesTableData row) => LineNote( + id: row.id, + lineIndex: row.lineIndex, + category: NoteCategory.values.byName(row.category), + text: row.noteText, + createdAt: row.createdAt, + ); + + // -- Snapshot management -------------------------------------------------- + + /// Watches all snapshots for a script, newest first. + Stream> watchSnapshotsForScript( + String scriptId, + ) => + (select(annotationSnapshotsTable) + ..where((t) => t.scriptId.equals(scriptId)) + ..orderBy([(t) => OrderingTerm.desc(t.timestamp)])) + .watch() + .map((rows) => rows.map(_rowToSnapshot).toList()); + + /// Inserts a snapshot. + Future insertSnapshot(AnnotationSnapshot snapshot) => into( + annotationSnapshotsTable, + ).insert( + AnnotationSnapshotsTableCompanion.insert( + id: snapshot.id, + scriptId: snapshot.scriptId, + timestamp: snapshot.timestamp, + snapshotJson: json.encode(snapshot.toJson()), + ), + ); + + AnnotationSnapshot _rowToSnapshot(AnnotationSnapshotsTableData row) => + AnnotationSnapshot.fromJson( + json.decode(row.snapshotJson) as Map, + ); + // Note: scriptId and timestamp exist in both the table columns (for + // efficient WHERE/ORDER BY filtering) AND in the JSON blob (for complete + // deserialization). The columns are the source of truth for queries; + // the JSON is the source of truth for the full snapshot data. + + // -- Bulk operations (for snapshot restore) ------------------------------- + + /// Deletes ALL marks and notes for a script, then inserts the given ones. + /// Used by snapshot restore. + Future replaceAllAnnotations({ + required String scriptId, + required List marks, + required List notes, + }) => transaction(() async { + await (delete(textMarksTable) + ..where((t) => t.scriptId.equals(scriptId))) + .go(); + await (delete(lineNotesTable) + ..where((t) => t.scriptId.equals(scriptId))) + .go(); + for (final mark in marks) { + await insertMark(scriptId, mark); + } + for (final note in notes) { + await insertNote(scriptId, note); + } + }); +} diff --git a/horatio/horatio_app/lib/database/daos/annotation_dao.g.dart b/horatio/horatio_app/lib/database/daos/annotation_dao.g.dart new file mode 100644 index 0000000..e6819c1 --- /dev/null +++ b/horatio/horatio_app/lib/database/daos/annotation_dao.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'annotation_dao.dart'; + +// ignore_for_file: type=lint +mixin _$AnnotationDaoMixin on DatabaseAccessor { + $TextMarksTableTable get textMarksTable => attachedDatabase.textMarksTable; + $LineNotesTableTable get lineNotesTable => attachedDatabase.lineNotesTable; + $AnnotationSnapshotsTableTable get annotationSnapshotsTable => + attachedDatabase.annotationSnapshotsTable; + AnnotationDaoManager get managers => AnnotationDaoManager(this); +} + +class AnnotationDaoManager { + final _$AnnotationDaoMixin _db; + AnnotationDaoManager(this._db); + $$TextMarksTableTableTableManager get textMarksTable => + $$TextMarksTableTableTableManager( + _db.attachedDatabase, + _db.textMarksTable, + ); + $$LineNotesTableTableTableManager get lineNotesTable => + $$LineNotesTableTableTableManager( + _db.attachedDatabase, + _db.lineNotesTable, + ); + $$AnnotationSnapshotsTableTableTableManager get annotationSnapshotsTable => + $$AnnotationSnapshotsTableTableTableManager( + _db.attachedDatabase, + _db.annotationSnapshotsTable, + ); +} diff --git a/horatio/horatio_app/lib/database/tables/annotation_snapshots_table.dart b/horatio/horatio_app/lib/database/tables/annotation_snapshots_table.dart new file mode 100644 index 0000000..87bfb26 --- /dev/null +++ b/horatio/horatio_app/lib/database/tables/annotation_snapshots_table.dart @@ -0,0 +1,15 @@ +import 'package:drift/drift.dart'; + +/// Drift table for annotation history snapshots. +class AnnotationSnapshotsTable extends Table { + @override + String get tableName => 'annotation_snapshots'; + + TextColumn get id => text()(); + TextColumn get scriptId => text()(); + DateTimeColumn get timestamp => dateTime()(); + TextColumn get snapshotJson => text()(); + + @override + Set get primaryKey => {id}; +} diff --git a/horatio/horatio_app/lib/database/tables/line_notes_table.dart b/horatio/horatio_app/lib/database/tables/line_notes_table.dart new file mode 100644 index 0000000..5d345f6 --- /dev/null +++ b/horatio/horatio_app/lib/database/tables/line_notes_table.dart @@ -0,0 +1,17 @@ +import 'package:drift/drift.dart'; + +/// Drift table for line-level interpretive notes. +class LineNotesTable extends Table { + @override + String get tableName => 'line_notes'; + + TextColumn get id => text()(); + TextColumn get scriptId => text()(); + IntColumn get lineIndex => integer()(); + TextColumn get category => text()(); + TextColumn get noteText => text()(); + DateTimeColumn get createdAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} diff --git a/horatio/horatio_app/lib/database/tables/text_marks_table.dart b/horatio/horatio_app/lib/database/tables/text_marks_table.dart new file mode 100644 index 0000000..8e5e602 --- /dev/null +++ b/horatio/horatio_app/lib/database/tables/text_marks_table.dart @@ -0,0 +1,18 @@ +import 'package:drift/drift.dart'; + +/// Drift table for text-level delivery marks on script lines. +class TextMarksTable extends Table { + @override + String get tableName => 'text_marks'; + + TextColumn get id => text()(); + TextColumn get scriptId => text()(); + IntColumn get lineIndex => integer()(); + IntColumn get startOffset => integer()(); + IntColumn get endOffset => integer()(); + TextColumn get markType => text()(); + DateTimeColumn get createdAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} diff --git a/horatio/horatio_app/lib/main.dart b/horatio/horatio_app/lib/main.dart index ace8d77..41f78c7 100644 --- a/horatio/horatio_app/lib/main.dart +++ b/horatio/horatio_app/lib/main.dart @@ -1,12 +1,23 @@ +import 'dart:io'; + import 'package:device_preview/device_preview.dart'; +import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:horatio_app/app.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; -void main() { +void main() async { WidgetsFlutterBinding.ensureInitialized(); + + final dbFolder = await getApplicationDocumentsDirectory(); + final dbFile = File(p.join(dbFolder.path, 'horatio.sqlite')); + final database = AppDatabase(NativeDatabase(dbFile)); + runApp( DevicePreview( - builder: (_) => const HoratioApp(), + builder: (_) => HoratioApp(database: database), ), ); } diff --git a/horatio/horatio_app/lib/router.dart b/horatio/horatio_app/lib/router.dart index 0a7c4b6..b9777c1 100644 --- a/horatio/horatio_app/lib/router.dart +++ b/horatio/horatio_app/lib/router.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:horatio_app/screens/annotation_editor_screen.dart'; +import 'package:horatio_app/screens/annotation_history_screen.dart'; import 'package:horatio_app/screens/home_screen.dart'; import 'package:horatio_app/screens/import_screen.dart'; import 'package:horatio_app/screens/rehearsal_screen.dart'; @@ -27,6 +29,12 @@ abstract final class RoutePaths { /// SRS flashcard review. static const String srsReview = '/srs-review'; + + /// Annotation editor. + static const String annotations = '/annotations'; + + /// Annotation history. + static const String annotationHistory = '/annotation-history'; } /// Application router configuration. @@ -90,6 +98,28 @@ final GoRouter appRouter = GoRouter( return const SizedBox.shrink(); }, ), + GoRoute( + path: RoutePaths.annotations, + redirect: (context, state) => + state.extra == null ? RoutePaths.home : null, + builder: (context, state) { + if (state.extra case final Script script) { + return AnnotationEditorScreen(script: script); + } + return const SizedBox.shrink(); + }, + ), + GoRoute( + path: RoutePaths.annotationHistory, + redirect: (context, state) => + state.extra == null ? RoutePaths.home : null, + builder: (context, state) { + if (state.extra case final Script script) { + return AnnotationHistoryScreen(script: script); + } + return const SizedBox.shrink(); + }, + ), ], errorBuilder: (context, state) => Scaffold( appBar: AppBar(title: const Text('Not Found')), diff --git a/horatio/horatio_app/lib/screens/annotation_editor_screen.dart b/horatio/horatio_app/lib/screens/annotation_editor_screen.dart new file mode 100644 index 0000000..07eac36 --- /dev/null +++ b/horatio/horatio_app/lib/screens/annotation_editor_screen.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:horatio_app/bloc/annotation/annotation_cubit.dart'; +import 'package:horatio_app/bloc/annotation/annotation_history_cubit.dart'; +import 'package:horatio_app/bloc/annotation/annotation_state.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_app/router.dart'; +import 'package:horatio_app/widgets/mark_overlay.dart'; +import 'package:horatio_app/widgets/mark_type_picker.dart'; +import 'package:horatio_app/widgets/note_editor_sheet.dart'; +import 'package:horatio_app/widgets/note_indicator.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// Screen for editing text marks and line notes on a script. +class AnnotationEditorScreen extends StatelessWidget { + /// Creates an [AnnotationEditorScreen]. + const AnnotationEditorScreen({required this.script, super.key}); + + /// The script to annotate. + final Script script; + + @override + Widget build(BuildContext context) { + final dao = context.read(); + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => + AnnotationCubit(dao: dao)..loadAnnotations(script.id), + ), + BlocProvider( + create: (_) => + AnnotationHistoryCubit(dao: dao)..loadSnapshots(script.id), + ), + ], + child: _AnnotationEditorBody(script: script), + ); + } +} + +class _AnnotationEditorBody extends StatelessWidget { + const _AnnotationEditorBody({required this.script}); + + final Script script; + + List get _allLines => + script.scenes.expand((s) => s.lines).toList(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text('Annotate: ${script.title}'), + actions: [ + IconButton( + icon: const Icon(Icons.history), + tooltip: 'History', + onPressed: () => + context.push(RoutePaths.annotationHistory, extra: script), + ), + ], + ), + floatingActionButton: + BlocBuilder( + builder: (context, state) { + if (state is! AnnotationLoaded) { + return const SizedBox.shrink(); + } + return FloatingActionButton( + onPressed: () => _saveSnapshot(context, state), + tooltip: 'Save Snapshot', + child: const Icon(Icons.save), + ); + }, + ), + body: BlocBuilder( + builder: (context, state) => switch (state) { + AnnotationInitial() => + const Center(child: CircularProgressIndicator()), + AnnotationLoaded() => _buildLineList(context, state), + }, + ), + ); + + Widget _buildLineList(BuildContext context, AnnotationLoaded state) { + final lines = _allLines; + return ListView.builder( + itemCount: lines.length, + itemBuilder: (context, index) { + final line = lines[index]; + final lineMarks = + state.marks.where((m) => m.lineIndex == index).toList(); + final lineNotes = + state.notes.where((n) => n.lineIndex == index).toList(); + final isSelected = state.selectedLineIndex == index; + return _LineTile( + line: line, + lineIndex: index, + marks: lineMarks, + notes: lineNotes, + isSelected: isSelected, + ); + }, + ); + } + + void _saveSnapshot(BuildContext context, AnnotationLoaded state) { + context.read().saveSnapshot( + marks: state.marks, + notes: state.notes, + ); + } +} + +class _LineTile extends StatelessWidget { + const _LineTile({ + required this.line, + required this.lineIndex, + required this.marks, + required this.notes, + required this.isSelected, + }); + + final ScriptLine line; + final int lineIndex; + final List marks; + final List notes; + final bool isSelected; + + @override + Widget build(BuildContext context) => Container( + color: isSelected + ? Theme.of(context).colorScheme.primaryContainer.withValues( + alpha: 0.3, + ) + : null, + child: InkWell( + onTap: () => + context.read().selectLine(lineIndex), + onLongPress: () => _showMarkPicker(context), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: MarkOverlay(text: line.text, marks: marks), + ), + NoteIndicator( + noteCount: notes.length, + onTap: () => _showNoteEditor(context), + ), + ], + ), + ), + ), + ); + + void _showMarkPicker(BuildContext context) { + final cubit = context.read(); + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Add Mark'), + content: MarkTypePicker( + onSelected: (type) { + cubit.addMark( + lineIndex: lineIndex, + startOffset: 0, + endOffset: line.text.length, + type: type, + ); + Navigator.pop(context); + }, + onCancelled: () => Navigator.pop(context), + ), + ), + ); + } + + void _showNoteEditor(BuildContext context) { + final cubit = context.read(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: NoteEditorSheet( + onSave: (category, text) { + cubit.addNote( + lineIndex: lineIndex, + category: category, + text: text, + ); + Navigator.pop(context); + }, + onCancel: () => Navigator.pop(context), + ), + ), + ); + } +} diff --git a/horatio/horatio_app/lib/screens/annotation_history_screen.dart b/horatio/horatio_app/lib/screens/annotation_history_screen.dart new file mode 100644 index 0000000..eca9190 --- /dev/null +++ b/horatio/horatio_app/lib/screens/annotation_history_screen.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/annotation/annotation_history_cubit.dart'; +import 'package:horatio_app/bloc/annotation/annotation_history_state.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:intl/intl.dart'; + +/// Screen for browsing and restoring annotation snapshots. +class AnnotationHistoryScreen extends StatelessWidget { + /// Creates an [AnnotationHistoryScreen]. + const AnnotationHistoryScreen({required this.script, super.key}); + + /// The script whose history to browse. + final Script script; + + @override + Widget build(BuildContext context) => BlocProvider( + create: (_) => + AnnotationHistoryCubit(dao: context.read()) + ..loadSnapshots(script.id), + child: _AnnotationHistoryBody(script: script), + ); +} + +class _AnnotationHistoryBody extends StatelessWidget { + const _AnnotationHistoryBody({required this.script}); + + final Script script; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text('History: ${script.title}')), + body: BlocBuilder( + builder: (context, state) => switch (state) { + AnnotationHistoryInitial() => + const Center(child: CircularProgressIndicator()), + AnnotationHistoryLoaded(snapshots: final snapshots) => + snapshots.isEmpty + ? const Center(child: Text('No history yet')) + : ListView.builder( + itemCount: snapshots.length, + itemBuilder: (context, index) => _SnapshotCard( + snapshot: snapshots[index], + ), + ), + }, + ), + ); +} + +class _SnapshotCard extends StatelessWidget { + const _SnapshotCard({required this.snapshot}); + + final AnnotationSnapshot snapshot; + + @override + Widget build(BuildContext context) { + final formatted = + DateFormat.yMMMd().add_Hm().format(snapshot.timestamp.toLocal()); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + title: Text(formatted), + subtitle: Text( + '${snapshot.marks.length} marks · ' + '${snapshot.notes.length} notes', + ), + trailing: TextButton( + onPressed: () => _confirmRestore(context), + child: const Text('Restore'), + ), + ), + ); + } + + void _confirmRestore(BuildContext context) { + final cubit = context.read(); + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Restore Snapshot?'), + content: const Text( + 'This will replace all current annotations with the snapshot.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + cubit.restoreSnapshot(snapshot); + Navigator.pop(context); + }, + child: const Text('Restore'), + ), + ], + ), + ); + } +} diff --git a/horatio/horatio_app/lib/screens/role_selection_screen.dart b/horatio/horatio_app/lib/screens/role_selection_screen.dart index d074214..d75543a 100644 --- a/horatio/horatio_app/lib/screens/role_selection_screen.dart +++ b/horatio/horatio_app/lib/screens/role_selection_screen.dart @@ -99,6 +99,15 @@ class RoleSelectionScreen extends StatelessWidget { ); }, ), + ListTile( + leading: const Icon(Icons.edit_note), + title: const Text('Annotate Script'), + subtitle: const Text('Add delivery marks and notes'), + onTap: () { + Navigator.pop(context); + context.push(RoutePaths.annotations, extra: script); + }, + ), ], ), ), diff --git a/horatio/horatio_app/lib/widgets/mark_overlay.dart b/horatio/horatio_app/lib/widgets/mark_overlay.dart new file mode 100644 index 0000000..e784a86 --- /dev/null +++ b/horatio/horatio_app/lib/widgets/mark_overlay.dart @@ -0,0 +1,87 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// Color map for each [MarkType]. +const Map markColors = { + MarkType.stress: Color.fromRGBO(244, 67, 54, 0.3), + MarkType.pause: Color.fromRGBO(33, 150, 243, 0.3), + MarkType.breath: Color.fromRGBO(76, 175, 80, 0.3), + MarkType.emphasis: Color.fromRGBO(255, 152, 0, 0.3), + MarkType.slowDown: Color.fromRGBO(156, 39, 176, 0.3), + MarkType.speedUp: Color.fromRGBO(0, 150, 136, 0.3), +}; + +/// Renders text with colored highlight spans for [TextMark] overlays. +class MarkOverlay extends StatelessWidget { + /// Creates a [MarkOverlay]. + const MarkOverlay({ + required this.text, + required this.marks, + this.style, + super.key, + }); + + /// The full line text. + final String text; + + /// Marks to overlay on the text. + final List marks; + + /// Base text style. + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final defaultStyle = + style ?? DefaultTextStyle.of(context).style; + if (marks.isEmpty) { + return RichText(text: TextSpan(text: text, style: defaultStyle)); + } + return RichText( + text: TextSpan(style: defaultStyle, children: _buildSpans()), + ); + } + + List _buildSpans() { + // Collect boundary events, clamped to valid text range. + final length = text.length; + final events = <({int offset, bool isStart, MarkType type})>[]; + for (final mark in marks) { + final start = mark.startOffset.clamp(0, length); + final end = mark.endOffset.clamp(0, length); + if (start >= end) continue; + events + ..add((offset: start, isStart: true, type: mark.type)) + ..add((offset: end, isStart: false, type: mark.type)); + } + events.sort((a, b) => a.offset.compareTo(b.offset)); + + final spans = []; + var cursor = 0; + final activeTypes = []; + + for (final event in events) { + final pos = min(event.offset, length); + if (pos > cursor) { + spans.add(TextSpan( + text: text.substring(cursor, pos), + style: activeTypes.isEmpty + ? null + : TextStyle(backgroundColor: markColors[activeTypes.last]), + )); + cursor = pos; + } + if (event.isStart) { + activeTypes.add(event.type); + } else { + activeTypes.remove(event.type); + } + } + if (cursor < length) { + spans.add(TextSpan(text: text.substring(cursor))); + } + return spans; + } +} diff --git a/horatio/horatio_app/lib/widgets/mark_type_picker.dart b/horatio/horatio_app/lib/widgets/mark_type_picker.dart new file mode 100644 index 0000000..83470d5 --- /dev/null +++ b/horatio/horatio_app/lib/widgets/mark_type_picker.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:horatio_app/widgets/mark_overlay.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// User-facing label for each [MarkType]. +String markTypeLabel(MarkType type) => switch (type) { + MarkType.stress => 'Stress', + MarkType.pause => 'Pause', + MarkType.breath => 'Breath', + MarkType.emphasis => 'Emphasis', + MarkType.slowDown => 'Slow Down', + MarkType.speedUp => 'Speed Up', + }; + +/// A picker displaying all [MarkType] options as colored chips. +class MarkTypePicker extends StatelessWidget { + /// Creates a [MarkTypePicker]. + const MarkTypePicker({ + required this.onSelected, + required this.onCancelled, + super.key, + }); + + /// Called when a mark type is tapped. + final ValueChanged onSelected; + + /// Called when the picker is dismissed. + final VoidCallback onCancelled; + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: MarkType.values.map((type) { + final color = markColors[type]!; + return ActionChip( + label: Text(markTypeLabel(type)), + backgroundColor: color, + onPressed: () => onSelected(type), + ); + }).toList(), + ), + const SizedBox(height: 16), + TextButton( + onPressed: onCancelled, + child: const Text('Cancel'), + ), + ], + ); +} diff --git a/horatio/horatio_app/lib/widgets/note_editor_sheet.dart b/horatio/horatio_app/lib/widgets/note_editor_sheet.dart new file mode 100644 index 0000000..5c94592 --- /dev/null +++ b/horatio/horatio_app/lib/widgets/note_editor_sheet.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// User-facing label for each [NoteCategory]. +String noteCategoryLabel(NoteCategory category) => switch (category) { + NoteCategory.intention => 'Intention', + NoteCategory.subtext => 'Subtext', + NoteCategory.blocking => 'Blocking', + NoteCategory.emotion => 'Emotion', + NoteCategory.delivery => 'Delivery', + NoteCategory.general => 'General', + }; + +/// A bottom-sheet widget for creating or editing a [LineNote]. +class NoteEditorSheet extends StatefulWidget { + /// Creates a [NoteEditorSheet]. + const NoteEditorSheet({ + required this.onSave, + required this.onCancel, + this.initialCategory, + this.initialText, + super.key, + }); + + /// Called with the chosen category and text on save. + final void Function(NoteCategory category, String text) onSave; + + /// Called when the user cancels editing. + final VoidCallback onCancel; + + /// Pre-selected category when editing an existing note. + final NoteCategory? initialCategory; + + /// Pre-filled text when editing an existing note. + final String? initialText; + + @override + State createState() => _NoteEditorSheetState(); +} + +class _NoteEditorSheetState extends State { + late NoteCategory _category; + late TextEditingController _textController; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _category = widget.initialCategory ?? NoteCategory.general; + _textController = TextEditingController(text: widget.initialText ?? ''); + } + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: _category, + decoration: const InputDecoration(labelText: 'Category'), + items: NoteCategory.values + .map( + (c) => DropdownMenuItem( + value: c, + child: Text(noteCategoryLabel(c)), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + setState(() => _category = value); + } + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _textController, + decoration: const InputDecoration( + labelText: 'Note', + hintText: 'Enter your note...', + ), + maxLines: 3, + validator: (value) => + value == null || value.trim().isEmpty ? 'Note cannot be empty' : null, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: widget.onCancel, + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _submit, + child: const Text('Save'), + ), + ], + ), + ], + ), + ), + ); + + void _submit() { + if (_formKey.currentState!.validate()) { + widget.onSave(_category, _textController.text.trim()); + } + } +} diff --git a/horatio/horatio_app/lib/widgets/note_indicator.dart b/horatio/horatio_app/lib/widgets/note_indicator.dart new file mode 100644 index 0000000..1bbffc3 --- /dev/null +++ b/horatio/horatio_app/lib/widgets/note_indicator.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +/// A small tappable badge showing the note count for a script line. +class NoteIndicator extends StatelessWidget { + /// Creates a [NoteIndicator]. + const NoteIndicator({ + required this.noteCount, + required this.onTap, + super.key, + }); + + /// Number of notes on the line. + final int noteCount; + + /// Callback when tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + if (noteCount == 0) { + return const SizedBox.shrink(); + } + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$noteCount', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ); + } +} diff --git a/horatio/horatio_app/pubspec.lock b/horatio/horatio_app/pubspec.lock index 293d5f5..ddd0878 100644 --- a/horatio/horatio_app/pubspec.lock +++ b/horatio/horatio_app/pubspec.lock @@ -65,6 +65,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c + url: "https://pub.dev" + source: hosted + version: "4.0.5" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" + url: "https://pub.dev" + source: hosted + version: "2.13.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" + url: "https://pub.dev" + source: hosted + version: "8.12.5" characters: dependency: transitive description: @@ -73,6 +121,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" cli_config: dependency: transitive description: @@ -81,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -97,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -137,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + url: "https://pub.dev" + source: hosted + version: "3.1.7" dbus: dependency: transitive description: @@ -185,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.32.1" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "88a9de3af8571518148a6d8a513b57779fd1e60a026d3ab8a481a878fba01d91" + url: "https://pub.dev" + source: hosted + version: "2.32.1" equatable: dependency: "direct main" description: @@ -317,6 +413,14 @@ packages: url: "https://pub.dev" source: hosted version: "17.1.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" hooks: dependency: transitive description: @@ -612,6 +716,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" record: dependency: "direct main" description: @@ -769,6 +889,14 @@ packages: description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd" + url: "https://pub.dev" + source: hosted + version: "4.2.2" source_map_stack_trace: dependency: transitive description: @@ -833,6 +961,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0+eol" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: ab2b467425f1d4f3acfa5fd11a08226f7d6c26ff102c06be1807e1dff34e050b + url: "https://pub.dev" + source: hosted + version: "0.44.3" stack_trace: dependency: transitive description: @@ -849,6 +985,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -906,7 +1050,7 @@ packages: source: hosted version: "1.1.0" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" diff --git a/horatio/horatio_app/pubspec.yaml b/horatio/horatio_app/pubspec.yaml index 47bfffa..8a2417b 100644 --- a/horatio/horatio_app/pubspec.yaml +++ b/horatio/horatio_app/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: device_preview: ^1.3.1 drift: ^2.22.0 sqlite3_flutter_libs: ^0.6.0+eol + uuid: ^4.5.1 path_provider: ^2.1.0 path: ^1.9.0 intl: ^0.20.2 @@ -35,6 +36,8 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + build_runner: ^2.4.0 + drift_dev: ^2.22.0 bloc_test: ^10.0.0 mocktail: ^1.0.0 plugin_platform_interface: any diff --git a/horatio/horatio_app/test/app_test.dart b/horatio/horatio_app/test/app_test.dart index b5e73b1..3b0ddeb 100644 --- a/horatio/horatio_app/test/app_test.dart +++ b/horatio/horatio_app/test/app_test.dart @@ -5,26 +5,56 @@ import 'package:horatio_app/app.dart'; import 'package:horatio_app/router.dart'; import 'package:horatio_core/horatio_core.dart'; +import 'helpers/test_database.dart'; + void main() { testWidgets('HoratioApp builds without crashing', (tester) async { - await tester.pumpWidget(const HoratioApp()); + await tester.pumpWidget(HoratioApp(database: createTestDatabase())); await tester.pumpAndSettle(); - - // The app should render the home screen. expect(find.text('Horatio'), findsOneWidget); }); testWidgets('SrsReviewCubit is created when srs-review route is visited', (tester) async { - await tester.pumpWidget(const HoratioApp()); + await tester.pumpWidget(HoratioApp(database: createTestDatabase())); await tester.pumpAndSettle(); unawaited(appRouter.push(RoutePaths.srsReview, extra: [ SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans'), ])); await tester.pumpAndSettle(); - - // SrsReviewScreen renders — the BlocProvider.create ran. expect(find.text('No review session active.'), findsOneWidget); }); + + testWidgets('AnnotationDao is provided when annotation route is visited', + (tester) async { + final db = createTestDatabase(); + await tester.pumpWidget(HoratioApp(database: db)); + await tester.pumpAndSettle(); + + const role = Role(name: 'Hero'); + const script = Script( + id: 'app-ann-id', + title: 'Ann Test', + roles: [role], + scenes: [ + Scene( + lines: [ + ScriptLine( + text: 'Hello.', + role: role, + sceneIndex: 0, + lineIndex: 0, + ), + ], + ), + ], + ); + unawaited(appRouter.push(RoutePaths.annotations, extra: script)); + await tester.pumpAndSettle(); + expect(find.text('Annotate: Ann Test'), findsOneWidget); + + // Close the database before teardown to cancel Drift stream timers. + await db.close(); + }); } diff --git a/horatio/horatio_app/test/bloc/annotation_cubit_test.dart b/horatio/horatio_app/test/bloc/annotation_cubit_test.dart new file mode 100644 index 0000000..5c5d18f --- /dev/null +++ b/horatio/horatio_app/test/bloc/annotation_cubit_test.dart @@ -0,0 +1,275 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/bloc/annotation/annotation_cubit.dart'; +import 'package:horatio_app/bloc/annotation/annotation_state.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAnnotationDao extends Mock implements AnnotationDao {} + +void main() { + late MockAnnotationDao dao; + late StreamController> marksController; + late StreamController> notesController; + + const scriptId = 'script-1'; + + final testMark = TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ); + + final testNote = LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'test', + createdAt: DateTime.utc(2026), + ); + + setUp(() { + dao = MockAnnotationDao(); + marksController = StreamController>.broadcast(); + notesController = StreamController>.broadcast(); + + when(() => dao.watchMarksForScript(scriptId)) + .thenAnswer((_) => marksController.stream); + when(() => dao.watchNotesForScript(scriptId)) + .thenAnswer((_) => notesController.stream); + }); + + tearDown(() { + marksController.close(); + notesController.close(); + }); + + setUpAll(() { + registerFallbackValue(testMark); + registerFallbackValue(testNote); + }); + + group('AnnotationCubit', () { + test('initial state is AnnotationInitial', () { + final cubit = AnnotationCubit(dao: dao); + expect(cubit.state, isA()); + cubit.close(); + }); + + test('loadAnnotations subscribes and emits on marks stream', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([testMark]); + await Future.delayed(Duration.zero); + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationLoaded).marks, [testMark]); + await cubit.close(); + }); + + test('loadAnnotations subscribes and emits on notes stream', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + notesController.add([testNote]); + await Future.delayed(Duration.zero); + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationLoaded).notes, [testNote]); + await cubit.close(); + }); + + test('loadAnnotations double-emits on both streams', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([testMark]); + notesController.add([testNote]); + await Future.delayed(Duration.zero); + final state = cubit.state as AnnotationLoaded; + expect(state.marks, [testMark]); + expect(state.notes, [testNote]); + await cubit.close(); + }); + + test('selectLine updates selectedLineIndex', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([]); + await Future.delayed(Duration.zero); + cubit.selectLine(3); + expect((cubit.state as AnnotationLoaded).selectedLineIndex, 3); + await cubit.close(); + }); + + test('selectLine is no-op when state is AnnotationInitial', () { + final cubit = AnnotationCubit(dao: dao); + cubit.selectLine(3); // Should not throw + expect(cubit.state, isA()); + cubit.close(); + }); + + test('startEditing / cancelEditing toggle EditingContext', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([]); + await Future.delayed(Duration.zero); + + cubit.startEditing(lineIndex: 2, isAddingMark: true); + final editing = (cubit.state as AnnotationLoaded).editing; + expect(editing, isNotNull); + expect(editing!.lineIndex, 2); + expect(editing.isAddingMark, isTrue); + + cubit.cancelEditing(); + expect((cubit.state as AnnotationLoaded).editing, isNull); + await cubit.close(); + }); + + test('EditingContext equality', () { + const a = EditingContext(lineIndex: 1, isAddingMark: true); + const b = EditingContext(lineIndex: 1, isAddingMark: true); + const c = EditingContext(lineIndex: 2, isAddingMark: false); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('startEditing is no-op when state is AnnotationInitial', () { + final cubit = AnnotationCubit(dao: dao); + cubit.startEditing(lineIndex: 0, isAddingMark: true); + expect(cubit.state, isA()); + cubit.close(); + }); + + test('cancelEditing is no-op when state is AnnotationInitial', () { + final cubit = AnnotationCubit(dao: dao); + cubit.cancelEditing(); + expect(cubit.state, isA()); + cubit.close(); + }); + + test('selectedLineIndex preserved across stream updates', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([]); + await Future.delayed(Duration.zero); + cubit.selectLine(5); + marksController.add([testMark]); // stream update + await Future.delayed(Duration.zero); + expect((cubit.state as AnnotationLoaded).selectedLineIndex, 5); + await cubit.close(); + }); + + test('addMark calls dao.insertMark', () async { + when(() => dao.insertMark(any(), any())).thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([]); + await Future.delayed(Duration.zero); + + await cubit.addMark( + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + ); + verify(() => dao.insertMark(scriptId, any())).called(1); + await cubit.close(); + }); + + test('addMark is no-op when scriptId is null', () async { + final cubit = AnnotationCubit(dao: dao); + await cubit.addMark( + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + ); + verifyNever(() => dao.insertMark(any(), any())); + await cubit.close(); + }); + + test('removeMark calls dao.deleteMark', () async { + when(() => dao.deleteMark('m1')).thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.removeMark('m1'); + verify(() => dao.deleteMark('m1')).called(1); + await cubit.close(); + }); + + test('addNote calls dao.insertNote', () async { + when(() => dao.insertNote(any(), any())).thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([]); + await Future.delayed(Duration.zero); + + await cubit.addNote( + lineIndex: 0, + category: NoteCategory.intention, + text: 'test note', + ); + verify(() => dao.insertNote(scriptId, any())).called(1); + await cubit.close(); + }); + + test('addNote is no-op when scriptId is null', () async { + final cubit = AnnotationCubit(dao: dao); + await cubit.addNote( + lineIndex: 0, + category: NoteCategory.intention, + text: 'test', + ); + verifyNever(() => dao.insertNote(any(), any())); + await cubit.close(); + }); + + test('updateNote calls dao.updateNoteText', () async { + when(() => dao.updateNoteText('n1', 'new')) + .thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.updateNote('n1', 'new'); + verify(() => dao.updateNoteText('n1', 'new')).called(1); + await cubit.close(); + }); + + test('removeNote calls dao.deleteNote', () async { + when(() => dao.deleteNote('n1')).thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.removeNote('n1'); + verify(() => dao.deleteNote('n1')).called(1); + await cubit.close(); + }); + + test('loadAnnotations with new scriptId cancels previous streams', + () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([testMark]); + await Future.delayed(Duration.zero); + + final marks2 = StreamController>.broadcast(); + final notes2 = StreamController>.broadcast(); + when(() => dao.watchMarksForScript('script-2')) + .thenAnswer((_) => marks2.stream); + when(() => dao.watchNotesForScript('script-2')) + .thenAnswer((_) => notes2.stream); + + cubit.loadAnnotations('script-2'); + marks2.add([]); + notes2.add([]); + await Future.delayed(Duration.zero); + + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationLoaded).scriptId, 'script-2'); + expect(state.marks, isEmpty); + + await cubit.close(); + await marks2.close(); + await notes2.close(); + }); + + test('close cancels stream subscriptions', () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + await cubit.close(); + // Adding to controller after close should not cause errors. + marksController.add([]); + notesController.add([]); + }); + }); +} diff --git a/horatio/horatio_app/test/bloc/annotation_history_cubit_test.dart b/horatio/horatio_app/test/bloc/annotation_history_cubit_test.dart new file mode 100644 index 0000000..92eb63a --- /dev/null +++ b/horatio/horatio_app/test/bloc/annotation_history_cubit_test.dart @@ -0,0 +1,169 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/bloc/annotation/annotation_history_cubit.dart'; +import 'package:horatio_app/bloc/annotation/annotation_history_state.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAnnotationDao extends Mock implements AnnotationDao {} + +void main() { + late MockAnnotationDao dao; + late StreamController> snapshotsController; + + const scriptId = 'script-1'; + + final testMark = TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ); + + final testNote = LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'test', + createdAt: DateTime.utc(2026), + ); + + final testSnapshot = AnnotationSnapshot( + id: 'snap-1', + scriptId: scriptId, + timestamp: DateTime.utc(2026, 3, 29), + marks: [testMark], + notes: [testNote], + ); + + setUp(() { + dao = MockAnnotationDao(); + snapshotsController = + StreamController>.broadcast(); + when(() => dao.watchSnapshotsForScript(scriptId)) + .thenAnswer((_) => snapshotsController.stream); + }); + + tearDown(() => snapshotsController.close()); + + setUpAll(() { + registerFallbackValue(testSnapshot); + }); + + group('AnnotationHistoryCubit', () { + test('initial state is AnnotationHistoryInitial', () { + final cubit = AnnotationHistoryCubit(dao: dao); + expect(cubit.state, isA()); + expect(cubit.state, equals(const AnnotationHistoryInitial())); + expect(const AnnotationHistoryInitial().props, isEmpty); + cubit.close(); + }); + + test('loadSnapshots subscribes and emits AnnotationHistoryLoaded', + () async { + final cubit = AnnotationHistoryCubit(dao: dao) + ..loadSnapshots(scriptId); + snapshotsController.add([testSnapshot]); + await Future.delayed(Duration.zero); + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationHistoryLoaded).snapshots, [testSnapshot]); + await cubit.close(); + }); + + test('saveSnapshot calls dao.insertSnapshot with correct data', () async { + when(() => dao.insertSnapshot(any())).thenAnswer((_) async {}); + final cubit = AnnotationHistoryCubit(dao: dao) + ..loadSnapshots(scriptId); + snapshotsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.saveSnapshot(marks: [testMark], notes: [testNote]); + final captured = + verify(() => dao.insertSnapshot(captureAny())).captured.single + as AnnotationSnapshot; + expect(captured.scriptId, scriptId); + expect(captured.marks, [testMark]); + expect(captured.notes, [testNote]); + await cubit.close(); + }); + + test('saveSnapshot is no-op when scriptId is null', () async { + final cubit = AnnotationHistoryCubit(dao: dao); + await cubit.saveSnapshot(marks: [], notes: []); + verifyNever(() => dao.insertSnapshot(any())); + await cubit.close(); + }); + + test('restoreSnapshot calls dao.replaceAllAnnotations', () async { + when( + () => dao.replaceAllAnnotations( + scriptId: any(named: 'scriptId'), + marks: any(named: 'marks'), + notes: any(named: 'notes'), + ), + ).thenAnswer((_) async {}); + final cubit = AnnotationHistoryCubit(dao: dao) + ..loadSnapshots(scriptId); + snapshotsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.restoreSnapshot(testSnapshot); + verify( + () => dao.replaceAllAnnotations( + scriptId: scriptId, + marks: testSnapshot.marks, + notes: testSnapshot.notes, + ), + ).called(1); + await cubit.close(); + }); + + test('restoreSnapshot is no-op when scriptId is null', () async { + final cubit = AnnotationHistoryCubit(dao: dao); + await cubit.restoreSnapshot(testSnapshot); + verifyNever( + () => dao.replaceAllAnnotations( + scriptId: any(named: 'scriptId'), + marks: any(named: 'marks'), + notes: any(named: 'notes'), + ), + ); + await cubit.close(); + }); + + test('loadSnapshots with new scriptId cancels previous stream', () async { + final cubit = AnnotationHistoryCubit(dao: dao) + ..loadSnapshots(scriptId); + snapshotsController.add([testSnapshot]); + await Future.delayed(Duration.zero); + + final snapshots2 = StreamController>.broadcast(); + when(() => dao.watchSnapshotsForScript('script-2')) + .thenAnswer((_) => snapshots2.stream); + + cubit.loadSnapshots('script-2'); + snapshots2.add([]); + await Future.delayed(Duration.zero); + + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationHistoryLoaded).snapshots, isEmpty); + + await cubit.close(); + await snapshots2.close(); + }); + + test('close cancels stream subscription', () async { + final cubit = AnnotationHistoryCubit(dao: dao) + ..loadSnapshots(scriptId); + await cubit.close(); + snapshotsController.add([testSnapshot]); + // Should not cause errors. + }); + }); +} diff --git a/horatio/horatio_app/test/bloc/rehearsal_cubit_test.dart b/horatio/horatio_app/test/bloc/rehearsal_cubit_test.dart index dc50d0d..8c3cd71 100644 --- a/horatio/horatio_app/test/bloc/rehearsal_cubit_test.dart +++ b/horatio/horatio_app/test/bloc/rehearsal_cubit_test.dart @@ -45,6 +45,7 @@ void main() { // A script where the only role's line has no preceding cue. const role = Role(name: 'Solo'); const s = Script( + id: 'empty-id', title: 'Empty', roles: [role], scenes: [ diff --git a/horatio/horatio_app/test/bloc/script_import_cubit_test.dart b/horatio/horatio_app/test/bloc/script_import_cubit_test.dart index 237ae50..24f499d 100644 --- a/horatio/horatio_app/test/bloc/script_import_cubit_test.dart +++ b/horatio/horatio_app/test/bloc/script_import_cubit_test.dart @@ -30,6 +30,7 @@ class FakeAssetBundle extends Fake implements AssetBundle { } const _fallbackScript = Script( + id: 'fallback-id', title: '', roles: [], scenes: [Scene(lines: [])], diff --git a/horatio/horatio_app/test/database/annotation_dao_test.dart b/horatio/horatio_app/test/database/annotation_dao_test.dart new file mode 100644 index 0000000..03bae32 --- /dev/null +++ b/horatio/horatio_app/test/database/annotation_dao_test.dart @@ -0,0 +1,175 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + late AppDatabase db; + late AnnotationDao dao; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + dao = db.annotationDao; + }); + + tearDown(() => db.close()); + + const scriptId = 'script-uuid-1'; + + TextMark makeMark({ + String id = 'm1', + int lineIndex = 0, + int startOffset = 0, + int endOffset = 5, + MarkType type = MarkType.stress, + }) => + TextMark( + id: id, + lineIndex: lineIndex, + startOffset: startOffset, + endOffset: endOffset, + type: type, + createdAt: DateTime.utc(2026, 3, 29), + ); + + LineNote makeNote({ + String id = 'n1', + int lineIndex = 0, + NoteCategory category = NoteCategory.intention, + String text = 'test note', + }) => + LineNote( + id: id, + lineIndex: lineIndex, + category: category, + text: text, + createdAt: DateTime.utc(2026, 3, 29), + ); + + group('TextMark CRUD', () { + test('insertMark and getMarksForLine', () async { + await dao.insertMark(scriptId, makeMark()); + final marks = await dao.getMarksForLine(scriptId, 0); + expect(marks.length, 1); + expect(marks.first.id, 'm1'); + expect(marks.first.type, MarkType.stress); + }); + + test('deleteMark removes mark', () async { + await dao.insertMark(scriptId, makeMark()); + await dao.deleteMark('m1'); + final marks = await dao.getMarksForLine(scriptId, 0); + expect(marks, isEmpty); + }); + + test('watchMarksForScript emits on insert', () async { + final stream = dao.watchMarksForScript(scriptId); + final future = expectLater( + stream, + emitsInOrder([ + isEmpty, + hasLength(1), + ]), + ); + await Future.delayed(Duration.zero); + await dao.insertMark(scriptId, makeMark()); + await future; + }); + + test('getMarksForLine filters by scriptId', () async { + await dao.insertMark(scriptId, makeMark()); + await dao.insertMark('other-script', makeMark(id: 'm2')); + final marks = await dao.getMarksForLine(scriptId, 0); + expect(marks.length, 1); + expect(marks.first.id, 'm1'); + }); + }); + + group('LineNote CRUD', () { + test('insertNote and getNotesForLine', () async { + await dao.insertNote(scriptId, makeNote()); + final notes = await dao.getNotesForLine(scriptId, 0); + expect(notes.length, 1); + expect(notes.first.text, 'test note'); + }); + + test('updateNoteText modifies text', () async { + await dao.insertNote(scriptId, makeNote()); + await dao.updateNoteText('n1', 'updated text'); + final notes = await dao.getNotesForLine(scriptId, 0); + expect(notes.first.text, 'updated text'); + }); + + test('deleteNote removes note', () async { + await dao.insertNote(scriptId, makeNote()); + await dao.deleteNote('n1'); + final notes = await dao.getNotesForLine(scriptId, 0); + expect(notes, isEmpty); + }); + + test('watchNotesForScript emits on insert', () async { + final stream = dao.watchNotesForScript(scriptId); + final future = expectLater( + stream, + emitsInOrder([isEmpty, hasLength(1)]), + ); + await Future.delayed(Duration.zero); + await dao.insertNote(scriptId, makeNote()); + await future; + }); + }); + + group('Snapshot management', () { + test('insertSnapshot and watch', () async { + final snapshot = AnnotationSnapshot( + id: 'snap-1', + scriptId: scriptId, + timestamp: DateTime.utc(2026, 3, 29), + marks: [makeMark()], + notes: [makeNote()], + ); + final stream = dao.watchSnapshotsForScript(scriptId); + final future = expectLater( + stream, + emitsInOrder([isEmpty, hasLength(1)]), + ); + await Future.delayed(Duration.zero); + await dao.insertSnapshot(snapshot); + await future; + }); + }); + + group('replaceAllAnnotations', () { + test('deletes existing and inserts new', () async { + await dao.insertMark(scriptId, makeMark(id: 'old-m')); + await dao.insertNote(scriptId, makeNote(id: 'old-n')); + + await dao.replaceAllAnnotations( + scriptId: scriptId, + marks: [makeMark(id: 'new-m')], + notes: [makeNote(id: 'new-n', text: 'new note')], + ); + + final marks = await dao.getMarksForLine(scriptId, 0); + expect(marks.length, 1); + expect(marks.first.id, 'new-m'); + + final notes = await dao.getNotesForLine(scriptId, 0); + expect(notes.length, 1); + expect(notes.first.id, 'new-n'); + }); + + test('does not affect other scripts', () async { + await dao.insertMark('other-script', makeMark(id: 'keep-m')); + await dao.replaceAllAnnotations( + scriptId: scriptId, + marks: [], + notes: [], + ); + final marks = await dao.getMarksForLine('other-script', 0); + expect(marks.length, 1); + expect(marks.first.id, 'keep-m'); + }); + }); +} diff --git a/horatio/horatio_app/test/helpers/test_database.dart b/horatio/horatio_app/test/helpers/test_database.dart new file mode 100644 index 0000000..e279f02 --- /dev/null +++ b/horatio/horatio_app/test/helpers/test_database.dart @@ -0,0 +1,5 @@ +import 'package:drift/native.dart'; +import 'package:horatio_app/database/app_database.dart'; + +/// Creates an in-memory [AppDatabase] for tests. +AppDatabase createTestDatabase() => AppDatabase(NativeDatabase.memory()); diff --git a/horatio/horatio_app/test/router_test.dart b/horatio/horatio_app/test/router_test.dart index 8f207ad..9f4116f 100644 --- a/horatio/horatio_app/test/router_test.dart +++ b/horatio/horatio_app/test/router_test.dart @@ -5,15 +5,27 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:horatio_app/bloc/script_import/script_import_cubit.dart'; import 'package:horatio_app/bloc/srs_review/srs_review_cubit.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; import 'package:horatio_app/router.dart'; import 'package:horatio_app/services/script_repository.dart'; import 'package:horatio_core/horatio_core.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockAnnotationDao extends Mock implements AnnotationDao {} Widget _wrapRouter() { final repository = ScriptRepository(); + final mockDao = _MockAnnotationDao(); + when(() => mockDao.watchMarksForScript(any())) + .thenAnswer((_) => Stream.value([])); + when(() => mockDao.watchNotesForScript(any())) + .thenAnswer((_) => Stream.value([])); + when(() => mockDao.watchSnapshotsForScript(any())) + .thenAnswer((_) => Stream.value([])); return MultiRepositoryProvider( providers: [ RepositoryProvider(create: (_) => repository), + RepositoryProvider.value(value: mockDao), ], child: MultiBlocProvider( providers: [ @@ -45,6 +57,7 @@ void main() { const role = Role(name: 'Hero'); const script = Script( + id: 'router-valid-id', title: 'Valid', roles: [role], scenes: [ @@ -73,6 +86,7 @@ void main() { const role = Role(name: 'Hero'); const script = Script( + id: 'router-play-id', title: 'Play', roles: [role], scenes: [ @@ -104,6 +118,7 @@ void main() { const role = Role(name: 'Hero'); const script = Script( + id: 'router-rehearse-id', title: 'Rehearse', roles: [role], scenes: [ @@ -166,5 +181,95 @@ void main() { // Should not crash — shows SizedBox.shrink or redirects. expect(tester.takeException(), isNull); }); + + testWidgets('annotations route with Script extra shows editor', + (tester) async { + await tester.pumpWidget(_wrapRouter()); + await tester.pumpAndSettle(); + + // Reset to home to clear any stale navigation stack. + appRouter.go(RoutePaths.home); + await tester.pumpAndSettle(); + + const role = Role(name: 'Hero'); + const script = Script( + id: 'router-annotate-id', + title: 'Annotate Play', + roles: [role], + scenes: [ + Scene( + lines: [ + ScriptLine( + text: 'Line.', + role: role, + sceneIndex: 0, + lineIndex: 0, + ), + ], + ), + ], + ); + + unawaited(appRouter.push(RoutePaths.annotations, extra: script)); + await tester.pumpAndSettle(); + + expect(find.text('Annotate: Annotate Play'), findsOneWidget); + }); + + testWidgets('annotations route with null extra redirects home', + (tester) async { + await tester.pumpWidget(_wrapRouter()); + await tester.pumpAndSettle(); + + appRouter.go(RoutePaths.annotations); + await tester.pumpAndSettle(); + + // Redirected to home. + expect(find.text('Horatio'), findsOneWidget); + }); + + testWidgets('annotation-history route with Script extra shows history', + (tester) async { + await tester.pumpWidget(_wrapRouter()); + await tester.pumpAndSettle(); + + const role = Role(name: 'Hero'); + const script = Script( + id: 'router-history-id', + title: 'History Play', + roles: [role], + scenes: [ + Scene( + lines: [ + ScriptLine( + text: 'Line.', + role: role, + sceneIndex: 0, + lineIndex: 0, + ), + ], + ), + ], + ); + + unawaited( + appRouter.push(RoutePaths.annotationHistory, extra: script), + ); + await tester.pumpAndSettle(); + + expect(find.text('History: History Play'), findsOneWidget); + }); + + testWidgets('annotation-history route with null extra redirects home', + (tester) async { + await tester.pumpWidget(_wrapRouter()); + await tester.pumpAndSettle(); + + appRouter.go(RoutePaths.annotationHistory); + await tester.pumpAndSettle(); + + // Redirected to home. + expect(find.text('Horatio'), findsOneWidget); + }); }); } diff --git a/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart b/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart new file mode 100644 index 0000000..1fedf2d --- /dev/null +++ b/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart @@ -0,0 +1,395 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_app/screens/annotation_editor_screen.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAnnotationDao extends Mock implements AnnotationDao {} + +const _hamlet = Role(name: 'Hamlet'); +const _horatio = Role(name: 'Horatio'); + +Script _testScript() => const Script( + id: 'editor-screen-test', + title: 'Test Play', + roles: [_hamlet, _horatio], + scenes: [ + Scene( + lines: [ + ScriptLine( + text: 'To be or not to be.', + role: _hamlet, + sceneIndex: 0, + lineIndex: 0, + ), + ScriptLine( + text: 'Indeed, my lord.', + role: _horatio, + sceneIndex: 0, + lineIndex: 1, + ), + ], + ), + ], + ); + +late MockAnnotationDao _dao; +late StreamController> _marksCtrl; +late StreamController> _notesCtrl; +late StreamController> _snapshotsCtrl; + +void _setUpDao() { + _dao = MockAnnotationDao(); + _marksCtrl = StreamController>.broadcast(); + _notesCtrl = StreamController>.broadcast(); + _snapshotsCtrl = StreamController>.broadcast(); + + when(() => _dao.watchMarksForScript(any())) + .thenAnswer((_) => _marksCtrl.stream); + when(() => _dao.watchNotesForScript(any())) + .thenAnswer((_) => _notesCtrl.stream); + when(() => _dao.watchSnapshotsForScript(any())) + .thenAnswer((_) => _snapshotsCtrl.stream); + when(() => _dao.insertSnapshot(any())).thenAnswer((_) async {}); + when(() => _dao.insertMark(any(), any())).thenAnswer((_) async {}); + when(() => _dao.insertNote(any(), any())).thenAnswer((_) async {}); +} + +void _tearDownStreams() { + _marksCtrl.close(); + _notesCtrl.close(); + _snapshotsCtrl.close(); +} + +Widget _buildScreen(Script script) => RepositoryProvider.value( + value: _dao, + child: MaterialApp( + home: AnnotationEditorScreen(script: script), + ), + ); + +Widget _buildScreenWithRouter(Script script) { + final router = GoRouter( + initialLocation: '/annotations', + routes: [ + GoRoute( + path: '/annotations', + builder: (context, state) => RepositoryProvider.value( + value: _dao, + child: AnnotationEditorScreen(script: script), + ), + ), + GoRoute( + path: '/annotation-history', + builder: (context, state) => + const Scaffold(body: Text('History Screen')), + ), + ], + ); + return MaterialApp.router(routerConfig: router); +} + +void main() { + setUpAll(() { + registerFallbackValue( + AnnotationSnapshot( + id: 'fb', + scriptId: 'fb', + timestamp: DateTime.utc(2026), + marks: const [], + notes: const [], + ), + ); + registerFallbackValue( + TextMark( + id: 'fb', + lineIndex: 0, + startOffset: 0, + endOffset: 1, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ), + ); + registerFallbackValue( + LineNote( + id: 'fb', + lineIndex: 0, + category: NoteCategory.general, + text: 'fb', + createdAt: DateTime.utc(2026), + ), + ); + }); + + group('AnnotationEditorScreen', () { + setUp(_setUpDao); + tearDown(_tearDownStreams); + + testWidgets('shows loading indicator in initial state', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + // Streams haven't emitted yet → AnnotationInitial. + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows script lines after data loads', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + expect( + find.text('To be or not to be.', findRichText: true), + findsOneWidget, + ); + expect( + find.text('Indeed, my lord.', findRichText: true), + findsOneWidget, + ); + }); + + testWidgets('lines with marks show colored overlay', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([ + TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ), + ]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + // MarkOverlay renders RichText widgets. + expect(find.byType(RichText), findsWidgets); + expect(find.text('To be or not to be.'), findsNothing); + }); + + testWidgets('lines with notes show note indicator badge', + (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([ + LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'test note', + createdAt: DateTime.utc(2026), + ), + ]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + expect(find.text('1'), findsOneWidget); + }); + + testWidgets('tapping a line highlights it', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap( + find.text('To be or not to be.', findRichText: true), + ); + await tester.pump(); + + // After tap, a Container with primary color should appear. + final containers = tester + .widgetList(find.byType(Container)) + .where((c) => c.color != null); + expect(containers, isNotEmpty); + }); + + testWidgets('History button is present and navigates', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreenWithRouter(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.history), findsOneWidget); + await tester.tap(find.byIcon(Icons.history)); + await tester.pumpAndSettle(); + + expect(find.text('History Screen'), findsOneWidget); + }); + + testWidgets('long-press on a line shows mark type picker', + (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.longPress( + find.text('To be or not to be.', findRichText: true), + ); + await tester.pumpAndSettle(); + + expect(find.text('Add Mark'), findsOneWidget); + expect(find.text('Stress'), findsOneWidget); + expect(find.text('Pause'), findsOneWidget); + }); + + testWidgets('selecting mark type in picker calls addMark', + (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.longPress( + find.text('To be or not to be.', findRichText: true), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Stress')); + await tester.pumpAndSettle(); + + verify(() => _dao.insertMark(any(), any())).called(1); + }); + + testWidgets('cancel in mark picker dismisses dialog', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.longPress( + find.text('To be or not to be.', findRichText: true), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Add Mark'), findsNothing); + }); + + testWidgets('tapping note indicator shows note editor sheet', + (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([ + LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'existing note', + createdAt: DateTime.utc(2026), + ), + ]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + + expect(find.text('Category'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('saving note in editor calls addNote', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([ + LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'existing note', + createdAt: DateTime.utc(2026), + ), + ]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField), 'New note text'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + verify(() => _dao.insertNote(any(), any())).called(1); + }); + + testWidgets('cancel in note editor sheet dismisses', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([ + LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'existing note', + createdAt: DateTime.utc(2026), + ), + ]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Category'), findsNothing); + }); + + testWidgets('FAB saves snapshot', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + verify(() => _dao.insertSnapshot(any())).called(1); + }); + + testWidgets('FAB hidden in initial state', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + // Streams haven't emitted → initial state. + expect(find.byType(FloatingActionButton), findsNothing); + }); + + testWidgets('AppBar shows script title', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + expect(find.text('Annotate: Test Play'), findsOneWidget); + }); + }); +} diff --git a/horatio/horatio_app/test/screens/annotation_history_screen_test.dart b/horatio/horatio_app/test/screens/annotation_history_screen_test.dart new file mode 100644 index 0000000..8a284cb --- /dev/null +++ b/horatio/horatio_app/test/screens/annotation_history_screen_test.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_app/screens/annotation_history_screen.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAnnotationDao extends Mock implements AnnotationDao {} + +const _hamlet = Role(name: 'Hamlet'); + +Script _testScript() => const Script( + id: 'history-screen-test', + title: 'Test Play', + roles: [_hamlet], + scenes: [ + Scene( + lines: [ + ScriptLine( + text: 'To be.', + role: _hamlet, + sceneIndex: 0, + lineIndex: 0, + ), + ], + ), + ], + ); + +late MockAnnotationDao _dao; +late StreamController> _snapshotsCtrl; + +void _setUpDao() { + _dao = MockAnnotationDao(); + _snapshotsCtrl = StreamController>.broadcast(); + + when(() => _dao.watchSnapshotsForScript(any())) + .thenAnswer((_) => _snapshotsCtrl.stream); + when(() => _dao.replaceAllAnnotations( + scriptId: any(named: 'scriptId'), + marks: any(named: 'marks'), + notes: any(named: 'notes'), + )).thenAnswer((_) async {}); +} + +Widget _buildScreen(Script script) => + RepositoryProvider.value( + value: _dao, + child: MaterialApp( + home: AnnotationHistoryScreen(script: script), + ), + ); + +void main() { + setUpAll(() { + registerFallbackValue( + AnnotationSnapshot( + id: 'fb', + scriptId: 'fb', + timestamp: DateTime.utc(2026), + marks: const [], + notes: const [], + ), + ); + }); + + group('AnnotationHistoryScreen', () { + setUp(_setUpDao); + tearDown(() => _snapshotsCtrl.close()); + + testWidgets('shows loading indicator in initial state', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows "No history yet" when snapshots list is empty', + (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + expect(find.text('No history yet'), findsOneWidget); + }); + + testWidgets('renders snapshot cards with timestamp and counts', + (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + final snapshot = AnnotationSnapshot( + id: 's1', + scriptId: 'history-screen-test', + timestamp: DateTime.utc(2026, 3, 15, 10, 30), + marks: [ + TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 2, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ), + ], + notes: [ + LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.general, + text: 'note', + createdAt: DateTime.utc(2026), + ), + LineNote( + id: 'n2', + lineIndex: 0, + category: NoteCategory.emotion, + text: 'another', + createdAt: DateTime.utc(2026), + ), + ], + ); + _snapshotsCtrl.add([snapshot]); + await tester.pumpAndSettle(); + + expect(find.text('1 marks · 2 notes'), findsOneWidget); + expect(find.text('Restore'), findsOneWidget); + }); + + testWidgets('Restore button shows confirmation dialog', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + final snapshot = AnnotationSnapshot( + id: 's1', + scriptId: 'history-screen-test', + timestamp: DateTime.utc(2026, 3, 15, 10, 30), + marks: const [], + notes: const [], + ); + _snapshotsCtrl.add([snapshot]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Restore')); + await tester.pumpAndSettle(); + + expect(find.text('Restore Snapshot?'), findsOneWidget); + expect( + find.text( + 'This will replace all current annotations with the snapshot.', + ), + findsOneWidget, + ); + }); + + testWidgets('confirming restore calls cubit method', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + final snapshot = AnnotationSnapshot( + id: 's1', + scriptId: 'history-screen-test', + timestamp: DateTime.utc(2026, 3, 15, 10, 30), + marks: const [], + notes: const [], + ); + _snapshotsCtrl.add([snapshot]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Restore')); + await tester.pumpAndSettle(); + + // Tap 'Restore' in dialog (the second one on screen). + await tester.tap(find.widgetWithText(TextButton, 'Restore').last); + await tester.pumpAndSettle(); + + verify( + () => _dao.replaceAllAnnotations( + scriptId: any(named: 'scriptId'), + marks: any(named: 'marks'), + notes: any(named: 'notes'), + ), + ).called(1); + }); + + testWidgets('cancelling dialog dismisses without restore', + (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + final snapshot = AnnotationSnapshot( + id: 's1', + scriptId: 'history-screen-test', + timestamp: DateTime.utc(2026, 3, 15, 10, 30), + marks: const [], + notes: const [], + ); + _snapshotsCtrl.add([snapshot]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Restore')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Restore Snapshot?'), findsNothing); + verifyNever( + () => _dao.replaceAllAnnotations( + scriptId: any(named: 'scriptId'), + marks: any(named: 'marks'), + notes: any(named: 'notes'), + ), + ); + }); + + testWidgets('AppBar shows script title', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + expect(find.text('History: Test Play'), findsOneWidget); + }); + }); +} diff --git a/horatio/horatio_app/test/screens/home_screen_test.dart b/horatio/horatio_app/test/screens/home_screen_test.dart index f924a43..faa0a4c 100644 --- a/horatio/horatio_app/test/screens/home_screen_test.dart +++ b/horatio/horatio_app/test/screens/home_screen_test.dart @@ -104,6 +104,7 @@ void main() { (tester) async { const role = Role(name: 'Hero'); const script = Script( + id: 'home-my-play-id', title: 'My Play', roles: [role], scenes: [ @@ -134,6 +135,7 @@ void main() { (tester) async { const role = Role(name: 'Hero'); const script = Script( + id: 'home-play-id', title: 'Play', roles: [role], scenes: [ @@ -308,6 +310,7 @@ void main() { (tester) async { const role = Role(name: 'Hero'); const script = Script( + id: 'home-drag-id', title: 'Play', roles: [role], scenes: [ @@ -347,6 +350,7 @@ void main() { (tester) async { const role = Role(name: 'Hero'); const script = Script( + id: 'home-nav-id', title: 'Navigation Play', roles: [role], scenes: [ diff --git a/horatio/horatio_app/test/screens/import_screen_test.dart b/horatio/horatio_app/test/screens/import_screen_test.dart index b028b92..e2ab7d7 100644 --- a/horatio/horatio_app/test/screens/import_screen_test.dart +++ b/horatio/horatio_app/test/screens/import_screen_test.dart @@ -182,6 +182,7 @@ void main() { // Emit a loaded state with a script. const role = Role(name: 'Actor'); const script = Script( + id: 'import-test-id', title: 'Test', roles: [role], scenes: [ diff --git a/horatio/horatio_app/test/screens/rehearsal_screen_test.dart b/horatio/horatio_app/test/screens/rehearsal_screen_test.dart index f4c8de8..eed6097 100644 --- a/horatio/horatio_app/test/screens/rehearsal_screen_test.dart +++ b/horatio/horatio_app/test/screens/rehearsal_screen_test.dart @@ -14,6 +14,7 @@ Script _twoLineScript() { const hamlet = Role(name: 'Hamlet'); const horatio = Role(name: 'Horatio'); return const Script( + id: 'rehearsal-test-id', title: 'Test', roles: [hamlet, horatio], scenes: [ diff --git a/horatio/horatio_app/test/screens/role_selection_screen_test.dart b/horatio/horatio_app/test/screens/role_selection_screen_test.dart index fb98c7a..e0a2ae1 100644 --- a/horatio/horatio_app/test/screens/role_selection_screen_test.dart +++ b/horatio/horatio_app/test/screens/role_selection_screen_test.dart @@ -8,6 +8,7 @@ Script _testScript() { const hamlet = Role(name: 'Hamlet'); const horatio = Role(name: 'Horatio'); return const Script( + id: 'role-select-test-id', title: 'Test Play', roles: [hamlet, horatio], scenes: [ @@ -56,6 +57,11 @@ Widget _wrapWithRouter(Script script) { builder: (context, state) => const Scaffold(body: Text('Schedule')), ), + GoRoute( + path: '/annotations', + builder: (context, state) => + const Scaffold(body: Text('Annotations')), + ), ], ); return MaterialApp.router(routerConfig: router); @@ -132,6 +138,7 @@ void main() { testWidgets('handles role with empty name', (tester) async { const emptyRole = Role(name: ''); const script = Script( + id: 'edge-id', title: 'Edge', roles: [emptyRole], scenes: [ @@ -153,5 +160,34 @@ void main() { expect(find.text('?'), findsOneWidget); }); + + testWidgets('bottom sheet shows Annotate Script option', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_wrapWithRouter(script)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Hamlet')); + await tester.pumpAndSettle(); + + expect(find.text('Annotate Script'), findsOneWidget); + expect( + find.text('Add delivery marks and notes'), + findsOneWidget, + ); + }); + + testWidgets('bottom sheet Annotate Script navigates', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_wrapWithRouter(script)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Hamlet')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Annotate Script')); + await tester.pumpAndSettle(); + + expect(find.text('Annotations'), findsOneWidget); + }); }); } diff --git a/horatio/horatio_app/test/screens/schedule_screen_test.dart b/horatio/horatio_app/test/screens/schedule_screen_test.dart index c3c40ed..04f2386 100644 --- a/horatio/horatio_app/test/screens/schedule_screen_test.dart +++ b/horatio/horatio_app/test/screens/schedule_screen_test.dart @@ -32,6 +32,7 @@ Script _testScript() { const hamlet = Role(name: 'Hamlet'); const horatio = Role(name: 'Horatio'); return const Script( + id: 'schedule-test-id', title: 'Test', roles: [hamlet, horatio], scenes: [ @@ -91,6 +92,7 @@ void main() { const hamlet = Role(name: 'Hamlet'); const horatio = Role(name: 'Horatio'); const script = Script( + id: 'one-sided-id', title: 'One-sided', roles: [hamlet, horatio], scenes: [ diff --git a/horatio/horatio_app/test/widgets/mark_overlay_test.dart b/horatio/horatio_app/test/widgets/mark_overlay_test.dart new file mode 100644 index 0000000..b4b823e --- /dev/null +++ b/horatio/horatio_app/test/widgets/mark_overlay_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/mark_overlay.dart'; +import 'package:horatio_core/horatio_core.dart'; + +TextMark _mark({ + required int start, + required int end, + MarkType type = MarkType.stress, +}) => + TextMark( + id: 'mark-$start-$end-${type.name}', + lineIndex: 0, + startOffset: start, + endOffset: end, + type: type, + createdAt: DateTime(2025), + ); + +void main() { + group('MarkOverlay', () { + testWidgets('empty marks list renders plain text', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: MarkOverlay(text: 'Hello world', marks: [])), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final span = richText.text as TextSpan; + expect(span.text, 'Hello world'); + expect(span.children, isNull); + }); + + testWidgets('single mark renders colored span', (tester) async { + final marks = [_mark(start: 0, end: 5)]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: MarkOverlay(text: 'Hello world', marks: marks)), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final span = richText.text as TextSpan; + expect(span.children, isNotNull); + + final marked = span.children!.first as TextSpan; + expect(marked.text, 'Hello'); + expect(marked.style?.backgroundColor, markColors[MarkType.stress]); + + final plain = span.children![1] as TextSpan; + expect(plain.text, ' world'); + expect(plain.style?.backgroundColor, isNull); + }); + + testWidgets('multiple non-overlapping marks', (tester) async { + final marks = [ + _mark(start: 0, end: 5), + _mark(start: 6, end: 11, type: MarkType.pause), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: MarkOverlay(text: 'Hello world', marks: marks)), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final span = richText.text as TextSpan; + expect(span.children, hasLength(3)); + + final first = span.children![0] as TextSpan; + expect(first.text, 'Hello'); + expect(first.style?.backgroundColor, markColors[MarkType.stress]); + + final gap = span.children![1] as TextSpan; + expect(gap.text, ' '); + + final second = span.children![2] as TextSpan; + expect(second.text, 'world'); + expect(second.style?.backgroundColor, markColors[MarkType.pause]); + }); + + testWidgets('each MarkType maps to distinct color', (tester) async { + final colors = {}; + for (final type in MarkType.values) { + final color = markColors[type]; + expect(color, isNotNull, reason: '$type should have a mapped color'); + colors.add(color!); + } + expect(colors, hasLength(MarkType.values.length)); + }); + + testWidgets('mark outside text bounds is clamped gracefully', + (tester) async { + final marks = [_mark(start: 50, end: 100)]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: MarkOverlay(text: 'Short', marks: marks)), + ), + ); + + // Should not crash — renders plain text since mark is fully clamped. + final richText = tester.widget(find.byType(RichText)); + expect(richText.text, isA()); + // When mark start >= end after clamping, it's skipped → children path. + // The text is still fully rendered either way. + expect(tester.takeException(), isNull); + }); + + testWidgets('custom style is applied', (tester) async { + const customStyle = TextStyle(fontSize: 24); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkOverlay(text: 'Styled', marks: [], style: customStyle), + ), + ), + ); + + final richText = tester.widget(find.byType(RichText)); + final span = richText.text as TextSpan; + expect(span.style?.fontSize, 24); + }); + }); +} diff --git a/horatio/horatio_app/test/widgets/mark_type_picker_test.dart b/horatio/horatio_app/test/widgets/mark_type_picker_test.dart new file mode 100644 index 0000000..885d4df --- /dev/null +++ b/horatio/horatio_app/test/widgets/mark_type_picker_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/mark_type_picker.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + group('MarkTypePicker', () { + testWidgets('displays all 6 MarkType labels', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkTypePicker(onSelected: (_) {}, onCancelled: () {}), + ), + ), + ); + + for (final type in MarkType.values) { + expect(find.text(markTypeLabel(type)), findsOneWidget); + } + }); + + testWidgets('tapping each type calls onSelected', (tester) async { + for (final type in MarkType.values) { + MarkType? selected; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkTypePicker( + onSelected: (t) => selected = t, + onCancelled: () {}, + ), + ), + ), + ); + + await tester.tap(find.text(markTypeLabel(type))); + expect(selected, type); + } + }); + + testWidgets('tapping cancel calls onCancelled', (tester) async { + var cancelled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkTypePicker( + onSelected: (_) {}, + onCancelled: () => cancelled = true, + ), + ), + ), + ); + + await tester.tap(find.text('Cancel')); + expect(cancelled, isTrue); + }); + }); +} diff --git a/horatio/horatio_app/test/widgets/note_editor_sheet_test.dart b/horatio/horatio_app/test/widgets/note_editor_sheet_test.dart new file mode 100644 index 0000000..579ea22 --- /dev/null +++ b/horatio/horatio_app/test/widgets/note_editor_sheet_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/note_editor_sheet.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + group('NoteEditorSheet', () { + testWidgets('displays all 6 NoteCategory values in dropdown', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteEditorSheet(onSave: (_, __) {}, onCancel: () {}), + ), + ), + ); + + // Open the dropdown. + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + + for (final category in NoteCategory.values) { + expect( + find.text(noteCategoryLabel(category)), + findsWidgets, + reason: '${category.name} should appear in dropdown', + ); + } + }); + + testWidgets('submit with text calls onSave', (tester) async { + NoteCategory? savedCategory; + String? savedText; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteEditorSheet( + onSave: (category, text) { + savedCategory = category; + savedText = text; + }, + onCancel: () {}, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'My note'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(savedCategory, NoteCategory.general); + expect(savedText, 'My note'); + }); + + testWidgets('submit with empty text shows validation error', + (tester) async { + var saveCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteEditorSheet( + onSave: (_, __) => saveCalled = true, + onCancel: () {}, + ), + ), + ), + ); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Note cannot be empty'), findsOneWidget); + expect(saveCalled, isFalse); + }); + + testWidgets('cancel calls onCancel', (tester) async { + var cancelled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteEditorSheet( + onSave: (_, __) {}, + onCancel: () => cancelled = true, + ), + ), + ), + ); + + await tester.tap(find.text('Cancel')); + expect(cancelled, isTrue); + }); + + testWidgets('pre-filled initialText and initialCategory', (tester) async { + NoteCategory? savedCategory; + String? savedText; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteEditorSheet( + onSave: (category, text) { + savedCategory = category; + savedText = text; + }, + onCancel: () {}, + initialCategory: NoteCategory.emotion, + initialText: 'Existing note', + ), + ), + ), + ); + + // Verify text is pre-filled. + expect(find.text('Existing note'), findsOneWidget); + + // Verify category label shown (the selected value). + expect(find.text('Emotion'), findsOneWidget); + + // Submit without changes. + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(savedCategory, NoteCategory.emotion); + expect(savedText, 'Existing note'); + }); + + testWidgets('changing category updates selection', (tester) async { + NoteCategory? savedCategory; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteEditorSheet( + onSave: (category, _) => savedCategory = category, + onCancel: () {}, + ), + ), + ), + ); + + // Open dropdown and select "Intention". + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Intention').last); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField), 'Test'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(savedCategory, NoteCategory.intention); + }); + }); +} diff --git a/horatio/horatio_app/test/widgets/note_indicator_test.dart b/horatio/horatio_app/test/widgets/note_indicator_test.dart new file mode 100644 index 0000000..336360d --- /dev/null +++ b/horatio/horatio_app/test/widgets/note_indicator_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/note_indicator.dart'; + +void main() { + group('NoteIndicator', () { + testWidgets('zero notes renders SizedBox.shrink', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: NoteIndicator(noteCount: 0, onTap: () {})), + ), + ); + + expect(find.byType(SizedBox), findsOneWidget); + expect(find.text('0'), findsNothing); + }); + + testWidgets('one note shows "1"', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: NoteIndicator(noteCount: 1, onTap: () {})), + ), + ); + + expect(find.text('1'), findsOneWidget); + }); + + testWidgets('multiple notes shows count', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: NoteIndicator(noteCount: 5, onTap: () {})), + ), + ); + + expect(find.text('5'), findsOneWidget); + }); + + testWidgets('tap triggers callback', (tester) async { + var tapped = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteIndicator(noteCount: 3, onTap: () => tapped = true), + ), + ), + ); + + await tester.tap(find.text('3')); + expect(tapped, isTrue); + }); + }); +} diff --git a/horatio/horatio_core/lib/src/models/annotation_snapshot.dart b/horatio/horatio_core/lib/src/models/annotation_snapshot.dart new file mode 100644 index 0000000..bc4ad5a --- /dev/null +++ b/horatio/horatio_core/lib/src/models/annotation_snapshot.dart @@ -0,0 +1,62 @@ +import 'package:horatio_core/src/models/line_note.dart'; +import 'package:horatio_core/src/models/text_mark.dart'; +import 'package:meta/meta.dart'; + +/// A point-in-time record of all annotations for a script. +@immutable +final class AnnotationSnapshot { + /// Creates an [AnnotationSnapshot] with unmodifiable lists. + AnnotationSnapshot({ + required this.id, + required this.scriptId, + required this.timestamp, + required List marks, + required List notes, + }) : marks = List.unmodifiable(marks), + notes = List.unmodifiable(notes); + + /// Deserializes from a JSON map. + factory AnnotationSnapshot.fromJson(Map json) => + AnnotationSnapshot( + id: json['id'] as String, + scriptId: json['scriptId'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + marks: (json['marks'] as List) + .map((e) => TextMark.fromJson(e as Map)) + .toList(), + notes: (json['notes'] as List) + .map((e) => LineNote.fromJson(e as Map)) + .toList(), + ); + + /// Unique identifier (UUID). + final String id; + + /// The script these annotations belong to. + final String scriptId; + + /// When this snapshot was taken. + final DateTime timestamp; + + /// All text marks at snapshot time. + final List marks; + + /// All line notes at snapshot time. + final List notes; + + @override + bool operator ==(Object other) => + identical(this, other) || other is AnnotationSnapshot && id == other.id; + + @override + int get hashCode => id.hashCode; + + /// Serializes to a JSON-compatible map. + Map toJson() => { + 'id': id, + 'scriptId': scriptId, + 'timestamp': timestamp.toUtc().toIso8601String(), + 'marks': marks.map((m) => m.toJson()).toList(), + 'notes': notes.map((n) => n.toJson()).toList(), + }; +} diff --git a/horatio/horatio_core/lib/src/models/line_note.dart b/horatio/horatio_core/lib/src/models/line_note.dart new file mode 100644 index 0000000..3c0686d --- /dev/null +++ b/horatio/horatio_core/lib/src/models/line_note.dart @@ -0,0 +1,55 @@ +import 'package:horatio_core/src/models/note_category.dart'; +import 'package:meta/meta.dart'; + +/// A free-text interpretive note attached to a whole script line. +@immutable +final class LineNote { + /// Creates a [LineNote]. + const LineNote({ + required this.id, + required this.lineIndex, + required this.category, + required this.text, + required this.createdAt, + }); + + /// Deserializes from a JSON map. + factory LineNote.fromJson(Map json) => LineNote( + id: json['id'] as String, + lineIndex: json['lineIndex'] as int, + category: NoteCategory.values.byName(json['category'] as String), + text: json['text'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + + /// Unique identifier (UUID). + final String id; + + /// Index of the [ScriptLine] this note is attached to. + final int lineIndex; + + /// The category of this note. + final NoteCategory category; + + /// Free-text note content. + final String text; + + /// When this note was created. + final DateTime createdAt; + + @override + bool operator ==(Object other) => + identical(this, other) || other is LineNote && id == other.id; + + @override + int get hashCode => id.hashCode; + + /// Serializes to a JSON-compatible map. + Map toJson() => { + 'id': id, + 'lineIndex': lineIndex, + 'category': category.name, + 'text': text, + 'createdAt': createdAt.toUtc().toIso8601String(), + }; +} diff --git a/horatio/horatio_core/lib/src/models/mark_type.dart b/horatio/horatio_core/lib/src/models/mark_type.dart new file mode 100644 index 0000000..3920958 --- /dev/null +++ b/horatio/horatio_core/lib/src/models/mark_type.dart @@ -0,0 +1,20 @@ +/// Types of text-level delivery marks an actor can place on script text. +enum MarkType { + /// Stress / emphasize this word. + stress, + + /// Pause before this span. + pause, + + /// Take a breath here. + breath, + + /// General emphasis. + emphasis, + + /// Deliver this span slower. + slowDown, + + /// Deliver this span faster. + speedUp, +} diff --git a/horatio/horatio_core/lib/src/models/models.dart b/horatio/horatio_core/lib/src/models/models.dart index 346ac49..f1f226f 100644 --- a/horatio/horatio_core/lib/src/models/models.dart +++ b/horatio/horatio_core/lib/src/models/models.dart @@ -1,6 +1,11 @@ +export 'annotation_snapshot.dart'; +export 'line_note.dart'; +export 'mark_type.dart'; +export 'note_category.dart'; export 'role.dart'; export 'scene.dart'; export 'script.dart'; export 'script_line.dart'; export 'srs_card.dart'; export 'stage_direction.dart'; +export 'text_mark.dart'; diff --git a/horatio/horatio_core/lib/src/models/note_category.dart b/horatio/horatio_core/lib/src/models/note_category.dart new file mode 100644 index 0000000..86b8039 --- /dev/null +++ b/horatio/horatio_core/lib/src/models/note_category.dart @@ -0,0 +1,20 @@ +/// Categories for line-level interpretive notes. +enum NoteCategory { + /// "What does the character want here?" + intention, + + /// "What are they really saying?" + subtext, + + /// "Cross downstage on this line." + blocking, + + /// "Suppressed anger building." + emotion, + + /// "Whisper this line." + delivery, + + /// Catch-all for uncategorized notes. + general, +} diff --git a/horatio/horatio_core/lib/src/models/script.dart b/horatio/horatio_core/lib/src/models/script.dart index 9ef6ccb..7de6272 100644 --- a/horatio/horatio_core/lib/src/models/script.dart +++ b/horatio/horatio_core/lib/src/models/script.dart @@ -5,11 +5,15 @@ import 'package:horatio_core/src/models/scene.dart'; final class Script { /// Creates a [Script] from parsed data. const Script({ + required this.id, required this.title, required this.roles, required this.scenes, }); + /// Unique identifier (UUID) for this script. + final String id; + /// The title of the script. final String title; diff --git a/horatio/horatio_core/lib/src/models/text_mark.dart b/horatio/horatio_core/lib/src/models/text_mark.dart new file mode 100644 index 0000000..3c92837 --- /dev/null +++ b/horatio/horatio_core/lib/src/models/text_mark.dart @@ -0,0 +1,67 @@ +import 'package:horatio_core/src/models/mark_type.dart'; +import 'package:meta/meta.dart'; + +/// A span-based delivery mark on text within a script line. +@immutable +final class TextMark { + /// Creates a [TextMark] with validated offsets. + const TextMark({ + required this.id, + required this.lineIndex, + required this.startOffset, + required this.endOffset, + required this.type, + required this.createdAt, + }) : assert(startOffset >= 0, 'startOffset must be non-negative'), + assert( + endOffset > startOffset, + 'endOffset must be greater than startOffset', + ); + + /// Deserializes from a JSON map. + /// + /// Throws [ArgumentError] if [type] is not a valid [MarkType] name. + factory TextMark.fromJson(Map json) => TextMark( + id: json['id'] as String, + lineIndex: json['lineIndex'] as int, + startOffset: json['startOffset'] as int, + endOffset: json['endOffset'] as int, + type: MarkType.values.byName(json['type'] as String), + createdAt: DateTime.parse(json['createdAt'] as String), + ); + + /// Unique identifier (UUID). + final String id; + + /// Index of the [ScriptLine] this mark applies to. + final int lineIndex; + + /// Start character offset in the line text (inclusive). + final int startOffset; + + /// End character offset in the line text (exclusive). + final int endOffset; + + /// The type of delivery mark. + final MarkType type; + + /// When this mark was created. + final DateTime createdAt; + + @override + bool operator ==(Object other) => + identical(this, other) || other is TextMark && id == other.id; + + @override + int get hashCode => id.hashCode; + + /// Serializes to a JSON-compatible map. + Map toJson() => { + 'id': id, + 'lineIndex': lineIndex, + 'startOffset': startOffset, + 'endOffset': endOffset, + 'type': type.name, + 'createdAt': createdAt.toUtc().toIso8601String(), + }; +} diff --git a/horatio/horatio_core/lib/src/parser/text_parser.dart b/horatio/horatio_core/lib/src/parser/text_parser.dart index 27d7e17..79517d1 100644 --- a/horatio/horatio_core/lib/src/parser/text_parser.dart +++ b/horatio/horatio_core/lib/src/parser/text_parser.dart @@ -1,6 +1,7 @@ import 'package:horatio_core/src/models/models.dart'; import 'package:horatio_core/src/parser/role_detector.dart'; import 'package:horatio_core/src/parser/script_parser.dart'; +import 'package:uuid/uuid.dart'; /// Parses plain text scripts into structured [Script] objects. /// @@ -154,6 +155,7 @@ final class TextParser implements ScriptParser { } return Script( + id: const Uuid().v4(), title: title, roles: List.unmodifiable(roles.values.toList()), scenes: List.unmodifiable(scenes), diff --git a/horatio/horatio_core/pubspec.yaml b/horatio/horatio_core/pubspec.yaml index 6e8b287..4341d0b 100644 --- a/horatio/horatio_core/pubspec.yaml +++ b/horatio/horatio_core/pubspec.yaml @@ -9,9 +9,11 @@ environment: sdk: ^3.11.0 dependencies: - collection: ^1.18.0 - xml: ^6.5.0 archive: ^4.0.0 + collection: ^1.18.0 + meta: ^1.16.0 + uuid: ^4.5.1 + xml: ^6.5.0 dev_dependencies: lints: ^6.0.0 diff --git a/horatio/horatio_core/test/models/model_test.dart b/horatio/horatio_core/test/models/model_test.dart index 10e4e16..350db59 100644 --- a/horatio/horatio_core/test/models/model_test.dart +++ b/horatio/horatio_core/test/models/model_test.dart @@ -52,6 +52,7 @@ void main() { const horatio = Role(name: 'Horatio'); const testScript = Script( + id: 'test-id', title: 'Test', roles: [hamlet, horatio], scenes: [ @@ -80,6 +81,16 @@ void main() { ], ); + test('id field is accessible', () { + const script = Script( + id: 'test-uuid-123', + title: 'Test', + roles: [], + scenes: [], + ); + expect(script.id, 'test-uuid-123'); + }); + test('totalLineCount sums across scenes', () { expect(testScript.totalLineCount, 3); }); @@ -233,4 +244,297 @@ void main() { expect(segment.toString(), 'Diff(match: hello)'); }); }); + + group('TextMark', () { + final now = DateTime.utc(2026, 3, 29, 12); + + TextMark makeMark({ + String id = 'mark-1', + int lineIndex = 0, + int startOffset = 0, + int endOffset = 5, + MarkType type = MarkType.stress, + DateTime? createdAt, + }) => TextMark( + id: id, + lineIndex: lineIndex, + startOffset: startOffset, + endOffset: endOffset, + type: type, + createdAt: createdAt ?? now, + ); + + test('construction with valid offsets', () { + final mark = makeMark(); + expect(mark.id, 'mark-1'); + expect(mark.lineIndex, 0); + expect(mark.startOffset, 0); + expect(mark.endOffset, 5); + expect(mark.type, MarkType.stress); + expect(mark.createdAt, now); + }); + + test('equality uses id only', () { + final a = makeMark(); + final b = makeMark(type: MarkType.pause, endOffset: 10); + final c = makeMark(id: 'mark-2'); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + expect(a == a, isTrue); + }); + + test('hashCode consistent with equality', () { + final a = makeMark(); + final b = makeMark(type: MarkType.pause, endOffset: 10); + expect(a.hashCode, b.hashCode); + }); + + test('assert fails for negative startOffset', () { + expect(() => makeMark(startOffset: -1), throwsA(isA())); + }); + + test('assert fails when endOffset <= startOffset', () { + expect( + () => makeMark(startOffset: 5, endOffset: 4), + throwsA(isA()), + ); + expect( + () => makeMark(startOffset: 3, endOffset: 3), + throwsA(isA()), + ); + }); + + test('toJson roundtrip', () { + final original = makeMark(); + final json = original.toJson(); + final restored = TextMark.fromJson(json); + + expect(restored.id, original.id); + expect(restored.lineIndex, original.lineIndex); + expect(restored.startOffset, original.startOffset); + expect(restored.endOffset, original.endOffset); + expect(restored.type, original.type); + expect(restored.createdAt, original.createdAt); + }); + + test('fromJson with invalid type throws ArgumentError', () { + final json = makeMark().toJson()..['type'] = 'invalid'; + expect(() => TextMark.fromJson(json), throwsArgumentError); + }); + + test('toJson serializes all MarkType values', () { + for (final type in MarkType.values) { + final mark = makeMark(type: type); + final json = mark.toJson(); + final restored = TextMark.fromJson(json); + expect(restored.type, type); + } + }); + }); + + group('LineNote', () { + final now = DateTime.utc(2026, 3, 29, 12); + + LineNote makeNote({ + String id = 'note-1', + int lineIndex = 0, + NoteCategory category = NoteCategory.intention, + String text = 'Seeking revenge', + DateTime? createdAt, + }) => LineNote( + id: id, + lineIndex: lineIndex, + category: category, + text: text, + createdAt: createdAt ?? now, + ); + + test('construction fields accessible', () { + final note = makeNote(); + expect(note.id, 'note-1'); + expect(note.lineIndex, 0); + expect(note.category, NoteCategory.intention); + expect(note.text, 'Seeking revenge'); + expect(note.createdAt, now); + }); + + test('equality uses id only', () { + final a = makeNote(); + final b = makeNote( + text: 'Different text', + category: NoteCategory.subtext, + ); + final c = makeNote(id: 'note-2'); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + expect(a == a, isTrue); + }); + + test('hashCode consistent with equality', () { + final a = makeNote(); + final b = makeNote(text: 'Different', category: NoteCategory.blocking); + expect(a.hashCode, b.hashCode); + }); + + test('toJson roundtrip', () { + final original = makeNote(); + final json = original.toJson(); + final restored = LineNote.fromJson(json); + + expect(restored.id, original.id); + expect(restored.lineIndex, original.lineIndex); + expect(restored.category, original.category); + expect(restored.text, original.text); + expect(restored.createdAt, original.createdAt); + }); + + test('fromJson with invalid category throws ArgumentError', () { + final json = makeNote().toJson()..['category'] = 'invalid'; + expect(() => LineNote.fromJson(json), throwsArgumentError); + }); + + test('toJson serializes all NoteCategory values', () { + for (final category in NoteCategory.values) { + final note = makeNote(category: category); + final json = note.toJson(); + final restored = LineNote.fromJson(json); + expect(restored.category, category); + } + }); + }); + + group('AnnotationSnapshot', () { + final now = DateTime.utc(2026, 3, 29, 12); + + TextMark sampleMark() => TextMark( + id: 'mark-snap-1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: now, + ); + + LineNote sampleNote() => LineNote( + id: 'note-snap-1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'Seeking revenge', + createdAt: now, + ); + + test('construction with unmodifiable lists', () { + final snapshot = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'script-1', + timestamp: now, + marks: [sampleMark()], + notes: [sampleNote()], + ); + + expect(snapshot.marks, hasLength(1)); + expect(snapshot.notes, hasLength(1)); + expect(() => snapshot.marks.add(sampleMark()), throwsUnsupportedError); + expect(() => snapshot.notes.add(sampleNote()), throwsUnsupportedError); + }); + + test('equality uses id only', () { + final a = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'script-1', + timestamp: now, + marks: const [], + notes: const [], + ); + final b = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'different-script', + timestamp: now.add(const Duration(hours: 1)), + marks: [sampleMark()], + notes: [sampleNote()], + ); + final c = AnnotationSnapshot( + id: 'snap-2', + scriptId: 'script-1', + timestamp: now, + marks: const [], + notes: const [], + ); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + expect(a == a, isTrue); + }); + + test('hashCode consistent with equality', () { + final a = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'script-1', + timestamp: now, + marks: const [], + notes: const [], + ); + final b = AnnotationSnapshot( + id: 'snap-1', + scriptId: 'other', + timestamp: now, + marks: [sampleMark()], + notes: const [], + ); + expect(a.hashCode, b.hashCode); + }); + + test('toJson roundtrip with empty lists', () { + final original = AnnotationSnapshot( + id: 'snap-empty', + scriptId: 'script-1', + timestamp: now, + marks: const [], + notes: const [], + ); + final json = original.toJson(); + final restored = AnnotationSnapshot.fromJson(json); + + expect(restored.id, original.id); + expect(restored.scriptId, original.scriptId); + expect(restored.timestamp, original.timestamp); + expect(restored.marks, isEmpty); + expect(restored.notes, isEmpty); + }); + + test('toJson roundtrip with populated lists', () { + final original = AnnotationSnapshot( + id: 'snap-full', + scriptId: 'script-1', + timestamp: now, + marks: [sampleMark()], + notes: [sampleNote()], + ); + final json = original.toJson(); + final restored = AnnotationSnapshot.fromJson(json); + + expect(restored.id, original.id); + expect(restored.scriptId, original.scriptId); + expect(restored.timestamp, original.timestamp); + expect(restored.marks, hasLength(1)); + expect(restored.marks.first.id, 'mark-snap-1'); + expect(restored.marks.first.type, MarkType.stress); + expect(restored.notes, hasLength(1)); + expect(restored.notes.first.id, 'note-snap-1'); + expect(restored.notes.first.category, NoteCategory.intention); + }); + + test('fromJson with malformed DateTime throws FormatException', () { + final json = { + 'id': 'snap-bad', + 'scriptId': 'script-1', + 'timestamp': 'not-a-date', + 'marks': [], + 'notes': [], + }; + expect(() => AnnotationSnapshot.fromJson(json), throwsFormatException); + }); + }); } diff --git a/horatio/horatio_core/test/parser/text_parser_test.dart b/horatio/horatio_core/test/parser/text_parser_test.dart index bc1df69..7125e47 100644 --- a/horatio/horatio_core/test/parser/text_parser_test.dart +++ b/horatio/horatio_core/test/parser/text_parser_test.dart @@ -27,6 +27,12 @@ HAMLET: I pray thee, do not mock me, fellow-student. expect(result.scenes.first.lines, hasLength(3)); }); + test('parse assigns a non-empty UUID id to the script', () { + final result = parser.parse(content: 'HAMLET: To be', title: 'Test'); + expect(result.id, isNotEmpty); + expect(result.id, matches(RegExp(r'^[0-9a-f-]{36}$'))); + }); + test('parses screenplay format with scene headings', () { const script = ''' ACT I diff --git a/horatio/horatio_core/test/planner/planner_test.dart b/horatio/horatio_core/test/planner/planner_test.dart index 61afe23..7e2e423 100644 --- a/horatio/horatio_core/test/planner/planner_test.dart +++ b/horatio/horatio_core/test/planner/planner_test.dart @@ -117,6 +117,7 @@ void main() { const horatio = Role(name: 'Horatio'); return const Script( + id: 'test-planner-id', title: 'Test Script', roles: [hamlet, horatio], scenes: [ @@ -180,6 +181,7 @@ void main() { const horatio = Role(name: 'Horatio'); const script = Script( + id: 'monologue-test-id', title: 'Monologue Test', roles: [hamlet, horatio], scenes: [ diff --git a/horatio/run.sh b/horatio/run.sh index dcece91..b25a875 100755 --- a/horatio/run.sh +++ b/horatio/run.sh @@ -241,6 +241,19 @@ app_get() { cache_step app_get "$h" } +app_codegen() { + local h + h=$(files_hash "$APP_DIR/lib/database" -name '*.dart' ! -name '*.g.dart') + if step_cached app_codegen "$h"; then + echo " [cached] app_codegen — skipping" + return + fi + heading "Running drift codegen" + cd "$APP_DIR" + dart run build_runner build --delete-conflicting-outputs + cache_step app_codegen "$h" +} + app_analyze() { local h h=$(files_hash "$APP_DIR" -name '*.dart' -o -name 'analysis_options.yaml') @@ -264,7 +277,13 @@ app_test() { heading "Testing horatio_app (with coverage)" cd "$APP_DIR" flutter test --coverage - check_coverage "$APP_DIR/coverage/lcov.info" "horatio_app" 100 + + # Filter generated files from coverage (drift codegen + table schemas). + local lcov="$APP_DIR/coverage/lcov.info" + awk '/^SF:.*(\.g\.dart|tables\/)/{skip=1} /^end_of_record/{if(skip){skip=0;next}} !skip' \ + "$lcov" > "${lcov}.tmp" && mv "${lcov}.tmp" "$lcov" + + check_coverage "$lcov" "horatio_app" 100 cache_step app_test "$h" } @@ -314,6 +333,7 @@ do_analyze() { core_analyze ensure_flutter app_get + app_codegen do_dead_code } @@ -323,6 +343,7 @@ do_test() { core_test ensure_flutter app_get + app_codegen app_test } @@ -340,6 +361,7 @@ do_run() { ensure_whisper core_get app_get + app_codegen app_analyze app_build app_run @@ -351,6 +373,7 @@ do_web() { ensure_whisper core_get app_get + app_codegen app_analyze app_web }