feat: annotations subsystem — core models, drift DB, cubits, and UI

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.
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-03-29 17:59:26 +02:00
parent 00cb497acf
commit fa5ccdaa96
57 changed files with 9226 additions and 13 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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<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.
```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<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.
```dart
@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 own `scriptId`)
- `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).
```dart
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 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<AnnotationSnapshot> 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<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.

View File

@ -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<ScriptRepository>(
create: (_) => ScriptRepository(),
),
RepositoryProvider<AnnotationDao>(
create: (_) => database.annotationDao,
),
],
child: MultiBlocProvider(
providers: [

View File

@ -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<AnnotationState> {
/// Creates an [AnnotationCubit].
AnnotationCubit({required AnnotationDao dao})
: _dao = dao,
super(const AnnotationInitial());
final AnnotationDao _dao;
StreamSubscription<List<TextMark>>? _marksSub;
StreamSubscription<List<LineNote>>? _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 = <TextMark>[];
var latestNotes = <LineNote>[];
_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<TextMark> marks,
List<LineNote> 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<void> 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<void> removeMark(String id) => _dao.deleteMark(id);
/// Adds a line note.
Future<void> 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<void> updateNote(String id, String text) =>
_dao.updateNoteText(id, text);
/// Removes a note.
Future<void> removeNote(String id) => _dao.deleteNote(id);
@override
Future<void> close() {
_marksSub?.cancel();
_notesSub?.cancel();
return super.close();
}
}

View File

@ -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<AnnotationHistoryState> {
/// Creates an [AnnotationHistoryCubit].
AnnotationHistoryCubit({required AnnotationDao dao})
: _dao = dao,
super(const AnnotationHistoryInitial());
final AnnotationDao _dao;
StreamSubscription<List<AnnotationSnapshot>>? _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<void> saveSnapshot({
required List<TextMark> marks,
required List<LineNote> 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<void> restoreSnapshot(AnnotationSnapshot snapshot) async {
final scriptId = _scriptId;
if (scriptId == null) return;
await _dao.replaceAllAnnotations(
scriptId: scriptId,
marks: snapshot.marks,
notes: snapshot.notes,
);
}
@override
Future<void> close() {
_sub?.cancel();
return super.close();
}
}

View File

@ -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<Object?> 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<AnnotationSnapshot> snapshots;
@override
List<Object?> get props => [scriptId, snapshots];
}

View File

@ -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<TextMark> marks;
/// All line notes for this script.
final List<LineNote> 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<TextMark>? marks,
List<LineNote>? 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<Object?> get props => [lineIndex, isAddingMark];
}

View File

@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@ -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<AppDatabase>
with _$AnnotationDaoMixin {
/// Creates an [AnnotationDao].
AnnotationDao(super.db);
// -- TextMark CRUD --------------------------------------------------------
/// Watches all marks for a script.
Stream<List<TextMark>> 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<List<TextMark>> 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<void> 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<void> 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<List<LineNote>> 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<List<LineNote>> 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<void> 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<void> updateNoteText(String id, String text) =>
(update(lineNotesTable)..where((t) => t.id.equals(id)))
.write(LineNotesTableCompanion(noteText: Value(text)));
/// Deletes a note by ID.
Future<void> 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<List<AnnotationSnapshot>> 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<void> 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<String, dynamic>,
);
// 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<void> replaceAllAnnotations({
required String scriptId,
required List<TextMark> marks,
required List<LineNote> 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);
}
});
}

View File

@ -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<AppDatabase> {
$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,
);
}

View File

@ -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<Column> get primaryKey => {id};
}

View File

@ -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<Column> get primaryKey => {id};
}

View File

@ -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<Column> get primaryKey => {id};
}

View File

@ -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),
),
);
}

View File

@ -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')),

View File

@ -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<AnnotationDao>();
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<ScriptLine> 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<AnnotationCubit, AnnotationState>(
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<AnnotationCubit, AnnotationState>(
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<AnnotationHistoryCubit>().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<TextMark> marks;
final List<LineNote> 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<AnnotationCubit>().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<AnnotationCubit>();
showDialog<void>(
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<AnnotationCubit>();
showModalBottomSheet<void>(
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),
),
),
);
}
}

View File

@ -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<AnnotationDao>())
..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<AnnotationHistoryCubit, AnnotationHistoryState>(
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<AnnotationHistoryCubit>();
showDialog<void>(
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'),
),
],
),
);
}
}

View File

@ -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);
},
),
],
),
),

View File

@ -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<MarkType, Color> 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<TextMark> 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<TextSpan> _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 = <TextSpan>[];
var cursor = 0;
final activeTypes = <MarkType>[];
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;
}
}

View File

@ -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<MarkType> 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'),
),
],
);
}

View File

@ -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<NoteEditorSheet> createState() => _NoteEditorSheetState();
}
class _NoteEditorSheetState extends State<NoteEditorSheet> {
late NoteCategory _category;
late TextEditingController _textController;
final _formKey = GlobalKey<FormState>();
@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<NoteCategory>(
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());
}
}
}

View File

@ -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,
),
),
),
);
}
}

View File

@ -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"

View File

@ -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

View File

@ -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>[
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();
});
}

View File

@ -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<List<TextMark>> marksController;
late StreamController<List<LineNote>> 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<List<TextMark>>.broadcast();
notesController = StreamController<List<LineNote>>.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<AnnotationInitial>());
cubit.close();
});
test('loadAnnotations subscribes and emits on marks stream', () async {
final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId);
marksController.add([testMark]);
await Future<void>.delayed(Duration.zero);
final state = cubit.state;
expect(state, isA<AnnotationLoaded>());
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<void>.delayed(Duration.zero);
final state = cubit.state;
expect(state, isA<AnnotationLoaded>());
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<void>.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<void>.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<AnnotationInitial>());
cubit.close();
});
test('startEditing / cancelEditing toggle EditingContext', () async {
final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId);
marksController.add([]);
await Future<void>.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<AnnotationInitial>());
cubit.close();
});
test('cancelEditing is no-op when state is AnnotationInitial', () {
final cubit = AnnotationCubit(dao: dao);
cubit.cancelEditing();
expect(cubit.state, isA<AnnotationInitial>());
cubit.close();
});
test('selectedLineIndex preserved across stream updates', () async {
final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId);
marksController.add([]);
await Future<void>.delayed(Duration.zero);
cubit.selectLine(5);
marksController.add([testMark]); // stream update
await Future<void>.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<void>.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<void>.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<void>.delayed(Duration.zero);
final marks2 = StreamController<List<TextMark>>.broadcast();
final notes2 = StreamController<List<LineNote>>.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<void>.delayed(Duration.zero);
final state = cubit.state;
expect(state, isA<AnnotationLoaded>());
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([]);
});
});
}

View File

@ -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<List<AnnotationSnapshot>> 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<List<AnnotationSnapshot>>.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<AnnotationHistoryInitial>());
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<void>.delayed(Duration.zero);
final state = cubit.state;
expect(state, isA<AnnotationHistoryLoaded>());
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<void>.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<void>.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<void>.delayed(Duration.zero);
final snapshots2 = StreamController<List<AnnotationSnapshot>>.broadcast();
when(() => dao.watchSnapshotsForScript('script-2'))
.thenAnswer((_) => snapshots2.stream);
cubit.loadSnapshots('script-2');
snapshots2.add([]);
await Future<void>.delayed(Duration.zero);
final state = cubit.state;
expect(state, isA<AnnotationHistoryLoaded>());
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.
});
});
}

View File

@ -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: [

View File

@ -30,6 +30,7 @@ class FakeAssetBundle extends Fake implements AssetBundle {
}
const _fallbackScript = Script(
id: 'fallback-id',
title: '',
roles: [],
scenes: [Scene(lines: [])],

View File

@ -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<void>.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<void>.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<void>.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');
});
});
}

View File

@ -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());

View File

@ -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<ScriptRepository>(create: (_) => repository),
RepositoryProvider<AnnotationDao>.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);
});
});
}

View File

@ -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<List<TextMark>> _marksCtrl;
late StreamController<List<LineNote>> _notesCtrl;
late StreamController<List<AnnotationSnapshot>> _snapshotsCtrl;
void _setUpDao() {
_dao = MockAnnotationDao();
_marksCtrl = StreamController<List<TextMark>>.broadcast();
_notesCtrl = StreamController<List<LineNote>>.broadcast();
_snapshotsCtrl = StreamController<List<AnnotationSnapshot>>.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<AnnotationDao>.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<AnnotationDao>.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<Container>(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);
});
});
}

View File

@ -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<List<AnnotationSnapshot>> _snapshotsCtrl;
void _setUpDao() {
_dao = MockAnnotationDao();
_snapshotsCtrl = StreamController<List<AnnotationSnapshot>>.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<AnnotationDao>.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);
});
});
}

View File

@ -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: [

View File

@ -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: [

View File

@ -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: [

View File

@ -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);
});
});
}

View File

@ -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: [

View File

@ -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<RichText>(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<RichText>(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<RichText>(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 = <Color>{};
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<RichText>(find.byType(RichText));
expect(richText.text, isA<TextSpan>());
// 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<RichText>(find.byType(RichText));
final span = richText.text as TextSpan;
expect(span.style?.fontSize, 24);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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<NoteCategory>));
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<NoteCategory>));
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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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<TextMark> marks,
required List<LineNote> notes,
}) : marks = List.unmodifiable(marks),
notes = List.unmodifiable(notes);
/// Deserializes from a JSON map.
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(),
);
/// 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<TextMark> marks;
/// All line notes at snapshot time.
final List<LineNote> 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<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(),
};
}

View File

@ -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<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),
);
/// 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<String, dynamic> toJson() => {
'id': id,
'lineIndex': lineIndex,
'category': category.name,
'text': text,
'createdAt': createdAt.toUtc().toIso8601String(),
};
}

View File

@ -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,
}

View File

@ -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';

View File

@ -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,
}

View File

@ -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;

View File

@ -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<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),
);
/// 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<String, dynamic> toJson() => {
'id': id,
'lineIndex': lineIndex,
'startOffset': startOffset,
'endOffset': endOffset,
'type': type.name,
'createdAt': createdAt.toUtc().toIso8601String(),
};
}

View File

@ -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),

View File

@ -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

View File

@ -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<AssertionError>()));
});
test('assert fails when endOffset <= startOffset', () {
expect(
() => makeMark(startOffset: 5, endOffset: 4),
throwsA(isA<AssertionError>()),
);
expect(
() => makeMark(startOffset: 3, endOffset: 3),
throwsA(isA<AssertionError>()),
);
});
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': <dynamic>[],
'notes': <dynamic>[],
};
expect(() => AnnotationSnapshot.fromJson(json), throwsFormatException);
});
});
}

View File

@ -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

View File

@ -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: [

View File

@ -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
}