mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 13:03:15 +02:00
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:
parent
603b35cdd8
commit
85edd6ba02
2980
horatio/docs/superpowers/plans/2026-03-29-annotations.md
Normal file
2980
horatio/docs/superpowers/plans/2026-03-29-annotations.md
Normal file
File diff suppressed because it is too large
Load Diff
491
horatio/docs/superpowers/specs/2026-03-29-annotations-design.md
Normal file
491
horatio/docs/superpowers/specs/2026-03-29-annotations-design.md
Normal 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.
|
||||
@ -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: [
|
||||
|
||||
143
horatio/horatio_app/lib/bloc/annotation/annotation_cubit.dart
Normal file
143
horatio/horatio_app/lib/bloc/annotation/annotation_cubit.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
23
horatio/horatio_app/lib/database/app_database.dart
Normal file
23
horatio/horatio_app/lib/database/app_database.dart
Normal 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;
|
||||
}
|
||||
1918
horatio/horatio_app/lib/database/app_database.g.dart
Normal file
1918
horatio/horatio_app/lib/database/app_database.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
188
horatio/horatio_app/lib/database/daos/annotation_dao.dart
Normal file
188
horatio/horatio_app/lib/database/daos/annotation_dao.dart
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
32
horatio/horatio_app/lib/database/daos/annotation_dao.g.dart
Normal file
32
horatio/horatio_app/lib/database/daos/annotation_dao.g.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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};
|
||||
}
|
||||
@ -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};
|
||||
}
|
||||
@ -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};
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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')),
|
||||
|
||||
204
horatio/horatio_app/lib/screens/annotation_editor_screen.dart
Normal file
204
horatio/horatio_app/lib/screens/annotation_editor_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
horatio/horatio_app/lib/screens/annotation_history_screen.dart
Normal file
102
horatio/horatio_app/lib/screens/annotation_history_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
87
horatio/horatio_app/lib/widgets/mark_overlay.dart
Normal file
87
horatio/horatio_app/lib/widgets/mark_overlay.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
53
horatio/horatio_app/lib/widgets/mark_type_picker.dart
Normal file
53
horatio/horatio_app/lib/widgets/mark_type_picker.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
121
horatio/horatio_app/lib/widgets/note_editor_sheet.dart
Normal file
121
horatio/horatio_app/lib/widgets/note_editor_sheet.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
42
horatio/horatio_app/lib/widgets/note_indicator.dart
Normal file
42
horatio/horatio_app/lib/widgets/note_indicator.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
275
horatio/horatio_app/test/bloc/annotation_cubit_test.dart
Normal file
275
horatio/horatio_app/test/bloc/annotation_cubit_test.dart
Normal 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([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
169
horatio/horatio_app/test/bloc/annotation_history_cubit_test.dart
Normal file
169
horatio/horatio_app/test/bloc/annotation_history_cubit_test.dart
Normal 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.
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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: [
|
||||
|
||||
@ -30,6 +30,7 @@ class FakeAssetBundle extends Fake implements AssetBundle {
|
||||
}
|
||||
|
||||
const _fallbackScript = Script(
|
||||
id: 'fallback-id',
|
||||
title: '',
|
||||
roles: [],
|
||||
scenes: [Scene(lines: [])],
|
||||
|
||||
175
horatio/horatio_app/test/database/annotation_dao_test.dart
Normal file
175
horatio/horatio_app/test/database/annotation_dao_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
5
horatio/horatio_app/test/helpers/test_database.dart
Normal file
5
horatio/horatio_app/test/helpers/test_database.dart
Normal 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());
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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: [
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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: [
|
||||
|
||||
129
horatio/horatio_app/test/widgets/mark_overlay_test.dart
Normal file
129
horatio/horatio_app/test/widgets/mark_overlay_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
60
horatio/horatio_app/test/widgets/mark_type_picker_test.dart
Normal file
60
horatio/horatio_app/test/widgets/mark_type_picker_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
158
horatio/horatio_app/test/widgets/note_editor_sheet_test.dart
Normal file
158
horatio/horatio_app/test/widgets/note_editor_sheet_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
53
horatio/horatio_app/test/widgets/note_indicator_test.dart
Normal file
53
horatio/horatio_app/test/widgets/note_indicator_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
62
horatio/horatio_core/lib/src/models/annotation_snapshot.dart
Normal file
62
horatio/horatio_core/lib/src/models/annotation_snapshot.dart
Normal 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(),
|
||||
};
|
||||
}
|
||||
55
horatio/horatio_core/lib/src/models/line_note.dart
Normal file
55
horatio/horatio_core/lib/src/models/line_note.dart
Normal 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(),
|
||||
};
|
||||
}
|
||||
20
horatio/horatio_core/lib/src/models/mark_type.dart
Normal file
20
horatio/horatio_core/lib/src/models/mark_type.dart
Normal 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,
|
||||
}
|
||||
@ -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';
|
||||
|
||||
20
horatio/horatio_core/lib/src/models/note_category.dart
Normal file
20
horatio/horatio_core/lib/src/models/note_category.dart
Normal 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,
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
67
horatio/horatio_core/lib/src/models/text_mark.dart
Normal file
67
horatio/horatio_core/lib/src/models/text_mark.dart
Normal 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(),
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user