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<AnnotationDao> - 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.
15 KiB
Horatio — Annotations Subsystem Design Spec
Overview
Add two layers of annotations to script lines:
- Text marks — span-based delivery marks on words/syllables (stress, pause, breath, emphasis, tempo changes)
- 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
ScriptLineviascriptId(UUID) +lineIndex - This spec also introduces a
scriptIdfield onScriptto provide a stable unique identifier for annotation binding (titles can collide)
Data Models (horatio_core)
MarkType Enum
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
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.
@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<String, dynamic> toJson() => {
'id': id,
'lineIndex': lineIndex,
'startOffset': startOffset,
'endOffset': endOffset,
'type': type.name,
'createdAt': createdAt.toUtc().toIso8601String(),
};
factory TextMark.fromJson(Map<String, dynamic> 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.
@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<String, dynamic> toJson() => {
'id': id,
'lineIndex': lineIndex,
'category': category.name,
'text': text,
'createdAt': createdAt.toUtc().toIso8601String(),
};
factory LineNote.fromJson(Map<String, dynamic> 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.
@immutable
final class AnnotationSnapshot {
AnnotationSnapshot({
required this.id,
required this.scriptId,
required this.timestamp,
required List<TextMark> marks,
required List<LineNote> notes,
}) : marks = List.unmodifiable(marks),
notes = List.unmodifiable(notes);
final String id; // UUID
final String scriptId;
final DateTime timestamp;
final List<TextMark> marks; // Unmodifiable
final List<LineNote> notes; // Unmodifiable
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AnnotationSnapshot && id == other.id;
@override
int get hashCode => id.hashCode;
Map<String, dynamic> 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<String, dynamic> json) =>
AnnotationSnapshot(
id: json['id'] as String,
scriptId: json['scriptId'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
marks: (json['marks'] as List<dynamic>)
.map((e) => TextMark.fromJson(e as Map<String, dynamic>))
.toList(),
notes: (json['notes'] as List<dynamic>)
.map((e) => LineNote.fromJson(e as Map<String, dynamic>))
.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<List<TextMark>>watchNotesForScript(scriptId)→Stream<List<LineNote>>watchSnapshotsForScript(scriptId)→Stream<List<AnnotationSnapshot>>insertMark(scriptId, TextMark),deleteMark(id)insertNote(scriptId, LineNote),updateNoteText(id, text),deleteNote(id)insertSnapshot(AnnotationSnapshot)(snapshot carries its ownscriptId)getMarksForLine(scriptId, lineIndex)→Future<List<TextMark>>getNotesForLine(scriptId, lineIndex)→Future<List<LineNote>>
State Management
AnnotationCubit
Handles annotation CRUD. Snapshot management is in a separate cubit (below).
sealed class AnnotationState extends Equatable {}
final class AnnotationInitial extends AnnotationState {}
final class AnnotationLoaded extends AnnotationState {
final String scriptId;
final List<TextMark> marks;
final List<LineNote> 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 streamsselectLine(lineIndex)— focus a line for annotationstartEditing(lineIndex, isAddingMark)— enter editing modecancelEditing()— exit editing modeaddMark(lineIndex, startOffset, endOffset, MarkType)— create TextMarkremoveMark(id)— delete a markaddNote(lineIndex, NoteCategory, text)— create LineNoteupdateNote(id, text)— edit note contentremoveNote(id)— delete a note
AnnotationHistoryCubit
Separate cubit for snapshot management (SRP: CRUD vs history are independent concerns).
sealed class AnnotationHistoryState extends Equatable {}
final class AnnotationHistoryInitial extends AnnotationHistoryState {}
final class AnnotationHistoryLoaded extends AnnotationHistoryState {
final String scriptId;
final List<AnnotationSnapshot> snapshots;
}
Methods:
loadSnapshots(scriptId)— subscribe to drift watch streamsaveSnapshot()— capture current marks + notes as a snapshotrestoreSnapshot(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
MarkTypehas 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
AnnotationSnapshotentries - 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, immutabilityAnnotationSnapshot: construction with unmodifiable lists, serialization roundtrip, empty marks/notes roundtrip, invalid enum names in JSON (expectArgumentError), malformed DateTime strings (expectFormatException)MarkType/NoteCategory: all enum values covered in serializationScript.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.0sqlite3_flutter_libs: ^0.6.0path_provider: ^2.1.0equatable: ^2.0.7
horatio_app (new dev dependencies):
build_runner— drift codegen runnerdrift_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.
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<Role> roles;
final List<Scene> 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.