From c52969d8bbc2fd8ff70c44eb4a20160165601d08 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 29 Mar 2026 21:11:43 +0200 Subject: [PATCH] feat(screen): integrate recording UI, note chips, and recording badges --- .../font-annotations-recording/00-header.md | 72 ++ .../01-font-scaling.md | 830 ++++++++++++ .../02-word-level-marks.md | 537 ++++++++ .../03-recording-infrastructure.md | 510 ++++++++ .../04-recording-services-cubit.md | 1018 +++++++++++++++ .../05-recording-ui-notes.md | 1117 +++++++++++++++++ .../06-screen-integration.md | 558 ++++++++ ...03-29-font-annotations-recording-design.md | 468 +++++++ horatio/horatio_app/.metadata | 2 +- horatio/horatio_app/lib/app.dart | 124 +- .../lib/bloc/annotation/annotation_cubit.dart | 51 +- .../lib/bloc/recording/recording_cubit.dart | 233 ++++ .../lib/bloc/recording/recording_state.dart | 116 ++ .../lib/bloc/text_scale/text_scale_cubit.dart | 47 + .../lib/bloc/text_scale/text_scale_state.dart | 13 + .../lib/database/app_database.dart | 26 +- .../lib/database/app_database.g.dart | 732 +++++++++++ .../lib/database/daos/annotation_dao.dart | 112 +- .../lib/database/daos/recording_dao.dart | 56 + .../lib/database/daos/recording_dao.g.dart | 20 + .../tables/line_recordings_table.dart | 18 + horatio/horatio_app/lib/main.dart | 9 +- .../lib/screens/annotation_editor_screen.dart | 464 +++++-- .../horatio_app/lib/screens/home_screen.dart | 19 +- .../lib/services/audio_playback_service.dart | 50 + .../lib/services/recording_service.dart | 39 + .../horatio_app/lib/widgets/grade_stars.dart | 39 + .../lib/widgets/mark_selection_toolbar.dart | 50 + .../horatio_app/lib/widgets/note_chip.dart | 45 + .../lib/widgets/note_editor_sheet.dart | 127 +- .../lib/widgets/recording_action_bar.dart | 78 ++ .../lib/widgets/recording_badge.dart | 39 + .../lib/widgets/recording_list_sheet.dart | 70 ++ .../widgets/text_scale_settings_sheet.dart | 52 + .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + horatio/horatio_app/pubspec.lock | 74 +- horatio/horatio_app/pubspec.yaml | 2 + horatio/horatio_app/test/app_test.dart | 58 +- .../test/bloc/annotation_cubit_test.dart | 115 +- .../test/bloc/recording_cubit_test.dart | 401 ++++++ .../test/bloc/text_scale_cubit_test.dart | 100 ++ .../test/database/annotation_dao_test.dart | 63 +- .../test/database/app_database_test.dart | 42 + .../test/database/recording_dao_test.dart | 80 ++ horatio/horatio_app/test/router_test.dart | 119 +- .../annotation_editor_screen_test.dart | 725 +++++++++-- .../test/screens/home_screen_test.dart | 41 +- .../services/audio_playback_service_test.dart | 115 ++ .../test/services/recording_service_test.dart | 98 ++ .../test/widgets/grade_stars_test.dart | 86 ++ .../widgets/mark_selection_toolbar_test.dart | 61 + .../test/widgets/note_chip_test.dart | 100 ++ .../test/widgets/note_editor_sheet_test.dart | 56 +- .../widgets/recording_action_bar_test.dart | 132 ++ .../test/widgets/recording_badge_test.dart | 47 + .../widgets/recording_list_sheet_test.dart | 138 ++ .../text_scale_settings_sheet_test.dart | 56 + horatio/horatio_app/web/index.html | 46 + horatio/horatio_app/web/manifest.json | 35 + .../lib/src/models/line_recording.dart | 66 + .../horatio_core/lib/src/models/models.dart | 1 + .../test/models/line_recording_test.dart | 89 ++ .../test/planner/planner_test.dart | 10 + horatio/run.sh | 2 +- 65 files changed, 10229 insertions(+), 475 deletions(-) create mode 100644 horatio/docs/superpowers/plans/font-annotations-recording/00-header.md create mode 100644 horatio/docs/superpowers/plans/font-annotations-recording/01-font-scaling.md create mode 100644 horatio/docs/superpowers/plans/font-annotations-recording/02-word-level-marks.md create mode 100644 horatio/docs/superpowers/plans/font-annotations-recording/03-recording-infrastructure.md create mode 100644 horatio/docs/superpowers/plans/font-annotations-recording/04-recording-services-cubit.md create mode 100644 horatio/docs/superpowers/plans/font-annotations-recording/05-recording-ui-notes.md create mode 100644 horatio/docs/superpowers/plans/font-annotations-recording/06-screen-integration.md create mode 100644 horatio/docs/superpowers/specs/2026-03-29-font-annotations-recording-design.md create mode 100644 horatio/horatio_app/lib/bloc/recording/recording_cubit.dart create mode 100644 horatio/horatio_app/lib/bloc/recording/recording_state.dart create mode 100644 horatio/horatio_app/lib/bloc/text_scale/text_scale_cubit.dart create mode 100644 horatio/horatio_app/lib/bloc/text_scale/text_scale_state.dart create mode 100644 horatio/horatio_app/lib/database/daos/recording_dao.dart create mode 100644 horatio/horatio_app/lib/database/daos/recording_dao.g.dart create mode 100644 horatio/horatio_app/lib/database/tables/line_recordings_table.dart create mode 100644 horatio/horatio_app/lib/services/audio_playback_service.dart create mode 100644 horatio/horatio_app/lib/services/recording_service.dart create mode 100644 horatio/horatio_app/lib/widgets/grade_stars.dart create mode 100644 horatio/horatio_app/lib/widgets/mark_selection_toolbar.dart create mode 100644 horatio/horatio_app/lib/widgets/note_chip.dart create mode 100644 horatio/horatio_app/lib/widgets/recording_action_bar.dart create mode 100644 horatio/horatio_app/lib/widgets/recording_badge.dart create mode 100644 horatio/horatio_app/lib/widgets/recording_list_sheet.dart create mode 100644 horatio/horatio_app/lib/widgets/text_scale_settings_sheet.dart create mode 100644 horatio/horatio_app/test/bloc/recording_cubit_test.dart create mode 100644 horatio/horatio_app/test/bloc/text_scale_cubit_test.dart create mode 100644 horatio/horatio_app/test/database/app_database_test.dart create mode 100644 horatio/horatio_app/test/database/recording_dao_test.dart create mode 100644 horatio/horatio_app/test/services/audio_playback_service_test.dart create mode 100644 horatio/horatio_app/test/services/recording_service_test.dart create mode 100644 horatio/horatio_app/test/widgets/grade_stars_test.dart create mode 100644 horatio/horatio_app/test/widgets/mark_selection_toolbar_test.dart create mode 100644 horatio/horatio_app/test/widgets/note_chip_test.dart create mode 100644 horatio/horatio_app/test/widgets/recording_action_bar_test.dart create mode 100644 horatio/horatio_app/test/widgets/recording_badge_test.dart create mode 100644 horatio/horatio_app/test/widgets/recording_list_sheet_test.dart create mode 100644 horatio/horatio_app/test/widgets/text_scale_settings_sheet_test.dart create mode 100644 horatio/horatio_app/web/index.html create mode 100644 horatio/horatio_app/web/manifest.json create mode 100644 horatio/horatio_core/lib/src/models/line_recording.dart create mode 100644 horatio/horatio_core/test/models/line_recording_test.dart diff --git a/horatio/docs/superpowers/plans/font-annotations-recording/00-header.md b/horatio/docs/superpowers/plans/font-annotations-recording/00-header.md new file mode 100644 index 0000000..1c2a9b1 --- /dev/null +++ b/horatio/docs/superpowers/plans/font-annotations-recording/00-header.md @@ -0,0 +1,72 @@ +nbbbfv# Font Scaling, Word-Level Marks, Voice Recording & Note UX — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Horatio app usable on 4K displays with auto-responsive font scaling + manual control, replace whole-line marks with word-level text selection, add per-line voice recording with playback and grading, and improve note UX with inline chips and edit/delete. + +**Architecture:** Incremental feature layering on the existing Drift + flutter_bloc stack. Each chunk builds on the previous: font scaling is independent, word-level marks replace the existing long-press flow, recording adds a new data layer (model → table → DAO → service → cubit → UI), note UX enhances existing cubit/DAO/widgets. + +**Tech Stack:** Flutter 3.10+, Dart 3.11+, flutter_bloc, Drift (SQLite), shared_preferences, audioplayers ^6.1.0, record ^6.2.0 (already in pubspec), mocktail, bloc_test + +**Spec:** `docs/superpowers/specs/2026-03-29-font-annotations-recording-design.md` + +**Pipeline:** `./run.sh test` runs analyze + codegen + test with 100% branch coverage. `.g.dart` and `tables/` files are filtered from coverage. + +--- + +## File Structure + +### New Files + +| File | Responsibility | +| -------------------------------------------------------------- | ------------------------------------------------------------- | +| `horatio_app/lib/bloc/text_scale/text_scale_cubit.dart` | Text scale factor management + SharedPreferences persistence | +| `horatio_app/lib/bloc/text_scale/text_scale_state.dart` | Equatable state for TextScaleCubit | +| `horatio_app/lib/widgets/text_scale_settings_sheet.dart` | Bottom sheet with slider 0.5–3.0x + reset button | +| `horatio_app/test/bloc/text_scale_cubit_test.dart` | Unit tests for TextScaleCubit | +| `horatio_app/test/widgets/text_scale_settings_sheet_test.dart` | Widget tests for settings sheet | +| `horatio_app/lib/widgets/mark_selection_toolbar.dart` | Floating toolbar with 6 colored chips for mark type selection | +| `horatio_app/test/widgets/mark_selection_toolbar_test.dart` | Widget tests for toolbar | +| `horatio_core/lib/src/models/line_recording.dart` | Immutable model for voice recordings | +| `horatio_core/test/models/line_recording_test.dart` | JSON round-trip + equality tests | +| `horatio_app/lib/database/tables/line_recordings_table.dart` | Drift table definition | +| `horatio_app/lib/database/daos/recording_dao.dart` | CRUD DAO for recordings | +| `horatio_app/test/database/recording_dao_test.dart` | Integration tests for RecordingDao | +| `horatio_app/lib/services/recording_service.dart` | Wraps `record` package for mic capture | +| `horatio_app/lib/services/audio_playback_service.dart` | Wraps `audioplayers` for playback | +| `horatio_app/test/services/recording_service_test.dart` | Mock-based unit tests | +| `horatio_app/test/services/audio_playback_service_test.dart` | Mock-based unit tests | +| `horatio_app/lib/bloc/recording/recording_cubit.dart` | State machine for record/play/grade lifecycle | +| `horatio_app/lib/bloc/recording/recording_state.dart` | Recording state hierarchy | +| `horatio_app/test/bloc/recording_cubit_test.dart` | Full branch coverage cubit tests | +| `horatio_app/lib/widgets/grade_stars.dart` | 0–5 star grading widget | +| `horatio_app/lib/widgets/recording_action_bar.dart` | Record/Play/Grade bottom bar | +| `horatio_app/lib/widgets/recording_badge.dart` | Mic icon + count badge per line | +| `horatio_app/lib/widgets/recording_list_sheet.dart` | Bottom sheet listing all recordings for a line | +| `horatio_app/lib/widgets/note_chip.dart` | Tappable inline note chip | +| `horatio_app/test/widgets/grade_stars_test.dart` | Widget tests | +| `horatio_app/test/widgets/recording_action_bar_test.dart` | Widget tests | +| `horatio_app/test/widgets/recording_badge_test.dart` | Widget tests | +| `horatio_app/test/widgets/recording_list_sheet_test.dart` | Widget tests | +| `horatio_app/test/widgets/note_chip_test.dart` | Widget tests | + +### Modified Files + +| File | Changes | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `horatio_app/pubspec.yaml` | Add `shared_preferences ^2.3.0`, `audioplayers ^6.1.0` | +| `horatio_core/lib/src/models/models.dart` | Export `line_recording.dart` | +| `horatio_app/lib/database/app_database.dart` | Add LineRecordingsTable, bump schema v1→v2, add MigrationStrategy | +| `horatio_app/lib/database/daos/annotation_dao.dart` | Add `updateNoteCategory` method | +| `horatio_app/lib/bloc/annotation/annotation_cubit.dart` | Change `updateNote` to accept optional category | +| `horatio_app/lib/app.dart` | Add TextScaleCubit, RecordingDao, services to providers; wrap with MediaQuery | +| `horatio_app/lib/main.dart` | Init SharedPreferences, pass to TextScaleCubit | +| `horatio_app/lib/screens/annotation_editor_screen.dart` | Word selection, recording UI, note chips, settings icon | +| `horatio_app/lib/screens/home_screen.dart` | Add settings icon to AppBar | +| `horatio_app/lib/widgets/note_editor_sheet.dart` | Add `noteId` parameter for edit mode | +| `horatio_app/test/bloc/annotation_cubit_test.dart` | Tests for updated updateNote | +| `horatio_app/test/screens/annotation_editor_screen_test.dart` | Tests for new interactions | +| `horatio_app/test/app_test.dart` | Update for new providers | +| `horatio_app/test/helpers/test_database.dart` | No changes needed (in-memory DB auto-migrates) | + +--- diff --git a/horatio/docs/superpowers/plans/font-annotations-recording/01-font-scaling.md b/horatio/docs/superpowers/plans/font-annotations-recording/01-font-scaling.md new file mode 100644 index 0000000..fecd381 --- /dev/null +++ b/horatio/docs/superpowers/plans/font-annotations-recording/01-font-scaling.md @@ -0,0 +1,830 @@ +## Chunk 1: Font Scaling + +### Task 1.1: Add shared_preferences dependency + +**Files:** + +- Modify: `horatio_app/pubspec.yaml` + +- [ ] **Step 1: Add dependency** + +In `horatio_app/pubspec.yaml`, add `shared_preferences: ^2.3.0` under `dependencies:` (after `path:`). Also add `audioplayers: ^6.1.0` (needed in Chunk 3 but add now to avoid re-running pub get). + +```yaml +path: ^1.9.0 +intl: ^0.20.2 +shared_preferences: ^2.3.0 +audioplayers: ^6.1.0 +horatio_core: +``` + +- [ ] **Step 2: Run pub get** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter pub get +``` + +Expected: resolves without errors. + +--- + +### Task 1.2: TextScaleState + +**Files:** + +- Create: `horatio_app/lib/bloc/text_scale/text_scale_state.dart` + +- [ ] **Step 1: Create state file** + +```dart +import 'package:equatable/equatable.dart'; + +/// State for [TextScaleCubit]. +final class TextScaleState extends Equatable { + /// Creates a [TextScaleState]. + const TextScaleState({required this.scaleFactor}); + + /// The text scale multiplier (0.5 – 3.0). + final double scaleFactor; + + @override + List get props => [scaleFactor]; +} +``` + +--- + +### Task 1.3: TextScaleCubit — failing tests + +**Files:** + +- Create: `horatio_app/test/bloc/text_scale_cubit_test.dart` +- Create: `horatio_app/lib/bloc/text_scale/text_scale_cubit.dart` + +- [ ] **Step 1: Write failing tests** + +```dart +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('TextScaleCubit', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('initial state has scaleFactor 1.0', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs); + expect(cubit.state, const TextScaleState(scaleFactor: 1.0)); + await cubit.close(); + }); + + test('loadScale reads saved value', () async { + SharedPreferences.setMockInitialValues({'text_scale_factor': 2.0}); + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs)..loadScale(); + await Future.delayed(Duration.zero); + expect(cubit.state, const TextScaleState(scaleFactor: 2.0)); + await cubit.close(); + }); + + test('loadScale uses 1.0 when no saved value', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs)..loadScale(); + await Future.delayed(Duration.zero); + expect(cubit.state, const TextScaleState(scaleFactor: 1.0)); + await cubit.close(); + }); + + test('setScale persists and emits', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs); + await cubit.setScale(1.8); + expect(cubit.state, const TextScaleState(scaleFactor: 1.8)); + expect(prefs.getDouble('text_scale_factor'), 1.8); + await cubit.close(); + }); + + test('autoDetect sets 1.5 for 4K desktop', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs); + cubit.autoDetect(const Size(1920, 1080), 2.0, isDesktop: true); + // 1920 * 2.0 = 3840 >= 3200 → 1.5 + expect(cubit.state, const TextScaleState(scaleFactor: 1.5)); + await cubit.close(); + }); + + test('autoDetect sets 1.0 for non-4K', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs); + cubit.autoDetect(const Size(1920, 1080), 1.0, isDesktop: true); + // 1920 * 1.0 = 1920 < 3200 → 1.0 + expect(cubit.state, const TextScaleState(scaleFactor: 1.0)); + await cubit.close(); + }); + + test('autoDetect sets 1.0 for mobile even at high resolution', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs); + cubit.autoDetect(const Size(1920, 1080), 2.0, isDesktop: false); + expect(cubit.state, const TextScaleState(scaleFactor: 1.0)); + await cubit.close(); + }); + + test('autoDetect skips when preference already saved', () async { + SharedPreferences.setMockInitialValues({'text_scale_factor': 2.5}); + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs)..loadScale(); + await Future.delayed(Duration.zero); + cubit.autoDetect(const Size(1920, 1080), 2.0, isDesktop: true); + // Should NOT override — preference already exists. + expect(cubit.state, const TextScaleState(scaleFactor: 2.5)); + await cubit.close(); + }); + + test('resetToAuto clears preference and re-detects', () async { + SharedPreferences.setMockInitialValues({'text_scale_factor': 2.5}); + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs)..loadScale(); + await Future.delayed(Duration.zero); + await cubit.resetToAuto(); + expect(prefs.containsKey('text_scale_factor'), isFalse); + // After reset, scale should be 1.0 (default). + expect(cubit.state, const TextScaleState(scaleFactor: 1.0)); + await cubit.close(); + }); + + test('TextScaleState equality', () { + const a = TextScaleState(scaleFactor: 1.0); + const b = TextScaleState(scaleFactor: 1.0); + const c = TextScaleState(scaleFactor: 2.0); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/bloc/text_scale_cubit_test.dart +``` + +Expected: Compilation error — `TextScaleCubit` does not exist. + +- [ ] **Step 3: Implement TextScaleCubit** + +```dart +import 'dart:ui'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Manages text scale factor with SharedPreferences persistence. +class TextScaleCubit extends Cubit { + /// Creates a [TextScaleCubit]. + TextScaleCubit({required SharedPreferences prefs}) + : _prefs = prefs, + super(const TextScaleState(scaleFactor: 1.0)); + + final SharedPreferences _prefs; + + static const _key = 'text_scale_factor'; + + bool get _hasSavedPreference => _prefs.containsKey(_key); + + /// Loads the saved scale factor from SharedPreferences. + void loadScale() { + final saved = _prefs.getDouble(_key); + if (saved != null) { + emit(TextScaleState(scaleFactor: saved)); + } + } + + /// Sets the scale factor, persisting to SharedPreferences. + Future setScale(double value) async { + await _prefs.setDouble(_key, value); + emit(TextScaleState(scaleFactor: value)); + } + + /// Auto-detects scale for 4K displays. Only runs when no preference saved. + void autoDetect(Size logicalSize, double dpr, {required bool isDesktop}) { + if (_hasSavedPreference) return; + final physicalWidth = logicalSize.width * dpr; + final scale = (physicalWidth >= 3200 && isDesktop) ? 1.5 : 1.0; + emit(TextScaleState(scaleFactor: scale)); + } + + /// Clears the saved preference and resets to default 1.0. + Future resetToAuto() async { + await _prefs.remove(_key); + emit(const TextScaleState(scaleFactor: 1.0)); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/bloc/text_scale_cubit_test.dart -v +``` + +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add horatio_app/lib/bloc/text_scale/ horatio_app/test/bloc/text_scale_cubit_test.dart +git commit -m "feat(text-scale): add TextScaleCubit with SharedPreferences persistence" +``` + +--- + +### Task 1.4: TextScaleSettingsSheet widget + +**Files:** + +- Create: `horatio_app/lib/widgets/text_scale_settings_sheet.dart` +- Create: `horatio_app/test/widgets/text_scale_settings_sheet_test.dart` + +- [ ] **Step 1: Write failing widget tests** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_state.dart'; +import 'package:horatio_app/widgets/text_scale_settings_sheet.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late TextScaleCubit cubit; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + cubit = TextScaleCubit(prefs: prefs); + }); + + tearDown(() => cubit.close()); + + Widget buildSheet() => MaterialApp( + home: BlocProvider.value( + value: cubit, + child: const Scaffold(body: TextScaleSettingsSheet()), + ), + ); + + group('TextScaleSettingsSheet', () { + testWidgets('shows slider and preview text', (tester) async { + await tester.pumpWidget(buildSheet()); + expect(find.byType(Slider), findsOneWidget); + expect(find.textContaining('1.0x'), findsOneWidget); + }); + + testWidgets('slider changes scale', (tester) async { + await tester.pumpWidget(buildSheet()); + // Drag slider to the right. + final slider = find.byType(Slider); + await tester.drag(slider, const Offset(100, 0)); + await tester.pumpAndSettle(); + // After drag, scale should have changed from 1.0. + expect(cubit.state.scaleFactor, isNot(1.0)); + }); + + testWidgets('reset button resets to default', (tester) async { + await cubit.setScale(2.0); + await tester.pumpWidget(buildSheet()); + await tester.tap(find.text('Reset to auto')); + await tester.pumpAndSettle(); + expect(cubit.state, const TextScaleState(scaleFactor: 1.0)); + }); + + testWidgets('shows current scale value', (tester) async { + await cubit.setScale(1.5); + await tester.pumpWidget(buildSheet()); + expect(find.textContaining('1.5x'), findsOneWidget); + }); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/widgets/text_scale_settings_sheet_test.dart +``` + +Expected: Compilation error — `TextScaleSettingsSheet` does not exist. + +- [ ] **Step 3: Implement TextScaleSettingsSheet** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_state.dart'; + +/// A bottom sheet with a slider for adjusting text scale factor. +class TextScaleSettingsSheet extends StatelessWidget { + /// Creates a [TextScaleSettingsSheet]. + const TextScaleSettingsSheet({super.key}); + + @override + Widget build(BuildContext context) => + BlocBuilder( + builder: (context, state) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Text Size', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Text( + 'Sample text at ${state.scaleFactor.toStringAsFixed(1)}x', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + Slider( + value: state.scaleFactor, + min: 0.5, + max: 3.0, + divisions: 25, + label: '${state.scaleFactor.toStringAsFixed(1)}x', + onChanged: (value) => + context.read().setScale(value), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => + context.read().resetToAuto(), + child: const Text('Reset to auto'), + ), + ), + ], + ), + ), + ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/widgets/text_scale_settings_sheet_test.dart -v +``` + +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add horatio_app/lib/widgets/text_scale_settings_sheet.dart horatio_app/test/widgets/text_scale_settings_sheet_test.dart +git commit -m "feat(text-scale): add TextScaleSettingsSheet widget" +``` + +--- + +### Task 1.5: Integrate TextScaleCubit into app.dart + main.dart + +**Files:** + +- Modify: `horatio_app/lib/main.dart` +- Modify: `horatio_app/lib/app.dart` +- Modify: `horatio_app/test/app_test.dart` + +- [ ] **Step 1: Update main.dart to init SharedPreferences** + +Replace the current `main()` body. Add `SharedPreferences` init and pass to `HoratioApp`: + +```dart +import 'dart:io'; + +import 'package:device_preview/device_preview.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:horatio_app/app.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + final dbFolder = await getApplicationDocumentsDirectory(); + final dbFile = File(p.join(dbFolder.path, 'horatio.sqlite')); + final database = AppDatabase(NativeDatabase(dbFile)); + final prefs = await SharedPreferences.getInstance(); + + runApp( + DevicePreview( + builder: (_) => HoratioApp(database: database, prefs: prefs), + ), + ); +} +``` + +- [ ] **Step 2: Update HoratioApp to accept prefs and provide TextScaleCubit** + +Replace `app.dart` fully. Key design choices: + +- Use `defaultTargetPlatform` instead of `dart:io` `Platform` to avoid web-incompatibility. +- Use a `_AutoDetectWrapper` `StatefulWidget` to run auto-detect exactly once in `initState`, not on every rebuild. + +```dart +import 'package:device_preview/device_preview.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/script_import/script_import_cubit.dart'; +import 'package:horatio_app/bloc/srs_review/srs_review_cubit.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_state.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'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Root widget for the Horatio app. +class HoratioApp extends StatelessWidget { + /// Creates the [HoratioApp]. + const HoratioApp({ + required this.database, + required this.prefs, + super.key, + }); + + /// The drift database instance. + final AppDatabase database; + + /// SharedPreferences for text scale persistence. + final SharedPreferences prefs; + + @override + Widget build(BuildContext context) => MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (_) => ScriptRepository(), + ), + RepositoryProvider( + create: (_) => database.annotationDao, + ), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ScriptImportCubit( + repository: context.read(), + ), + ), + BlocProvider( + create: (_) => SrsReviewCubit(), + ), + BlocProvider( + create: (_) => TextScaleCubit(prefs: prefs)..loadScale(), + ), + ], + child: const _AutoDetectWrapper(), + ), + ); +} + +/// Runs auto-detect once in initState, then wraps child with MediaQuery. +class _AutoDetectWrapper extends StatefulWidget { + const _AutoDetectWrapper(); + + @override + State<_AutoDetectWrapper> createState() => _AutoDetectWrapperState(); +} + +class _AutoDetectWrapperState extends State<_AutoDetectWrapper> { + bool _hasAutoDetected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_hasAutoDetected) { + _hasAutoDetected = true; + final mq = MediaQuery.of(context); + final isDesktop = + defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows; + context.read().autoDetect( + mq.size, + mq.devicePixelRatio, + isDesktop: isDesktop, + ); + } + } + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context); + return BlocBuilder( + builder: (context, state) => MediaQuery( + data: mq.copyWith( + textScaler: TextScaler.linear(state.scaleFactor), + ), + child: MaterialApp.router( + title: 'Horatio', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + locale: DevicePreview.locale(context), + builder: DevicePreview.appBuilder, + routerConfig: appRouter, + ), + ), + ); + } +} +``` + +- [ ] **Step 3: Update app_test.dart** + +The `HoratioApp` now requires `prefs`. Update all test usages: + +```dart +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/app.dart'; +import 'package:horatio_app/router.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'helpers/test_database.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('HoratioApp builds without crashing', (tester) async { + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + HoratioApp(database: createTestDatabase(), prefs: prefs), + ); + await tester.pumpAndSettle(); + expect(find.text('Horatio'), findsOneWidget); + }); + + testWidgets('SrsReviewCubit is created when srs-review route is visited', + (tester) async { + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + HoratioApp(database: createTestDatabase(), prefs: prefs), + ); + await tester.pumpAndSettle(); + + unawaited(appRouter.push(RoutePaths.srsReview, extra: [ + SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans'), + ])); + await tester.pumpAndSettle(); + expect(find.text('No review session active.'), findsOneWidget); + }); + + testWidgets('AnnotationDao is provided when annotation route is visited', + (tester) async { + final db = createTestDatabase(); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget(HoratioApp(database: db, prefs: prefs)); + 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(); + }); +} +``` + +- [ ] **Step 4: Run all tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test +``` + +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add horatio_app/lib/main.dart horatio_app/lib/app.dart horatio_app/test/app_test.dart horatio_app/pubspec.yaml +git commit -m "feat(text-scale): integrate TextScaleCubit into app root with auto-detect" +``` + +--- + +### Task 1.6: Add settings icon to HomeScreen and AnnotationEditorScreen + +**Files:** + +- Modify: `horatio_app/lib/screens/home_screen.dart` +- Modify: `horatio_app/lib/screens/annotation_editor_screen.dart` +- Modify: `horatio_app/test/screens/annotation_editor_screen_test.dart` + +- [ ] **Step 1: Add settings icon to HomeScreen AppBar** + +In `home_screen.dart`, add these imports at the top: + +```dart +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/widgets/text_scale_settings_sheet.dart'; +``` + +Change: + +```dart +appBar: AppBar(title: const Text('Horatio')), +``` + +to: + +```dart +appBar: AppBar( + title: const Text('Horatio'), + actions: [ + IconButton( + icon: const Icon(Icons.text_fields), + tooltip: 'Text Size', + onPressed: () => showModalBottomSheet( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: const TextScaleSettingsSheet(), + ), + ), + ), + ], +), +``` + +- [ ] **Step 2: Add settings icon to AnnotationEditorScreen AppBar** + +In `annotation_editor_screen.dart`, add these imports: + +```dart +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/widgets/text_scale_settings_sheet.dart'; +``` + +In `_AnnotationEditorBody.build`, the existing `actions` list looks like: + +```dart +actions: [ + IconButton( + icon: const Icon(Icons.history), + tooltip: 'History', + onPressed: () => + context.push(RoutePaths.annotationHistory, extra: script), + ), +], +``` + +Add the text size button before the history button: + +```dart +actions: [ + IconButton( + icon: const Icon(Icons.text_fields), + tooltip: 'Text Size', + onPressed: () => showModalBottomSheet( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: const TextScaleSettingsSheet(), + ), + ), + ), + IconButton( + icon: const Icon(Icons.history), + tooltip: 'History', + onPressed: () => + context.push(RoutePaths.annotationHistory, extra: script), + ), +], +``` + +- [ ] **Step 3: Update annotation_editor_screen_test.dart for TextScaleCubit** + +In the test file, add these imports: + +```dart +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +``` + +Add to the `setUp` block: + +```dart +SharedPreferences.setMockInitialValues({}); +``` + +Create a `TextScaleCubit` in `setUp`: + +```dart +late TextScaleCubit textScaleCubit; + +setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + textScaleCubit = TextScaleCubit(prefs: prefs); + // ... existing setup ... +}); + +tearDown(() { + textScaleCubit.close(); + // ... existing teardown ... +}); +``` + +Wrap the existing test `_buildScreen` helpers with a `BlocProvider.value(value: textScaleCubit, ...)`. + +Add a test for the text size button: + +```dart +testWidgets('text size button opens settings sheet', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.text_fields)); + await tester.pumpAndSettle(); + + expect(find.byType(TextScaleSettingsSheet), findsOneWidget); +}); +``` + +- [ ] **Step 4: Run all tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test +``` + +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add horatio_app/lib/screens/home_screen.dart horatio_app/lib/screens/annotation_editor_screen.dart horatio_app/test/ +git commit -m "feat(text-scale): add text size settings button to home and annotation screens" +``` + +--- + +### Task 1.7: Run full pipeline for Chunk 1 + +- [ ] **Step 1: Run codegen + analyze + test** + +```bash +cd /home/kuhy/testsAndMisc/horatio && ./run.sh test +``` + +Expected: 100% coverage, all pass. + +- [ ] **Step 2: Fix any issues** + +If coverage gaps exist, add missing tests. If analysis warnings, fix them. + +--- diff --git a/horatio/docs/superpowers/plans/font-annotations-recording/02-word-level-marks.md b/horatio/docs/superpowers/plans/font-annotations-recording/02-word-level-marks.md new file mode 100644 index 0000000..e2fd20e --- /dev/null +++ b/horatio/docs/superpowers/plans/font-annotations-recording/02-word-level-marks.md @@ -0,0 +1,537 @@ +## Chunk 2: Word-Level Mark Selection + +### Task 2.1: MarkSelectionToolbar widget + +**Files:** + +- Create: `horatio_app/lib/widgets/mark_selection_toolbar.dart` +- Create: `horatio_app/test/widgets/mark_selection_toolbar_test.dart` + +- [ ] **Step 1: Write failing tests** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/mark_selection_toolbar.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + group('MarkSelectionToolbar', () { + testWidgets('shows 6 mark type chips', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkSelectionToolbar( + onMarkSelected: (_) {}, + onCancelled: () {}, + ), + ), + ), + ); + expect(find.byType(ActionChip), findsNWidgets(6)); + expect(find.text('Stress'), findsOneWidget); + expect(find.text('Pause'), findsOneWidget); + expect(find.text('Breath'), findsOneWidget); + expect(find.text('Emphasis'), findsOneWidget); + expect(find.text('Slow Down'), findsOneWidget); + expect(find.text('Speed Up'), findsOneWidget); + }); + + testWidgets('tapping chip calls onMarkSelected', (tester) async { + MarkType? selected; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkSelectionToolbar( + onMarkSelected: (type) => selected = type, + onCancelled: () {}, + ), + ), + ), + ); + await tester.tap(find.text('Stress')); + expect(selected, MarkType.stress); + }); + + testWidgets('cancel button calls onCancelled', (tester) async { + var cancelled = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkSelectionToolbar( + onMarkSelected: (_) {}, + onCancelled: () => cancelled = true, + ), + ), + ), + ); + await tester.tap(find.text('Cancel')); + expect(cancelled, isTrue); + }); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/widgets/mark_selection_toolbar_test.dart +``` + +Expected: Compilation error — `MarkSelectionToolbar` does not exist. + +- [ ] **Step 3: Implement MarkSelectionToolbar** + +```dart +import 'package:flutter/material.dart'; +import 'package:horatio_app/widgets/mark_overlay.dart'; +import 'package:horatio_app/widgets/mark_type_picker.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// Floating toolbar showing mark type chips for text selection annotation. +class MarkSelectionToolbar extends StatelessWidget { + /// Creates a [MarkSelectionToolbar]. + const MarkSelectionToolbar({ + required this.onMarkSelected, + required this.onCancelled, + super.key, + }); + + /// Called when a mark type chip is tapped. + final ValueChanged onMarkSelected; + + /// Called when the action is cancelled. + final VoidCallback onCancelled; + + @override + Widget build(BuildContext context) => Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...MarkType.values.map( + (type) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: ActionChip( + label: Text(markTypeLabel(type)), + backgroundColor: markColors[type], + onPressed: () => onMarkSelected(type), + ), + ), + ), + const SizedBox(width: 4), + TextButton( + onPressed: onCancelled, + child: const Text('Cancel'), + ), + ], + ), + ), + ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/widgets/mark_selection_toolbar_test.dart -v +``` + +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add horatio_app/lib/widgets/mark_selection_toolbar.dart horatio_app/test/widgets/mark_selection_toolbar_test.dart +git commit -m "feat(marks): add MarkSelectionToolbar widget" +``` + +--- + +### Task 2.2: Rework \_LineTile for word-level selection + +**Files:** + +- Modify: `horatio_app/lib/screens/annotation_editor_screen.dart` +- Modify: `horatio_app/test/screens/annotation_editor_screen_test.dart` + +- [ ] **Step 1: Replace \_LineTile implementation** + +The `_LineTile` widget needs two distinct rendering modes: + +**When `isSelected == false`**: Read-only `MarkOverlay` (current behavior minus long-press mark). + +**When `isSelected == true`**: `SelectableText.rich` with colored spans + `MarkSelectionToolbar` appearing when text is selected. + +Replace the `_LineTile` class with a `StatefulWidget` to manage the `TextSelection` and toolbar overlay: + +```dart +class _LineTile extends StatefulWidget { + const _LineTile({ + required this.line, + required this.lineIndex, + required this.marks, + required this.notes, + required this.isSelected, + }); + + final ScriptLine line; + final int lineIndex; + final List marks; + final List notes; + final bool isSelected; + + @override + State<_LineTile> createState() => _LineTileState(); +} + +class _LineTileState extends State<_LineTile> { + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _toolbarOverlay; + TextSelection? _selection; + + @override + void dispose() { + _removeToolbar(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _LineTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.isSelected && oldWidget.isSelected) { + _removeToolbar(); + } + } + + void _removeToolbar() { + _toolbarOverlay?.remove(); + _toolbarOverlay = null; + } + + void _onSelectionChanged( + TextSelection selection, + SelectionChangedCause? cause, + ) { + _removeToolbar(); + if (selection.isCollapsed) { + _selection = null; + return; + } + _selection = selection; + _showToolbar(); + } + + void _showToolbar() { + final overlay = Overlay.of(context); + _toolbarOverlay = OverlayEntry( + builder: (context) => Positioned( + width: MediaQuery.of(context).size.width, + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: const Offset(0, -48), + child: Align( + alignment: Alignment.centerLeft, + child: MarkSelectionToolbar( + onMarkSelected: _applyMark, + onCancelled: _removeToolbar, + ), + ), + ), + ), + ); + overlay.insert(_toolbarOverlay!); + } + + void _applyMark(MarkType type) { + final sel = _selection; + if (sel == null || sel.isCollapsed) return; + final start = sel.start; + final end = sel.end; + context.read().addMark( + lineIndex: widget.lineIndex, + startOffset: start, + endOffset: end, + type: type, + ); + _removeToolbar(); + } + + List _buildSpans() { + // Reuse MarkOverlay's span-building logic but return TextSpan children. + // (Could extract from MarkOverlay into a shared utility.) + final text = widget.line.text; + final marks = widget.marks; + if (marks.isEmpty) return [TextSpan(text: text)]; + + final length = text.length; + final events = <({int offset, bool isStart, MarkType type})>[]; + for (final mark in marks) { + final s = mark.startOffset.clamp(0, length); + final e = mark.endOffset.clamp(0, length); + if (s >= e) continue; + events + ..add((offset: s, isStart: true, type: mark.type)) + ..add((offset: e, isStart: false, type: mark.type)); + } + events.sort((a, b) => a.offset.compareTo(b.offset)); + + final spans = []; + var cursor = 0; + final activeTypes = []; + for (final event in events) { + final pos = event.offset.clamp(0, 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; + } + + @override + Widget build(BuildContext context) => Container( + color: widget.isSelected + ? Theme.of(context).colorScheme.primaryContainer.withValues( + alpha: 0.3, + ) + : null, + child: InkWell( + onTap: () => context + .read() + .selectLine(widget.lineIndex), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: widget.isSelected + ? CompositedTransformTarget( + link: _layerLink, + child: SelectableText.rich( + TextSpan( + style: DefaultTextStyle.of(context).style, + children: _buildSpans(), + ), + onSelectionChanged: _onSelectionChanged, + ), + ) + : MarkOverlay( + text: widget.line.text, + marks: widget.marks, + ), + ), + NoteIndicator( + noteCount: widget.notes.length, + onTap: () => _showNoteEditor(context), + ), + ], + ), + ), + ), + ); + + void _showNoteEditor(BuildContext context) { + final cubit = context.read(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: NoteEditorSheet( + onSave: (category, text) { + cubit.addNote( + lineIndex: widget.lineIndex, + category: category, + text: text, + ); + Navigator.pop(context); + }, + onCancel: () => Navigator.pop(context), + ), + ), + ); + } +} +``` + +> **Note:** `_showNoteEditor` uses the current `(NoteCategory, String)` callback. Task 5.6 will update this call site to the new `(NoteCategory, String, {String? noteId})` signature and add edit-mode support. + +Remove the old `_showMarkPicker` method entirely (the long-press flow is replaced by text selection + toolbar). + +- [ ] **Step 2: Update existing tests and add new ones** + +Remove these three tests: + +- `long-press on a line shows mark type picker` +- `selecting mark type in picker calls addMark` +- `cancel in mark picker dismisses dialog` + +Add the following replacement tests: + +```dart +testWidgets('selected line shows SelectableText', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + // Tap to select the first line. + await tester.tap( + find.text('To be or not to be.', findRichText: true), + ); + await tester.pump(); + + expect(find.byType(SelectableText), findsOneWidget); +}); + +testWidgets('unselected line shows MarkOverlay not SelectableText', + (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + // No line selected — should be MarkOverlay. + expect(find.byType(SelectableText), findsNothing); + expect(find.byType(MarkOverlay), findsWidgets); +}); + +testWidgets('tapping a marked span shows remove dialog', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + final mark = TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ); + _marksCtrl.add([mark]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + // Tap the line to select it. + await tester.tap( + find.text('To be or not to be.', findRichText: true), + ); + await tester.pump(); + + // Tap the colored span area to trigger mark removal dialog. + // The mark covers "To be" (offsets 0-5). + await tester.tapAt(tester.getTopLeft(find.byType(SelectableText))); + await tester.pump(); + + expect(find.text('Remove mark?'), findsOneWidget); +}); +``` + +Note: The overlay-based `MarkSelectionToolbar` is tested separately in `mark_selection_toolbar_test.dart`. The screen test verifies the mode transitions (selected → `SelectableText`, unselected → `MarkOverlay`). Full overlay interaction testing requires integration tests or the existing `MarkSelectionToolbar` widget tests. + +- [ ] **Step 3: Add mark removal to \_LineTile** + +When a line is selected and the user taps on a region that already has a mark (colored span), show an `AlertDialog` asking `'Remove mark?'` with Yes/No. On confirmation, call `cubit.removeMark(markId)`. + +In `_LineTileState._buildSpans`, add a `TapGestureRecognizer` to colored spans: + +```dart +if (activeTypes.isNotEmpty) { + final markForSpan = marks.firstWhere( + (m) => + m.startOffset <= cursor && + m.endOffset >= pos && + m.type == activeTypes.last, + ); + spans.add(TextSpan( + text: text.substring(cursor, pos), + style: TextStyle(backgroundColor: markColors[activeTypes.last]), + recognizer: TapGestureRecognizer() + ..onTap = () => _showRemoveMarkDialog(markForSpan.id), + )); +} +``` + +Add the `_showRemoveMarkDialog` method: + +```dart +void _showRemoveMarkDialog(String markId) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Remove mark?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('No'), + ), + TextButton( + onPressed: () { + context.read().removeMark(markId); + Navigator.pop(dialogContext); + }, + child: const Text('Yes'), + ), + ], + ), + ); +} +``` + +Don't forget to import `TapGestureRecognizer` from `package:flutter/gestures.dart` and dispose recognizers properly. + +- [ ] **Step 3: Run all tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test +``` + +Expected: All pass. + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/screens/annotation_editor_screen.dart horatio_app/test/screens/annotation_editor_screen_test.dart +git commit -m "feat(marks): replace whole-line marks with word-level text selection + toolbar" +``` + +--- + +### Task 2.3: Run full pipeline for Chunk 2 + +- [ ] **Step 1: Run codegen + analyze + test** + +```bash +cd /home/kuhy/testsAndMisc/horatio && ./run.sh test +``` + +Expected: 100% coverage, all pass. + +- [ ] **Step 2: Fix any issues** + +--- diff --git a/horatio/docs/superpowers/plans/font-annotations-recording/03-recording-infrastructure.md b/horatio/docs/superpowers/plans/font-annotations-recording/03-recording-infrastructure.md new file mode 100644 index 0000000..cca28de --- /dev/null +++ b/horatio/docs/superpowers/plans/font-annotations-recording/03-recording-infrastructure.md @@ -0,0 +1,510 @@ +## Chunk 3: Recording Infrastructure (Model + Table + Migration + DAO) + +### Task 3.1: LineRecording model in horatio_core + +**Files:** + +- Create: `horatio_core/lib/src/models/line_recording.dart` +- Modify: `horatio_core/lib/src/models/models.dart` +- Create: `horatio_core/test/models/line_recording_test.dart` + +- [ ] **Step 1: Write failing tests** + +```dart +import 'package:horatio_core/horatio_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('LineRecording', () { + final recording = LineRecording( + id: 'r1', + scriptId: 's1', + lineIndex: 0, + filePath: '/recordings/s1/line_0_123.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + grade: 3, + ); + + test('properties are accessible', () { + expect(recording.id, 'r1'); + expect(recording.scriptId, 's1'); + expect(recording.lineIndex, 0); + expect(recording.filePath, '/recordings/s1/line_0_123.m4a'); + expect(recording.durationMs, 5000); + expect(recording.createdAt, DateTime.utc(2026)); + expect(recording.grade, 3); + }); + + test('grade can be null', () { + final ungraded = LineRecording( + id: 'r2', + scriptId: 's1', + lineIndex: 0, + filePath: '/path.m4a', + durationMs: 1000, + createdAt: DateTime.utc(2026), + ); + expect(ungraded.grade, isNull); + }); + + test('equality based on id', () { + final same = LineRecording( + id: 'r1', + scriptId: 'different', + lineIndex: 99, + filePath: '/other.m4a', + durationMs: 0, + createdAt: DateTime.utc(2000), + ); + expect(recording, equals(same)); + expect(recording.hashCode, same.hashCode); + }); + + test('inequality with different id', () { + final different = LineRecording( + id: 'r99', + scriptId: 's1', + lineIndex: 0, + filePath: '/path.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ); + expect(recording, isNot(equals(different))); + }); + + test('toJson roundtrip', () { + final json = recording.toJson(); + final restored = LineRecording.fromJson(json); + expect(restored.id, recording.id); + expect(restored.scriptId, recording.scriptId); + expect(restored.lineIndex, recording.lineIndex); + expect(restored.filePath, recording.filePath); + expect(restored.durationMs, recording.durationMs); + expect(restored.createdAt, recording.createdAt); + expect(restored.grade, recording.grade); + }); + + test('toJson roundtrip with null grade', () { + final ungraded = LineRecording( + id: 'r3', + scriptId: 's1', + lineIndex: 0, + filePath: '/path.m4a', + durationMs: 1000, + createdAt: DateTime.utc(2026), + ); + final json = ungraded.toJson(); + final restored = LineRecording.fromJson(json); + expect(restored.grade, isNull); + }); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_core && dart test test/models/line_recording_test.dart +``` + +Expected: Compilation error. + +- [ ] **Step 3: Implement LineRecording** + +```dart +import 'package:meta/meta.dart'; + +/// A voice recording for a specific script line. +@immutable +final class LineRecording { + /// Creates a [LineRecording]. + const LineRecording({ + required this.id, + required this.scriptId, + required this.lineIndex, + required this.filePath, + required this.durationMs, + required this.createdAt, + this.grade, + }); + + /// Deserializes from a JSON map. + factory LineRecording.fromJson(Map json) => LineRecording( + id: json['id'] as String, + scriptId: json['scriptId'] as String, + lineIndex: json['lineIndex'] as int, + filePath: json['filePath'] as String, + durationMs: json['durationMs'] as int, + createdAt: DateTime.parse(json['createdAt'] as String), + grade: json['grade'] as int?, + ); + + /// Unique identifier (UUID). + final String id; + + /// The script this recording belongs to. + final String scriptId; + + /// Index of the line this recording is for. + final int lineIndex; + + /// Path to the audio file on disk. + final String filePath; + + /// Duration in milliseconds. + final int durationMs; + + /// When this recording was created. + final DateTime createdAt; + + /// Grade 0-5 (SM-2 quality scale), null if not yet graded. + final int? grade; + + @override + bool operator ==(Object other) => + identical(this, other) || other is LineRecording && id == other.id; + + @override + int get hashCode => id.hashCode; + + /// Serializes to a JSON-compatible map. + Map toJson() => { + 'id': id, + 'scriptId': scriptId, + 'lineIndex': lineIndex, + 'filePath': filePath, + 'durationMs': durationMs, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'grade': grade, + }; +} +``` + +- [ ] **Step 4: Export from models.dart** + +Add `export 'line_recording.dart';` to `horatio_core/lib/src/models/models.dart`. + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_core && dart test test/models/line_recording_test.dart -v +``` + +Expected: All pass. + +- [ ] **Step 6: Commit** + +```bash +git add horatio_core/lib/src/models/line_recording.dart horatio_core/lib/src/models/models.dart horatio_core/test/models/line_recording_test.dart +git commit -m "feat(core): add LineRecording model with JSON serialization" +``` + +--- + +### Task 3.2: LineRecordingsTable + Database migration + +**Files:** + +- Create: `horatio_app/lib/database/tables/line_recordings_table.dart` +- Modify: `horatio_app/lib/database/app_database.dart` + +- [ ] **Step 1: Create table definition** + +```dart +import 'package:drift/drift.dart'; + +/// Drift table for per-line voice recordings. +class LineRecordingsTable extends Table { + @override + String get tableName => 'line_recordings'; + + TextColumn get id => text()(); + TextColumn get scriptId => text()(); + IntColumn get lineIndex => integer()(); + TextColumn get filePath => text()(); + IntColumn get durationMs => integer()(); + DateTimeColumn get createdAt => dateTime()(); + IntColumn get grade => integer().nullable()(); + + @override + Set get primaryKey => {id}; +} +``` + +- [ ] **Step 2: Update app_database.dart** + +Replace the full `app_database.dart` file. Key changes: add `LineRecordingsTable` to tables, bump schema to 2, add migration. Leave `RecordingDao` for Task 3.3. + +```dart +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/line_recordings_table.dart'; +import 'package:horatio_app/database/tables/text_marks_table.dart'; + +part 'app_database.g.dart'; + +/// Central drift database for Horatio. +/// +/// Schema version 2: adds line_recordings table for voice recordings. +@DriftDatabase( + tables: [ + TextMarksTable, + LineNotesTable, + AnnotationSnapshotsTable, + LineRecordingsTable, + ], + daos: [AnnotationDao], +) +class AppDatabase extends _$AppDatabase { + /// Creates an [AppDatabase] with the given [QueryExecutor]. + AppDatabase(super.e); + + @override + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (m) => m.createAll(), + onUpgrade: (m, from, to) async { + if (from < 2) { + await m.createTable(lineRecordingsTable); + } + }, + ); +} +``` + +- [ ] **Step 3: Run codegen** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && dart run build_runner build --delete-conflicting-outputs +``` + +Expected: Generates updated `.g.dart` files. + +- [ ] **Step 4: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test +``` + +Expected: All pass (in-memory test DB auto-creates all tables). + +- [ ] **Step 5: Commit** + +```bash +git add horatio_app/lib/database/ +git commit -m "feat(db): add LineRecordingsTable and migrate schema v1→v2" +``` + +--- + +### Task 3.3: RecordingDao + +**Files:** + +- Create: `horatio_app/lib/database/daos/recording_dao.dart` +- Modify: `horatio_app/lib/database/app_database.dart` (add DAO reference) +- Create: `horatio_app/test/database/recording_dao_test.dart` + +- [ ] **Step 1: Write failing DAO tests** + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + late AppDatabase db; + late RecordingDao dao; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + dao = db.recordingDao; + }); + + tearDown(() => db.close()); + + final recording = LineRecording( + id: 'r1', + scriptId: 's1', + lineIndex: 0, + filePath: '/path/to/file.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ); + + group('RecordingDao', () { + test('insert and watch recordings', () async { + await dao.insertRecording('s1', recording); + final stream = dao.watchRecordingsForScript('s1'); + final recordings = await stream.first; + expect(recordings, hasLength(1)); + expect(recordings.first.id, 'r1'); + expect(recordings.first.filePath, '/path/to/file.m4a'); + }); + + test('delete recording', () async { + await dao.insertRecording('s1', recording); + await dao.deleteRecording('r1'); + final recordings = await dao.watchRecordingsForScript('s1').first; + expect(recordings, isEmpty); + }); + + test('update grade', () async { + await dao.insertRecording('s1', recording); + await dao.updateRecordingGrade('r1', 4); + final recordings = await dao.watchRecordingsForScript('s1').first; + expect(recordings.first.grade, 4); + }); + + test('update grade to null', () async { + await dao.insertRecording('s1', recording); + await dao.updateRecordingGrade('r1', 4); + await dao.updateRecordingGrade('r1', null); + final recordings = await dao.watchRecordingsForScript('s1').first; + expect(recordings.first.grade, isNull); + }); + + test('watch returns empty for unknown script', () async { + final recordings = + await dao.watchRecordingsForScript('unknown').first; + expect(recordings, isEmpty); + }); + + test('recordings ordered by lineIndex', () async { + final r2 = LineRecording( + id: 'r2', + scriptId: 's1', + lineIndex: 5, + filePath: '/p2.m4a', + durationMs: 1000, + createdAt: DateTime.utc(2026), + ); + await dao.insertRecording('s1', r2); + await dao.insertRecording('s1', recording); + final recordings = await dao.watchRecordingsForScript('s1').first; + expect(recordings[0].lineIndex, 0); + expect(recordings[1].lineIndex, 5); + }); + }); +} +``` + +- [ ] **Step 2: Implement RecordingDao** + +```dart +import 'package:drift/drift.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:horatio_app/database/tables/line_recordings_table.dart'; +import 'package:horatio_core/horatio_core.dart'; + +part 'recording_dao.g.dart'; + +/// Data access object for voice recording persistence. +@DriftAccessor(tables: [LineRecordingsTable]) +class RecordingDao extends DatabaseAccessor + with _$RecordingDaoMixin { + /// Creates a [RecordingDao]. + RecordingDao(super.db); + + /// Watches all recordings for a script, ordered by lineIndex. + Stream> watchRecordingsForScript(String scriptId) => + (select(lineRecordingsTable) + ..where((t) => t.scriptId.equals(scriptId)) + ..orderBy([(t) => OrderingTerm.asc(t.lineIndex)])) + .watch() + .map((rows) => rows.map(_rowToRecording).toList()); + + /// Inserts a recording. + Future insertRecording(String scriptId, LineRecording recording) => + into(lineRecordingsTable).insert( + LineRecordingsTableCompanion.insert( + id: recording.id, + scriptId: scriptId, + lineIndex: recording.lineIndex, + filePath: recording.filePath, + durationMs: recording.durationMs, + createdAt: recording.createdAt, + grade: Value(recording.grade), + ), + ); + + /// Deletes a recording by ID. + Future deleteRecording(String id) => + (delete(lineRecordingsTable)..where((t) => t.id.equals(id))).go(); + + /// Updates or clears the grade of a recording. + Future updateRecordingGrade(String id, int? grade) => + (update(lineRecordingsTable)..where((t) => t.id.equals(id))) + .write(LineRecordingsTableCompanion(grade: Value(grade))); + + LineRecording _rowToRecording(LineRecordingsTableData row) => LineRecording( + id: row.id, + scriptId: row.scriptId, + lineIndex: row.lineIndex, + filePath: row.filePath, + durationMs: row.durationMs, + createdAt: row.createdAt, + grade: row.grade, + ); +} +``` + +- [ ] **Step 3: Add RecordingDao to AppDatabase** + +Update `app_database.dart` — add `RecordingDao` to `daos` list: + +```dart +@DriftDatabase( + tables: [ + TextMarksTable, + LineNotesTable, + AnnotationSnapshotsTable, + LineRecordingsTable, + ], + daos: [AnnotationDao, RecordingDao], +) +``` + +Add import: `import 'package:horatio_app/database/daos/recording_dao.dart';` + +- [ ] **Step 4: Run codegen** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && dart run build_runner build --delete-conflicting-outputs +``` + +- [ ] **Step 5: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/database/recording_dao_test.dart -v +``` + +Expected: All pass. + +- [ ] **Step 6: Commit** + +```bash +git add horatio_app/lib/database/ horatio_app/test/database/recording_dao_test.dart +git commit -m "feat(db): add RecordingDao with CRUD + stream watch" +``` + +--- + +### Task 3.4: Run pipeline for Chunk 3 + +- [ ] **Step 1: Run codegen + analyze + test** + +```bash +cd /home/kuhy/testsAndMisc/horatio && ./run.sh test +``` + +Expected: 100% coverage. + +--- diff --git a/horatio/docs/superpowers/plans/font-annotations-recording/04-recording-services-cubit.md b/horatio/docs/superpowers/plans/font-annotations-recording/04-recording-services-cubit.md new file mode 100644 index 0000000..2e9e817 --- /dev/null +++ b/horatio/docs/superpowers/plans/font-annotations-recording/04-recording-services-cubit.md @@ -0,0 +1,1018 @@ +## Chunk 4: Recording Services + Cubit + +### Task 4.1: RecordingService + +**Files:** + +- Create: `horatio_app/lib/services/recording_service.dart` +- Create: `horatio_app/test/services/recording_service_test.dart` + +- [ ] **Step 1: Write failing tests** + +RecordingService wraps the `record` package. Tests must fully mock it. + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/services/recording_service.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; + +class MockAudioRecorder extends Mock implements AudioRecorder {} + +void main() { + late MockAudioRecorder mockRecorder; + late RecordingService service; + + setUp(() { + mockRecorder = MockAudioRecorder(); + service = RecordingService(recorder: mockRecorder); + }); + + tearDown(() => service.dispose()); + + group('RecordingService', () { + test('startRecording starts recording to path', () async { + when(() => mockRecorder.start(any(), path: any(named: 'path'))) + .thenAnswer((_) async {}); + when(() => mockRecorder.hasPermission()) + .thenAnswer((_) async => true); + await service.startRecording('/tmp/test.m4a'); + verify( + () => mockRecorder.start( + any(), + path: '/tmp/test.m4a', + ), + ).called(1); + }); + + test('stopRecording stops and returns path', () async { + when(() => mockRecorder.stop()).thenAnswer((_) async => '/tmp/test.m4a'); + final path = await service.stopRecording(); + expect(path, '/tmp/test.m4a'); + }); + + test('hasPermission delegates', () async { + when(() => mockRecorder.hasPermission()) + .thenAnswer((_) async => true); + expect(await service.hasPermission(), isTrue); + }); + + test('hasPermission returns false', () async { + when(() => mockRecorder.hasPermission()) + .thenAnswer((_) async => false); + expect(await service.hasPermission(), isFalse); + }); + + test('dispose calls recorder dispose', () async { + when(() => mockRecorder.dispose()).thenAnswer((_) async {}); + await service.dispose(); + verify(() => mockRecorder.dispose()).called(1); + }); + }); +} +``` + +- [ ] **Step 2: Implement RecordingService** + +```dart +import 'package:record/record.dart'; + +/// Wraps the [AudioRecorder] for microphone recording. +class RecordingService { + /// Creates a [RecordingService]. + RecordingService({AudioRecorder? recorder}) + : _recorder = recorder ?? AudioRecorder(); + + final AudioRecorder _recorder; + + /// Whether the app has microphone permission. + Future hasPermission() => _recorder.hasPermission(); + + /// Starts recording to the given file path. + Future startRecording(String filePath) async { + await _recorder.start(const RecordConfig(), path: filePath); + } + + /// Stops recording and returns the file path. + Future stopRecording() => _recorder.stop(); + + /// Releases the recorder resources. + Future dispose() => _recorder.dispose(); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/services/recording_service_test.dart -v +``` + +Expected: All pass. + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/services/recording_service.dart horatio_app/test/services/recording_service_test.dart +git commit -m "feat(recording): add RecordingService wrapper for record package" +``` + +--- + +### Task 4.2: AudioPlaybackService + +**Files:** + +- Create: `horatio_app/lib/services/audio_playback_service.dart` +- Create: `horatio_app/test/services/audio_playback_service_test.dart` + +- [ ] **Step 1: Write failing tests** + +```dart +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAudioPlayer extends Mock implements AudioPlayer {} + +void main() { + late MockAudioPlayer mockPlayer; + late AudioPlaybackService service; + + setUp(() { + mockPlayer = MockAudioPlayer(); + service = AudioPlaybackService(player: mockPlayer); + when(() => mockPlayer.onPlayerStateChanged) + .thenAnswer((_) => const Stream.empty()); + when(() => mockPlayer.onPositionChanged) + .thenAnswer((_) => const Stream.empty()); + }); + + tearDown(() async { + when(() => mockPlayer.dispose()).thenAnswer((_) async {}); + await service.dispose(); + }); + + setUpAll(() { + registerFallbackValue(DeviceFileSource('')); + }); + + group('AudioPlaybackService', () { + test('play calls player.play with DeviceFileSource', () async { + when(() => mockPlayer.play(any())).thenAnswer((_) async {}); + await service.play('/tmp/test.m4a'); + verify(() => mockPlayer.play(any(that: isA()))) + .called(1); + }); + + test('stop calls player.stop', () async { + when(() => mockPlayer.stop()).thenAnswer((_) async {}); + await service.stop(); + verify(() => mockPlayer.stop()).called(1); + }); + + test('status stream maps player state changes', () async { + final controller = StreamController(); + when(() => mockPlayer.onPlayerStateChanged) + .thenAnswer((_) => controller.stream); + final service2 = AudioPlaybackService(player: mockPlayer); + + final statuses = []; + final sub = service2.status.listen(statuses.add); + + controller.add(PlayerState.playing); + controller.add(PlayerState.completed); + controller.add(PlayerState.stopped); + controller.add(PlayerState.paused); + + await Future.delayed(Duration.zero); + expect(statuses, [ + PlaybackStatus.playing, + PlaybackStatus.completed, + PlaybackStatus.idle, + PlaybackStatus.idle, + ]); + + await sub.cancel(); + await controller.close(); + when(() => mockPlayer.dispose()).thenAnswer((_) async {}); + await service2.dispose(); + }); + + test('position stream delegates', () async { + final controller = StreamController(); + when(() => mockPlayer.onPositionChanged) + .thenAnswer((_) => controller.stream); + final service2 = AudioPlaybackService(player: mockPlayer); + + final positions = []; + final sub = service2.position.listen(positions.add); + + controller.add(const Duration(seconds: 1)); + controller.add(const Duration(seconds: 2)); + + await Future.delayed(Duration.zero); + expect(positions, [ + const Duration(seconds: 1), + const Duration(seconds: 2), + ]); + + await sub.cancel(); + await controller.close(); + when(() => mockPlayer.dispose()).thenAnswer((_) async {}); + await service2.dispose(); + }); + + test('dispose calls player.dispose', () async { + when(() => mockPlayer.dispose()).thenAnswer((_) async {}); + await service.dispose(); + verify(() => mockPlayer.dispose()).called(1); + }); + }); +} +``` + +- [ ] **Step 2: Implement AudioPlaybackService** + +```dart +import 'package:audioplayers/audioplayers.dart'; + +/// Playback status for the audio player. +enum PlaybackStatus { + /// Not playing. + idle, + + /// Currently playing audio. + playing, + + /// Playback finished. + completed, +} + +/// Wraps [AudioPlayer] for audio playback. +class AudioPlaybackService { + /// Creates an [AudioPlaybackService]. + AudioPlaybackService({AudioPlayer? player}) + : _player = player ?? AudioPlayer(); + + final AudioPlayer _player; + + /// Plays audio from a local file path. + Future play(String filePath) => + _player.play(DeviceFileSource(filePath)); + + /// Stops playback. + Future stop() => _player.stop(); + + /// Stream of playback status changes. + Stream get status => + _player.onPlayerStateChanged.map((state) => switch (state) { + PlayerState.playing => PlaybackStatus.playing, + PlayerState.completed => PlaybackStatus.completed, + _ => PlaybackStatus.idle, + }); + + /// Stream of playback position. + Stream get position => _player.onPositionChanged; + + /// Releases the player resources. + Future dispose() => _player.dispose(); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/services/audio_playback_service_test.dart -v +``` + +Expected: All pass. + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/services/audio_playback_service.dart horatio_app/test/services/audio_playback_service_test.dart +git commit -m "feat(recording): add AudioPlaybackService with status stream" +``` + +--- + +### Task 4.3: RecordingState + +**Files:** + +- Create: `horatio_app/lib/bloc/recording/recording_state.dart` + +- [ ] **Step 1: Create state file** + +```dart +import 'package:equatable/equatable.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// States for [RecordingCubit]. +sealed class RecordingState extends Equatable { + const RecordingState(); + + /// All recordings for the current script. + /// Empty in [RecordingInitial], populated after [RecordingCubit.loadRecordings]. + List get recordings; +} + +/// No recordings loaded. +final class RecordingInitial extends RecordingState { + const RecordingInitial(); + + @override + List get recordings => const []; + + @override + List get props => []; +} + +/// Idle — recordings loaded, nothing in progress. +final class RecordingIdle extends RecordingState { + const RecordingIdle({required this.recordings}); + + /// All recordings for the current script. + final List recordings; + + @override + List get props => [recordings]; +} + +/// Recording in progress. +final class RecordingInProgress extends RecordingState { + const RecordingInProgress({ + required this.recordings, + required this.lineIndex, + required this.elapsed, + }); + + /// All recordings for the current script. + final List recordings; + + /// The line being recorded. + final int lineIndex; + + /// Elapsed recording time. + final Duration elapsed; + + @override + List get props => [recordings, lineIndex, elapsed]; +} + +/// Playing back a recording. +final class RecordingPlayback extends RecordingState { + const RecordingPlayback({ + required this.recordings, + required this.recording, + required this.position, + }); + + /// All recordings for the current script. + final List recordings; + + /// The recording being played. + final LineRecording recording; + + /// Current playback position. + final Duration position; + + @override + List get props => [recordings, recording, position]; +} + +/// Grading a recording after playback. +final class RecordingGrading extends RecordingState { + const RecordingGrading({ + required this.recordings, + required this.recording, + }); + + /// All recordings for the current script. + final List recordings; + + /// The recording to grade. + final LineRecording recording; + + @override + List get props => [recordings, recording]; +} + +/// Error state. +final class RecordingError extends RecordingState { + const RecordingError({ + required this.recordings, + required this.message, + }); + + /// All recordings for the current script. + final List recordings; + + /// Error message. + final String message; + + @override + List get props => [recordings, message]; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add horatio_app/lib/bloc/recording/recording_state.dart +git commit -m "feat(recording): add RecordingState hierarchy" +``` + +--- + +### Task 4.4: RecordingCubit + +**Files:** + +- Create: `horatio_app/lib/bloc/recording/recording_cubit.dart` +- Create: `horatio_app/test/bloc/recording_cubit_test.dart` + +- [ ] **Step 1: Write failing tests** + +This is the most complex test file. Key branches to cover: + +```dart +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/bloc/recording/recording_cubit.dart'; +import 'package:horatio_app/bloc/recording/recording_state.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockRecordingDao extends Mock implements RecordingDao {} + +class MockRecordingService extends Mock implements RecordingService {} + +class MockAudioPlaybackService extends Mock implements AudioPlaybackService {} + +void main() { + late MockRecordingDao dao; + late MockRecordingService recordingService; + late MockAudioPlaybackService playbackService; + late StreamController> recordingsController; + late StreamController statusController; + late StreamController positionController; + + const scriptId = 'script-1'; + + final testRecording = LineRecording( + id: 'r1', + scriptId: scriptId, + lineIndex: 0, + filePath: '/path/to/file.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ); + + setUp(() { + dao = MockRecordingDao(); + recordingService = MockRecordingService(); + playbackService = MockAudioPlaybackService(); + recordingsController = StreamController>.broadcast(); + statusController = StreamController.broadcast(); + positionController = StreamController.broadcast(); + + when(() => dao.watchRecordingsForScript(scriptId)) + .thenAnswer((_) => recordingsController.stream); + when(() => playbackService.status) + .thenAnswer((_) => statusController.stream); + when(() => playbackService.position) + .thenAnswer((_) => positionController.stream); + }); + + tearDown(() { + recordingsController.close(); + statusController.close(); + positionController.close(); + }); + + setUpAll(() { + registerFallbackValue(testRecording); + }); + + RecordingCubit createCubit() => RecordingCubit( + dao: dao, + recordingService: recordingService, + playbackService: playbackService, + ); + + group('RecordingCubit', () { + test('initial state is RecordingInitial', () { + final cubit = createCubit(); + expect(cubit.state, isA()); + cubit.close(); + }); + + test('loadRecordings emits RecordingIdle on stream data', () async { + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + expect(cubit.state, isA()); + expect((cubit.state as RecordingIdle).recordings, [testRecording]); + await cubit.close(); + }); + + test('startRecording transitions to RecordingInProgress', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + expect(cubit.state, isA()); + expect((cubit.state as RecordingInProgress).lineIndex, 0); + await cubit.close(); + }); + + test('startRecording emits error on permission denied', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => false); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + expect(cubit.state, isA()); + expect( + (cubit.state as RecordingError).message, + 'Microphone permission required for recording', + ); + await cubit.close(); + }); + + test('startRecording is no-op when already recording', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + await cubit.startRecording(scriptId, 1); // Should be ignored. + expect((cubit.state as RecordingInProgress).lineIndex, 0); + await cubit.close(); + }); + + test('stopRecording transitions to RecordingIdle', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + when(() => recordingService.stopRecording()) + .thenAnswer((_) async => '/path/to/file.m4a'); + when(() => dao.insertRecording(any(), any())) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + await cubit.stopRecording(); + verify(() => dao.insertRecording(scriptId, any())).called(1); + await cubit.close(); + }); + + test('stopRecording handles null path', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + when(() => recordingService.stopRecording()) + .thenAnswer((_) async => null); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + await cubit.stopRecording(); + verifyNever(() => dao.insertRecording(any(), any())); + await cubit.close(); + }); + + test('playRecording transitions to RecordingPlayback', () async { + when(() => playbackService.play(any())).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.playRecording(testRecording); + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('playback completion transitions to RecordingGrading', () async { + when(() => playbackService.play(any())).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.playRecording(testRecording); + statusController.add(PlaybackStatus.completed); + await Future.delayed(Duration.zero); + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('stopPlayback transitions to RecordingIdle', () async { + when(() => playbackService.play(any())).thenAnswer((_) async {}); + when(() => playbackService.stop()).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.playRecording(testRecording); + await cubit.stopPlayback(); + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('gradeRecording calls dao and returns to idle', () async { + when(() => dao.updateRecordingGrade('r1', 4)) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.gradeRecording('r1', 4); + verify(() => dao.updateRecordingGrade('r1', 4)).called(1); + await cubit.close(); + }); + + test('deleteRecording calls dao', () async { + when(() => dao.deleteRecording('r1')).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.deleteRecording('r1'); + verify(() => dao.deleteRecording('r1')).called(1); + await cubit.close(); + }); + + test('close cancels stream subscriptions and timer', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + await cubit.startRecording(scriptId, 0); + await cubit.close(); + // No errors from timer after close. + }); + + test('startRecording is no-op when not in loaded state', () async { + final cubit = createCubit(); + await cubit.startRecording(scriptId, 0); + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('stopRecording is no-op when not recording', () async { + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + await cubit.stopRecording(); // Should not throw. + await cubit.close(); + }); + + test('position stream updates RecordingPlayback position', () async { + when(() => playbackService.play(any())).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.playRecording(testRecording); + positionController.add(const Duration(seconds: 2)); + await Future.delayed(Duration.zero); + final state = cubit.state; + expect(state, isA()); + expect((state as RecordingPlayback).position, + const Duration(seconds: 2)); + await cubit.close(); + }); + + test('RecordingState equality', () { + expect(const RecordingInitial(), const RecordingInitial()); + expect( + RecordingIdle(recordings: [testRecording]), + RecordingIdle(recordings: [testRecording]), + ); + }); + }); +} +``` + +- [ ] **Step 2: Implement RecordingCubit** + +Key design choices: + +- Recording file path is generated by `RecordingService` (not the cubit) to avoid `dart:io` dependency in the cubit. Pass the `recordingsDir` (from `path_provider`) through the constructor. +- The cubit never touches the filesystem directly — all I/O goes through `RecordingService`. + +```dart +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/recording/recording_state.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:uuid/uuid.dart'; + +/// Manages the record → play → grade lifecycle for voice recordings. +class RecordingCubit extends Cubit { + /// Creates a [RecordingCubit]. + /// + /// [recordingsDir] is the base directory for storing recordings + /// (from path_provider's getApplicationDocumentsDirectory). + RecordingCubit({ + required RecordingDao dao, + required RecordingService recordingService, + required AudioPlaybackService playbackService, + required String recordingsDir, + }) : _dao = dao, + _recordingService = recordingService, + _playbackService = playbackService, + _recordingsDir = recordingsDir, + super(const RecordingInitial()); + + final RecordingDao _dao; + final RecordingService _recordingService; + final AudioPlaybackService _playbackService; + final String _recordingsDir; + + static const _uuid = Uuid(); + + StreamSubscription>? _recordingsSub; + StreamSubscription? _statusSub; + StreamSubscription? _positionSub; + Timer? _elapsedTimer; + + String? _scriptId; + int? _recordingLineIndex; + DateTime? _recordingStartedAt; + List _latestRecordings = []; + + /// Subscribes to recording streams for a script. + void loadRecordings(String scriptId) { + _scriptId = scriptId; + _recordingsSub?.cancel(); + _recordingsSub = + _dao.watchRecordingsForScript(scriptId).listen((recordings) { + _latestRecordings = recordings; + final current = state; + if (current is RecordingInProgress) { + emit(RecordingInProgress( + recordings: recordings, + lineIndex: current.lineIndex, + elapsed: current.elapsed, + )); + } else if (current is RecordingPlayback) { + emit(RecordingPlayback( + recordings: recordings, + recording: current.recording, + position: current.position, + )); + } else if (current is RecordingGrading) { + emit(RecordingGrading( + recordings: recordings, + recording: current.recording, + )); + } else if (current is RecordingError) { + emit(RecordingError( + recordings: recordings, + message: current.message, + )); + } else { + emit(RecordingIdle(recordings: recordings)); + } + }); + } + + /// Starts recording for a line. + Future startRecording(String scriptId, int lineIndex) async { + if (state is RecordingInProgress) return; + if (state is RecordingInitial) return; + + final hasPermission = await _recordingService.hasPermission(); + if (!hasPermission) { + emit(RecordingError( + recordings: _latestRecordings, + message: 'Microphone permission required for recording', + )); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final filePath = + '$_recordingsDir/$scriptId/line_${lineIndex}_$timestamp.m4a'; + + await _recordingService.startRecording(filePath); + _recordingLineIndex = lineIndex; + _recordingStartedAt = DateTime.now(); + + var elapsed = Duration.zero; + emit(RecordingInProgress( + recordings: _latestRecordings, + lineIndex: lineIndex, + elapsed: elapsed, + )); + + _elapsedTimer = Timer.periodic( + const Duration(milliseconds: 100), + (_) { + elapsed += const Duration(milliseconds: 100); + if (state is RecordingInProgress) { + emit(RecordingInProgress( + recordings: _latestRecordings, + lineIndex: lineIndex, + elapsed: elapsed, + )); + } + }, + ); + } + + /// Stops recording and saves to database. + Future stopRecording() async { + if (state is! RecordingInProgress) return; + _elapsedTimer?.cancel(); + _elapsedTimer = null; + + final path = await _recordingService.stopRecording(); + if (path == null) { + emit(RecordingIdle(recordings: _latestRecordings)); + return; + } + + final elapsed = _recordingStartedAt != null + ? DateTime.now().difference(_recordingStartedAt!) + : Duration.zero; + + final recording = LineRecording( + id: _uuid.v4(), + scriptId: _scriptId!, + lineIndex: _recordingLineIndex!, + filePath: path, + durationMs: elapsed.inMilliseconds, + createdAt: DateTime.now().toUtc(), + ); + + await _dao.insertRecording(_scriptId!, recording); + emit(RecordingIdle(recordings: _latestRecordings)); + } + + /// Plays a recording. + Future playRecording(LineRecording recording) async { + await _playbackService.play(recording.filePath); + emit(RecordingPlayback( + recordings: _latestRecordings, + recording: recording, + position: Duration.zero, + )); + + _positionSub?.cancel(); + _positionSub = _playbackService.position.listen((position) { + if (state is RecordingPlayback) { + emit(RecordingPlayback( + recordings: _latestRecordings, + recording: recording, + position: position, + )); + } + }); + + _statusSub?.cancel(); + _statusSub = _playbackService.status.listen((status) { + if (status == PlaybackStatus.completed && state is RecordingPlayback) { + _positionSub?.cancel(); + _statusSub?.cancel(); + emit(RecordingGrading( + recordings: _latestRecordings, + recording: recording, + )); + } + }); + } + + /// Stops playback. + Future stopPlayback() async { + await _playbackService.stop(); + _positionSub?.cancel(); + _statusSub?.cancel(); + emit(RecordingIdle(recordings: _latestRecordings)); + } + + /// Grades a recording (0-5). + Future gradeRecording(String id, int grade) => + _dao.updateRecordingGrade(id, grade); + + /// Deletes a recording. + Future deleteRecording(String id) => _dao.deleteRecording(id); + + @override + Future close() { + _recordingsSub?.cancel(); + _statusSub?.cancel(); + _positionSub?.cancel(); + _elapsedTimer?.cancel(); + return super.close(); + } +} +``` + +Also update the `RecordingService.startRecording` to create the parent directory. Change the implementation in `recording_service.dart` (from Task 4.1) to: + +```dart +import 'dart:io'; + +import 'package:record/record.dart'; + +/// Wraps the [AudioRecorder] for microphone recording. +class RecordingService { + /// Creates a [RecordingService]. + RecordingService({AudioRecorder? recorder}) + : _recorder = recorder ?? AudioRecorder(); + + final AudioRecorder _recorder; + + /// Whether the app has microphone permission. + Future hasPermission() => _recorder.hasPermission(); + + /// Starts recording to the given file path. + /// Creates the parent directory if it doesn't exist. + Future startRecording(String filePath) async { + final dir = Directory(filePath).parent; + if (!dir.existsSync()) { + await dir.create(recursive: true); + } + await _recorder.start(const RecordConfig(), path: filePath); + } + + /// Stops recording and returns the file path. + Future stopRecording() => _recorder.stop(); + + /// Releases the recorder resources. + Future dispose() => _recorder.dispose(); +} +``` + +Update the `RecordingCubit` test to pass `recordingsDir`: + +```dart +RecordingCubit createCubit() => RecordingCubit( + dao: dao, + recordingService: recordingService, + playbackService: playbackService, + recordingsDir: '/tmp/test_recordings', + ); +``` + +- [ ] **Step 3: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/bloc/recording_cubit_test.dart -v +``` + +Expected: All pass. + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/bloc/recording/ horatio_app/test/bloc/recording_cubit_test.dart +git commit -m "feat(recording): add RecordingCubit with full state machine" +``` + +--- + +### Task 4.5: Run pipeline for Chunk 4 + +- [ ] **Step 1: Run codegen + analyze + test** + +```bash +cd /home/kuhy/testsAndMisc/horatio && ./run.sh test +``` + +Expected: 100% coverage. + +--- diff --git a/horatio/docs/superpowers/plans/font-annotations-recording/05-recording-ui-notes.md b/horatio/docs/superpowers/plans/font-annotations-recording/05-recording-ui-notes.md new file mode 100644 index 0000000..84a56fe --- /dev/null +++ b/horatio/docs/superpowers/plans/font-annotations-recording/05-recording-ui-notes.md @@ -0,0 +1,1117 @@ +## Chunk 5: Recording UI + Note UX Improvements + +### Task 5.1: GradeStars widget + +**Files:** + +- Create: `horatio_app/lib/widgets/grade_stars.dart` +- Create: `horatio_app/test/widgets/grade_stars_test.dart` + +- [ ] **Step 1: Write failing tests** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/grade_stars.dart'; + +void main() { + group('GradeStars', () { + testWidgets('shows 5 star icons and blackout button', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: null, onGrade: (_) {}), + ), + ), + ); + expect(find.byIcon(Icons.star_border), findsNWidgets(5)); + expect(find.text('Blackout'), findsOneWidget); + }); + + testWidgets('filled stars match grade', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: 3, onGrade: (_) {}), + ), + ), + ); + expect(find.byIcon(Icons.star), findsNWidgets(3)); + expect(find.byIcon(Icons.star_border), findsNWidgets(2)); + }); + + testWidgets('tapping star calls onGrade', (tester) async { + int? graded; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: null, onGrade: (g) => graded = g), + ), + ), + ); + // Tap the 4th star (index 3, value 4). + await tester.tap(find.byIcon(Icons.star_border).at(3)); + expect(graded, 4); + }); + + testWidgets('tapping blackout calls onGrade with 0', (tester) async { + int? graded; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: null, onGrade: (g) => graded = g), + ), + ), + ); + await tester.tap(find.text('Blackout')); + expect(graded, 0); + }); + + testWidgets('grade 0 highlights blackout button', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: 0, onGrade: (_) {}), + ), + ), + ); + // All stars empty when grade is 0. + expect(find.byIcon(Icons.star_border), findsNWidgets(5)); + }); + + testWidgets('grade 5 fills all stars', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: 5, onGrade: (_) {}), + ), + ), + ); + expect(find.byIcon(Icons.star), findsNWidgets(5)); + expect(find.byIcon(Icons.star_border), findsNothing); + }); + }); +} +``` + +- [ ] **Step 2: Implement GradeStars** + +```dart +import 'package:flutter/material.dart'; + +/// A 0–5 grade widget with tappable stars and a "Blackout" (grade 0) button. +class GradeStars extends StatelessWidget { + /// Creates a [GradeStars]. + const GradeStars({ + required this.grade, + required this.onGrade, + super.key, + }); + + /// Current grade (0-5), null if not yet graded. + final int? grade; + + /// Called with the selected grade (0-5). + final ValueChanged onGrade; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => onGrade(0), + style: TextButton.styleFrom( + foregroundColor: + grade == 0 ? Colors.red : null, + ), + child: const Text('Blackout'), + ), + for (var i = 1; i <= 5; i++) + IconButton( + icon: Icon( + grade != null && i <= grade! ? Icons.star : Icons.star_border, + color: Colors.amber, + ), + onPressed: () => onGrade(i), + ), + ], + ); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/widgets/grade_stars_test.dart -v +``` + +Expected: All pass. + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/widgets/grade_stars.dart horatio_app/test/widgets/grade_stars_test.dart +git commit -m "feat(recording): add GradeStars widget" +``` + +--- + +### Task 5.2: RecordingBadge widget + +**Files:** + +- Create: `horatio_app/lib/widgets/recording_badge.dart` +- Create: `horatio_app/test/widgets/recording_badge_test.dart` + +- [ ] **Step 1: Write failing tests** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/recording_badge.dart'; + +void main() { + group('RecordingBadge', () { + testWidgets('hidden when count is 0', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingBadge(recordingCount: 0, onTap: () {}), + ), + ), + ); + expect(find.byType(SizedBox), findsOneWidget); + expect(find.byIcon(Icons.mic), findsNothing); + }); + + testWidgets('shows mic icon and count', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingBadge(recordingCount: 3, onTap: () {}), + ), + ), + ); + expect(find.byIcon(Icons.mic), findsOneWidget); + expect(find.text('3'), findsOneWidget); + }); + + testWidgets('tap calls onTap', (tester) async { + var tapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingBadge( + recordingCount: 1, + onTap: () => tapped = true, + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.mic)); + expect(tapped, isTrue); + }); + }); +} +``` + +- [ ] **Step 2: Implement RecordingBadge** + +```dart +import 'package:flutter/material.dart'; + +/// A small mic icon with count badge, showing recordings per line. +class RecordingBadge extends StatelessWidget { + /// Creates a [RecordingBadge]. + const RecordingBadge({ + required this.recordingCount, + required this.onTap, + super.key, + }); + + /// Number of recordings for the line. + final int recordingCount; + + /// Callback when tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + if (recordingCount == 0) return const SizedBox.shrink(); + return GestureDetector( + onTap: onTap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.mic, size: 16), + const SizedBox(width: 2), + Text( + '$recordingCount', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + ], + ), + ); + } +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/widgets/recording_badge_test.dart -v +``` + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/widgets/recording_badge.dart horatio_app/test/widgets/recording_badge_test.dart +git commit -m "feat(recording): add RecordingBadge widget" +``` + +--- + +### Task 5.3: RecordingActionBar widget + +**Files:** + +- Create: `horatio_app/lib/widgets/recording_action_bar.dart` +- Create: `horatio_app/test/widgets/recording_action_bar_test.dart` + +- [ ] **Step 1: Write failing tests** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/recording_action_bar.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + group('RecordingActionBar', () { + testWidgets('shows record button', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: false, + elapsed: Duration.zero, + latestRecording: null, + onRecord: () {}, + onStop: () {}, + onPlay: () {}, + ), + ), + ), + ); + expect(find.byIcon(Icons.mic), findsOneWidget); + }); + + testWidgets('shows stop button when recording', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: true, + elapsed: const Duration(seconds: 5), + latestRecording: null, + onRecord: () {}, + onStop: () {}, + onPlay: () {}, + ), + ), + ), + ); + expect(find.byIcon(Icons.stop), findsOneWidget); + expect(find.textContaining('0:05'), findsOneWidget); + }); + + testWidgets('play button enabled when recording exists', (tester) async { + final recording = LineRecording( + id: 'r1', + scriptId: 's1', + lineIndex: 0, + filePath: '/p.m4a', + durationMs: 3000, + createdAt: DateTime.utc(2026), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: false, + elapsed: Duration.zero, + latestRecording: recording, + onRecord: () {}, + onStop: () {}, + onPlay: () {}, + ), + ), + ), + ); + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + }); + + testWidgets('play button disabled when no recording', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: false, + elapsed: Duration.zero, + latestRecording: null, + onRecord: () {}, + onStop: () {}, + onPlay: () {}, + ), + ), + ), + ); + final playButton = tester.widget( + find.widgetWithIcon(IconButton, Icons.play_arrow), + ); + expect(playButton.onPressed, isNull); + }); + + testWidgets('tap record calls onRecord', (tester) async { + var called = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: false, + elapsed: Duration.zero, + latestRecording: null, + onRecord: () => called = true, + onStop: () {}, + onPlay: () {}, + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.mic)); + expect(called, isTrue); + }); + + testWidgets('tap stop calls onStop', (tester) async { + var called = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: true, + elapsed: Duration.zero, + latestRecording: null, + onRecord: () {}, + onStop: () => called = true, + onPlay: () {}, + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.stop)); + expect(called, isTrue); + }); + }); +} +``` + +- [ ] **Step 2: Implement RecordingActionBar** + +```dart +import 'package:flutter/material.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// Bottom action bar for record/play controls on a selected line. +class RecordingActionBar extends StatelessWidget { + /// Creates a [RecordingActionBar]. + const RecordingActionBar({ + required this.isRecording, + required this.elapsed, + required this.latestRecording, + required this.onRecord, + required this.onStop, + required this.onPlay, + super.key, + }); + + /// Whether currently recording. + final bool isRecording; + + /// Elapsed recording time. + final Duration elapsed; + + /// Most recent recording for the selected line (null if none). + final LineRecording? latestRecording; + + /// Start recording callback. + final VoidCallback onRecord; + + /// Stop recording callback. + final VoidCallback onStop; + + /// Play last recording callback. + final VoidCallback onPlay; + + String _formatDuration(Duration d) { + final minutes = d.inMinutes; + final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border( + top: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues( + alpha: 0.3, + ), + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isRecording) ...[ + IconButton( + icon: const Icon(Icons.stop, color: Colors.red), + onPressed: onStop, + tooltip: 'Stop Recording', + ), + Text(_formatDuration(elapsed)), + ] else ...[ + IconButton( + icon: const Icon(Icons.mic), + onPressed: onRecord, + tooltip: 'Record', + ), + ], + const SizedBox(width: 16), + IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: latestRecording != null ? onPlay : null, + tooltip: 'Play Last Recording', + ), + ], + ), + ); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/widgets/recording_action_bar_test.dart -v +``` + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/widgets/recording_action_bar.dart horatio_app/test/widgets/recording_action_bar_test.dart +git commit -m "feat(recording): add RecordingActionBar widget" +``` + +--- + +### Task 5.4: RecordingListSheet widget + +**Files:** + +- Create: `horatio_app/lib/widgets/recording_list_sheet.dart` +- Create: `horatio_app/test/widgets/recording_list_sheet_test.dart` + +- [ ] **Step 1: Write failing tests** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/recording_list_sheet.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + final recordings = [ + LineRecording( + id: 'r1', + scriptId: 's1', + lineIndex: 0, + filePath: '/p1.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + grade: 3, + ), + LineRecording( + id: 'r2', + scriptId: 's1', + lineIndex: 0, + filePath: '/p2.m4a', + durationMs: 3000, + createdAt: DateTime.utc(2026, 1, 2), + ), + ]; + + group('RecordingListSheet', () { + testWidgets('shows recordings', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: recordings, + onPlay: (_) {}, + onDelete: (_) {}, + ), + ), + ), + ); + expect(find.textContaining('5.0s'), findsOneWidget); + expect(find.textContaining('3.0s'), findsOneWidget); + }); + + testWidgets('shows empty message', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: const [], + onPlay: (_) {}, + onDelete: (_) {}, + ), + ), + ), + ); + expect(find.text('No recordings'), findsOneWidget); + }); + + testWidgets('tap play calls onPlay', (tester) async { + LineRecording? played; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: recordings, + onPlay: (r) => played = r, + onDelete: (_) {}, + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.play_arrow).first); + expect(played?.id, 'r1'); + }); + + testWidgets('tap delete calls onDelete', (tester) async { + String? deleted; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: recordings, + onPlay: (_) {}, + onDelete: (id) => deleted = id, + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.delete).first); + expect(deleted, 'r1'); + }); + + testWidgets('shows grade for graded recording', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: recordings, + onPlay: (_) {}, + onDelete: (_) {}, + ), + ), + ), + ); + // First recording has grade 3. + expect(find.byIcon(Icons.star), findsWidgets); + }); + }); +} +``` + +- [ ] **Step 2: Implement RecordingListSheet** + +```dart +import 'package:flutter/material.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:intl/intl.dart'; + +/// Bottom sheet listing all recordings for a line. +class RecordingListSheet extends StatelessWidget { + /// Creates a [RecordingListSheet]. + const RecordingListSheet({ + required this.recordings, + required this.onPlay, + required this.onDelete, + super.key, + }); + + /// Recordings to display. + final List recordings; + + /// Called when play is tapped for a recording. + final ValueChanged onPlay; + + /// Called when delete is tapped for a recording. + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Recordings', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (recordings.isEmpty) + const Center(child: Text('No recordings')) + else + ...recordings.map( + (r) => ListTile( + leading: IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () => onPlay(r), + ), + title: Text( + '${(r.durationMs / 1000).toStringAsFixed(1)}s — ' + '${DateFormat.yMd().format(r.createdAt)}', + ), + subtitle: r.grade != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < r.grade!; i++) + const Icon(Icons.star, + size: 14, color: Colors.amber), + ], + ) + : const Text('Not graded'), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => onDelete(r.id), + ), + ), + ), + ], + ), + ); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/widgets/recording_list_sheet_test.dart -v +``` + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/widgets/recording_list_sheet.dart horatio_app/test/widgets/recording_list_sheet_test.dart +git commit -m "feat(recording): add RecordingListSheet widget" +``` + +--- + +### Task 5.5: NoteChip widget + +**Files:** + +- Create: `horatio_app/lib/widgets/note_chip.dart` +- Create: `horatio_app/test/widgets/note_chip_test.dart` + +- [ ] **Step 1: Write failing tests** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/note_chip.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + group('NoteChip', () { + testWidgets('shows truncated text and category', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteChip( + note: LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'This is a very long note that should be truncated', + createdAt: DateTime.utc(2026), + ), + onTap: () {}, + onDelete: () {}, + ), + ), + ), + ); + // Truncated to 30 chars. + expect(find.textContaining('This is a very long note that '), findsOneWidget); + }); + + testWidgets('short text not truncated', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteChip( + note: LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.emotion, + text: 'Short note', + createdAt: DateTime.utc(2026), + ), + onTap: () {}, + onDelete: () {}, + ), + ), + ), + ); + expect(find.text('Short note'), findsOneWidget); + }); + + testWidgets('tap calls onTap', (tester) async { + var tapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteChip( + note: LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.general, + text: 'Test', + createdAt: DateTime.utc(2026), + ), + onTap: () => tapped = true, + onDelete: () {}, + ), + ), + ), + ); + await tester.tap(find.byType(ActionChip)); + expect(tapped, isTrue); + }); + + testWidgets('long-press calls onDelete', (tester) async { + var deleted = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteChip( + note: LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.blocking, + text: 'Test', + createdAt: DateTime.utc(2026), + ), + onTap: () {}, + onDelete: () => deleted = true, + ), + ), + ), + ); + await tester.longPress(find.byType(GestureDetector).first); + expect(deleted, isTrue); + }); + }); +} +``` + +- [ ] **Step 2: Implement NoteChip** + +```dart +import 'package:flutter/material.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// Category icons for note chips. +const Map noteCategoryIcons = { + NoteCategory.intention: Icons.psychology, + NoteCategory.subtext: Icons.chat_bubble_outline, + NoteCategory.blocking: Icons.directions_walk, + NoteCategory.emotion: Icons.favorite, + NoteCategory.delivery: Icons.record_voice_over, + NoteCategory.general: Icons.note, +}; + +/// An inline chip displaying a note's category icon and truncated text. +class NoteChip extends StatelessWidget { + /// Creates a [NoteChip]. + const NoteChip({ + required this.note, + required this.onTap, + required this.onDelete, + super.key, + }); + + /// The note to display. + final LineNote note; + + /// Called when the chip is tapped (edit). + final VoidCallback onTap; + + /// Called when the chip is long-pressed (delete). + final VoidCallback onDelete; + + String get _truncatedText => + note.text.length > 30 ? '${note.text.substring(0, 30)}…' : note.text; + + @override + Widget build(BuildContext context) => GestureDetector( + onLongPress: onDelete, + child: ActionChip( + avatar: Icon( + noteCategoryIcons[note.category] ?? Icons.note, + size: 16, + ), + label: Text(_truncatedText), + onPressed: onTap, + ), + ); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/widgets/note_chip_test.dart -v +``` + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/widgets/note_chip.dart horatio_app/test/widgets/note_chip_test.dart +git commit -m "feat(notes): add NoteChip widget with category icon and truncation" +``` + +--- + +### Task 5.6: Update NoteEditorSheet for edit mode + +**Files:** + +- Modify: `horatio_app/lib/widgets/note_editor_sheet.dart` + +- [ ] **Step 1: Add noteId parameter** + +Change `onSave` callback type and add `noteId` parameter: + +```dart +/// 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, + this.noteId, + super.key, + }); + + /// Called with the chosen category, text, and optional noteId on save. + final void Function(NoteCategory category, String text, {String? noteId}) + 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; + + /// Non-null when editing an existing note. + final String? noteId; +``` + +Update `_submit` to pass `noteId`: + +```dart +void _submit() { + if (_formKey.currentState!.validate()) { + widget.onSave( + _category, + _textController.text.trim(), + noteId: widget.noteId, + ); + } +} +``` + +- [ ] **Step 2: Update call sites and tests for new callback signature** + +Update `_showNoteEditor` in `annotation_editor_screen.dart` (from Chunk 2) to use the new signature: + +```dart + void _showNoteEditor(BuildContext context) { + final cubit = context.read(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: NoteEditorSheet( + onSave: (category, text, {String? noteId}) { + cubit.addNote( + lineIndex: widget.lineIndex, + category: category, + text: text, + ); + Navigator.pop(context); + }, + onCancel: () => Navigator.pop(context), + ), + ), + ); + } +``` + +In all tests that use `NoteEditorSheet`, update `onSave` to accept the named `noteId` parameter: + +```dart +onSave: (category, text, {String? noteId}) { ... }, +``` + +- [ ] **Step 3: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test +``` + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/widgets/note_editor_sheet.dart horatio_app/test/ +git commit -m "feat(notes): add noteId parameter to NoteEditorSheet for edit mode" +``` + +--- + +### Task 5.7: Update AnnotationDao + AnnotationCubit for note category updates + +**Files:** + +- Modify: `horatio_app/lib/database/daos/annotation_dao.dart` +- Modify: `horatio_app/lib/bloc/annotation/annotation_cubit.dart` +- Modify: `horatio_app/test/bloc/annotation_cubit_test.dart` + +- [ ] **Step 1: Add updateNoteCategory to AnnotationDao** + +Add this method to `AnnotationDao`: + +```dart +/// Updates the category of a note. +Future updateNoteCategory(String id, NoteCategory category) => + (update(lineNotesTable)..where((t) => t.id.equals(id))) + .write(LineNotesTableCompanion(category: Value(category.name))); +``` + +- [ ] **Step 2: Update AnnotationCubit.updateNote** + +Change signature to accept optional category: + +```dart +/// Updates a note's text and/or category. +Future updateNote( + String id, { + String? text, + NoteCategory? category, +}) async { + if (text != null) { + await _dao.updateNoteText(id, text); + } + if (category != null) { + await _dao.updateNoteCategory(id, category); + } +} +``` + +- [ ] **Step 3: Update tests** + +In `annotation_cubit_test.dart`, update the `updateNote` test and add a category update test: + +```dart +test('updateNote calls dao.updateNoteText', () async { + when(() => dao.updateNoteText('n1', 'new')) + .thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.updateNote('n1', text: 'new'); + verify(() => dao.updateNoteText('n1', 'new')).called(1); + await cubit.close(); +}); + +test('updateNote calls dao.updateNoteCategory', () async { + when(() => dao.updateNoteCategory('n1', NoteCategory.emotion)) + .thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.updateNote('n1', category: NoteCategory.emotion); + verify(() => dao.updateNoteCategory('n1', NoteCategory.emotion)).called(1); + await cubit.close(); +}); + +test('updateNote with both text and category', () async { + when(() => dao.updateNoteText('n1', 'new')) + .thenAnswer((_) async {}); + when(() => dao.updateNoteCategory('n1', NoteCategory.blocking)) + .thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.updateNote('n1', text: 'new', category: NoteCategory.blocking); + verify(() => dao.updateNoteText('n1', 'new')).called(1); + verify(() => dao.updateNoteCategory('n1', NoteCategory.blocking)).called(1); + await cubit.close(); +}); + +test('updateNote with no arguments is no-op', () async { + final cubit = AnnotationCubit(dao: dao); + await cubit.updateNote('n1'); + verifyNever(() => dao.updateNoteText(any(), any())); + verifyNever(() => dao.updateNoteCategory(any(), any())); + await cubit.close(); +}); +``` + +- [ ] **Step 4: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/bloc/annotation_cubit_test.dart -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add horatio_app/lib/database/daos/annotation_dao.dart horatio_app/lib/bloc/annotation/annotation_cubit.dart horatio_app/test/bloc/annotation_cubit_test.dart +git commit -m "feat(notes): add updateNoteCategory to DAO and cubit" +``` + +--- + +### Task 5.8: Run pipeline for Chunk 5 + +- [ ] **Step 1: Run full pipeline** + +```bash +cd /home/kuhy/testsAndMisc/horatio && ./run.sh test +``` + +Expected: 100% coverage. + +--- diff --git a/horatio/docs/superpowers/plans/font-annotations-recording/06-screen-integration.md b/horatio/docs/superpowers/plans/font-annotations-recording/06-screen-integration.md new file mode 100644 index 0000000..2b9e79d --- /dev/null +++ b/horatio/docs/superpowers/plans/font-annotations-recording/06-screen-integration.md @@ -0,0 +1,558 @@ +## Chunk 6: Screen Integration + Providers + Final Pipeline + +### Task 6.1: Add RecordingDao + services to app.dart + +**Files:** + +- Modify: `horatio_app/lib/app.dart` +- Modify: `horatio_app/test/app_test.dart` + +- [ ] **Step 1: Update app.dart providers** + +Add `RecordingDao`, `RecordingService`, and `AudioPlaybackService` as `RepositoryProvider`s: + +```dart +import 'package:horatio_app/database/daos/recording_dao.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; +``` + +In `MultiRepositoryProvider.providers`, add: + +```dart +RepositoryProvider( + create: (_) => database.recordingDao, +), +RepositoryProvider( + create: (_) => RecordingService(), + dispose: (service) => service.dispose(), +), +RepositoryProvider( + create: (_) => AudioPlaybackService(), + dispose: (service) => service.dispose(), +), +``` + +The `HoratioApp` constructor must accept a `recordingsDir` parameter (String): + +```dart +class HoratioApp extends StatelessWidget { + const HoratioApp({ + required this.database, + required this.recordingsDir, + super.key, + }); + + final AppDatabase database; + final String recordingsDir; +``` + +And add the recordings dir to the `MultiRepositoryProvider.providers` list so screens can access it: + +```dart +RepositoryProvider.value(value: recordingsDir), +``` + +In `main.dart`, pass `recordingsDir` from `path_provider`: + +```dart +final appDocDir = await getApplicationDocumentsDirectory(); +final recordingsDir = '${appDocDir.path}/horatio_recordings'; +// ... +HoratioApp(database: database, recordingsDir: recordingsDir), +``` + +Note: `RepositoryProvider` in flutter_bloc ^9.0.0 supports the `dispose` parameter — this is confirmed in the official docs and the constructor signature: `RepositoryProvider({required T create(BuildContext), void dispose(T)?, ...})`. + +- [ ] **Step 2: Update app_test.dart** + +Add mock classes for the new dependencies and wire them into the test builders: + +```dart +class MockRecordingDao extends Mock implements RecordingDao {} +class MockRecordingService extends Mock implements RecordingService {} +class MockAudioPlaybackService extends Mock implements AudioPlaybackService {} +``` + +In `_buildScreen` and `_buildScreenWithRouter`, wrap with the new `RepositoryProvider`s: + +```dart +Widget _buildScreen(Script script) => MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: _scriptRepo), + RepositoryProvider.value(value: _annotationDao), + RepositoryProvider.value(value: _recordingDao), + RepositoryProvider.value(value: _recordingService), + RepositoryProvider.value( + value: _playbackService, + ), + ], + child: MaterialApp(home: HoratioApp(database: _database)), + ); +``` + +Existing tests should still pass since the new providers are lazy (only created on first access). + +- [ ] **Step 3: Run tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test test/app_test.dart -v +``` + +- [ ] **Step 4: Commit** + +```bash +git add horatio_app/lib/app.dart horatio_app/test/app_test.dart +git commit -m "feat(app): add RecordingDao and audio services to providers" +``` + +--- + +### Task 6.2: Integrate recording + note chips into AnnotationEditorScreen + +**Files:** + +- Modify: `horatio_app/lib/screens/annotation_editor_screen.dart` +- Modify: `horatio_app/test/screens/annotation_editor_screen_test.dart` + +- [ ] **Step 1: Add RecordingCubit provider to AnnotationEditorScreen** + +In the `AnnotationEditorScreen.build` method, add `RecordingCubit` to the `MultiBlocProvider`. Also pass `recordingsDir` — the `HoratioApp` (in `app.dart`) must pass the documents directory path down. For simplicity, read it from a `RepositoryProvider` keyed by a typedef: + +In `app.dart`, add the recordings dir as a named provider (added in Task 6.1): + +```dart +RepositoryProvider.value( + value: recordingsDir, // passed from main.dart +), +``` + +In `AnnotationEditorScreen.build`, update the `MultiBlocProvider.providers` list: + +```dart +BlocProvider( + create: (context) => RecordingCubit( + dao: context.read(), + recordingService: context.read(), + playbackService: context.read(), + recordingsDir: context.read(), + )..loadRecordings(script.id), +), +``` + +Add these imports at the top of `annotation_editor_screen.dart`: + +```dart +import 'package:horatio_app/bloc/recording/recording_cubit.dart'; +import 'package:horatio_app/bloc/recording/recording_state.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; +import 'package:horatio_app/widgets/note_chip.dart'; +import 'package:horatio_app/widgets/recording_action_bar.dart'; +import 'package:horatio_app/widgets/recording_badge.dart'; +import 'package:horatio_app/widgets/recording_list_sheet.dart'; +``` + +- [ ] **Step 2: Add RecordingActionBar below the line list** + +Replace `_AnnotationEditorBody.build`'s `body:` parameter — swap the bare `BlocBuilder` with a `Column` containing both the line list and a conditional `RecordingActionBar`: + +```dart + body: BlocBuilder( + builder: (context, annotationState) => switch (annotationState) { + AnnotationInitial() => + const Center(child: CircularProgressIndicator()), + AnnotationLoaded() => Column( + children: [ + Expanded(child: _buildLineList(context, annotationState)), + if (annotationState.selectedLineIndex != null) + BlocBuilder( + builder: (context, recState) { + final lineIndex = + annotationState.selectedLineIndex!; + final isRecording = recState is RecordingInProgress && + recState.lineIndex == lineIndex; + final elapsed = + isRecording ? recState.elapsed : Duration.zero; + final recordings = recState.recordings + .where((r) => r.lineIndex == lineIndex) + .toList(); + return RecordingActionBar( + isRecording: isRecording, + elapsed: elapsed, + latestRecording: + recordings.isNotEmpty ? recordings.last : null, + onRecord: () => context + .read() + .startRecording(script.id, lineIndex), + onStop: () => + context.read().stopRecording(), + onPlay: () { + if (recordings.isNotEmpty) { + context + .read() + .playRecording(recordings.last); + } + }, + ); + }, + ), + ], + ), + }, + ), +``` + +- [ ] **Step 3: Add NoteChips and RecordingBadge to \_LineTile** + +Replace the `_LineTile.build` method's `child: Padding(...)` with a `Column` containing both the existing content and a conditional `Wrap` of `NoteChip` widgets when the line is selected: + +```dart + @override + Widget build(BuildContext context) => Container( + color: widget.isSelected + ? Theme.of(context).colorScheme.primaryContainer.withValues( + alpha: 0.3, + ) + : null, + child: InkWell( + onTap: () => + context.read().selectLine(widget.lineIndex), + onLongPress: () => _showMarkPicker(context), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: widget.isSelected + ? _SelectableMarkOverlay( + text: widget.line.text, + marks: widget.marks, + lineIndex: widget.lineIndex, + ) + : MarkOverlay( + text: widget.line.text, + marks: widget.marks, + ), + ), + BlocBuilder( + builder: (context, recState) { + final count = recState.recordings + .where((r) => r.lineIndex == widget.lineIndex) + .length; + return RecordingBadge( + recordingCount: count, + onTap: () => _showRecordingList( + context, + recState.recordings + .where( + (r) => r.lineIndex == widget.lineIndex, + ) + .toList(), + ), + ); + }, + ), + NoteIndicator( + noteCount: widget.notes.length, + onTap: () => _showNoteEditor(context), + ), + ], + ), + if (widget.isSelected && widget.notes.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: widget.notes + .map( + (note) => NoteChip( + note: note, + onTap: () => + _showNoteEditorForEdit(context, note), + onDelete: () => context + .read() + .removeNote(note.id), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ), + ); +``` + +Add a helper to show the recording list bottom sheet: + +```dart + void _showRecordingList( + BuildContext context, + List recordings, + ) { + showModalBottomSheet( + context: context, + builder: (_) => RecordingListSheet( + recordings: recordings, + onPlay: (recording) { + Navigator.pop(context); + context.read().playRecording(recording); + }, + onGrade: (id, grade) => + context.read().gradeRecording(id, grade), + onDelete: (id) => + context.read().deleteRecording(id), + ), + ); + } + + void _showNoteEditorForEdit(BuildContext context, LineNote note) { + final cubit = context.read(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: NoteEditorSheet( + initialCategory: note.category, + initialText: note.text, + noteId: note.id, + onSave: (category, text, {String? noteId}) { + if (noteId != null) { + cubit.updateNote(noteId, text); + } + Navigator.pop(context); + }, + onCancel: () => Navigator.pop(context), + ), + ), + ); + } +``` + +- [ ] **Step 4: Update existing tests with mock providers** + +Add mock classes and streams at the top of `annotation_editor_screen_test.dart`: + +```dart +import 'package:horatio_app/bloc/recording/recording_cubit.dart'; +import 'package:horatio_app/bloc/recording/recording_state.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; + +class MockRecordingDao extends Mock implements RecordingDao {} +class MockRecordingService extends Mock implements RecordingService {} +class MockAudioPlaybackService extends Mock implements AudioPlaybackService {} +``` + +Add setup for recording mocks: + +```dart +late MockRecordingDao _recordingDao; +late StreamController> _recordingsCtrl; +late MockRecordingService _recordingService; +late MockAudioPlaybackService _playbackService; + +void _setUpRecordingMocks() { + _recordingDao = MockRecordingDao(); + _recordingsCtrl = StreamController>.broadcast(); + _recordingService = MockRecordingService(); + _playbackService = MockAudioPlaybackService(); + + when(() => _recordingDao.watchRecordingsForScript(any())) + .thenAnswer((_) => _recordingsCtrl.stream); +} +``` + +Update `_setUpDao` to call `_setUpRecordingMocks()` at the end. +Update `_tearDownStreams` to also close `_recordingsCtrl`. + +Update `_buildScreen` and `_buildScreenWithRouter` to provide the recording dependencies: + +```dart +Widget _buildScreen(Script script) => MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: _dao), + RepositoryProvider.value(value: _recordingDao), + RepositoryProvider.value(value: _recordingService), + RepositoryProvider.value( + value: _playbackService, + ), + RepositoryProvider.value(value: '/tmp/test_recordings'), + ], + child: MaterialApp( + home: AnnotationEditorScreen(script: script), + ), + ); +``` + +Apply the same pattern to `_buildScreenWithRouter`. + +- [ ] **Step 5: Add integration tests for the new interactions** + +```dart + testWidgets('shows recording action bar when line selected', + (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([]); + _recordingsCtrl.add([]); + await tester.pumpAndSettle(); + + // Tap to select a line. + await tester.tap(find.text('To be or not to be.')); + await tester.pumpAndSettle(); + + expect(find.byType(RecordingActionBar), findsOneWidget); + }); + + testWidgets('hides recording action bar when no line selected', + (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([]); + _recordingsCtrl.add([]); + await tester.pumpAndSettle(); + + expect(find.byType(RecordingActionBar), findsNothing); + }); + + testWidgets('shows note chips for selected line', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([ + LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.general, + text: 'A test note', + createdAt: DateTime.utc(2026), + ), + ]); + _recordingsCtrl.add([]); + await tester.pumpAndSettle(); + + // Tap to select the first line. + await tester.tap(find.text('To be or not to be.')); + await tester.pumpAndSettle(); + + expect(find.byType(NoteChip), findsOneWidget); + expect(find.text('A test note'), findsOneWidget); + }); + + testWidgets('recording badge shows count for line', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([]); + _recordingsCtrl.add([ + LineRecording( + id: 'r1', + scriptId: 'editor-screen-test', + lineIndex: 0, + filePath: '/tmp/rec.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ), + ]); + await tester.pumpAndSettle(); + + expect(find.byType(RecordingBadge), findsWidgets); + expect(find.text('1'), findsOneWidget); + }); + + testWidgets('long-press note chip calls removeNote', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + final note = LineNote( + id: 'n-del', + lineIndex: 0, + category: NoteCategory.general, + text: 'Delete me', + createdAt: DateTime.utc(2026), + ); + + when(() => _dao.deleteNote(any())).thenAnswer((_) async {}); + _marksCtrl.add([]); + _notesCtrl.add([note]); + _recordingsCtrl.add([]); + await tester.pumpAndSettle(); + + // Select line. + await tester.tap(find.text('To be or not to be.')); + await tester.pumpAndSettle(); + + // Long-press the NoteChip. + await tester.longPress(find.byType(NoteChip)); + await tester.pumpAndSettle(); + + verify(() => _dao.deleteNote('n-del')).called(1); + }); +``` + +- [ ] **Step 7: Run all tests** + +```bash +cd /home/kuhy/testsAndMisc/horatio/horatio_app && flutter test +``` + +- [ ] **Step 8: Commit** + +```bash +git add horatio_app/lib/screens/annotation_editor_screen.dart horatio_app/test/screens/annotation_editor_screen_test.dart +git commit -m "feat(screen): integrate recording UI, note chips, and recording badges" +``` + +--- + +### Task 6.3: Run full pipeline + +- [ ] **Step 1: Run codegen + analyze + test** + +```bash +cd /home/kuhy/testsAndMisc/horatio && ./run.sh test +``` + +Expected: 100% coverage, all analyses pass, dead code check clean. + +- [ ] **Step 2: Fix any remaining issues** + +Coverage gaps, lint warnings, dead code — fix iteratively until 100%. + +--- + +### Task 6.4: Final commit + +- [ ] **Step 1: Commit all remaining changes** + +```bash +git add -A +git commit -m "feat: responsive font scaling, word-level marks, voice recording, note UX improvements" +``` + +- [ ] **Step 2: Confirm pipeline passes one final time** + +```bash +cd /home/kuhy/testsAndMisc/horatio && ./run.sh -f test +``` + +Expected: All green. diff --git a/horatio/docs/superpowers/specs/2026-03-29-font-annotations-recording-design.md b/horatio/docs/superpowers/specs/2026-03-29-font-annotations-recording-design.md new file mode 100644 index 0000000..0c87b44 --- /dev/null +++ b/horatio/docs/superpowers/specs/2026-03-29-font-annotations-recording-design.md @@ -0,0 +1,468 @@ +# Responsive Font, Working Annotations & Voice Recording — Design Spec + +**Date**: 2026-03-29 +**Status**: APPROVED (review round 2 passed — 17/17 original findings resolved, 3/3 new findings resolved) +**Scope**: Font scaling, word-level marks, note UX improvements, per-line voice recording with grading + +--- + +## Problem Statement + +Three issues identified by manual testing on a 4K Linux desktop: + +1. **Font size**: Default Material 14sp body text renders unreadably small on high-DPI displays. No manual scaling control exists. +2. **Annotations partially broken**: Long-press marks the entire line (`startOffset: 0, endOffset: text.length`). Users cannot select specific words. Voice recording per line is unimplemented. +3. **Note UX**: Notes show only a count badge; there is no inline expansion, editing of existing notes, or deletion gesture. + +## Out of Scope + +- Demo mode (separate spec) +- SRS integration of voice recordings +- Cloud sync of recordings or annotations +- Multi-device recording format compatibility + +--- + +## Section 1: Responsive Font Scaling + +### Architecture + +``` +TextScaleCubit (flutter_bloc) + ├── state: TextScaleState { scaleFactor: double } + ├── loadScale() → reads SharedPreferences + ├── setScale(double) → persists + emits + └── autoDetect(Size size, double dpr) → heuristic for 4K + +SharedPreferences key: "text_scale_factor" +``` + +### Auto-Detection Heuristic + +On first launch (no saved preference): + +``` +physicalWidth = size.width * devicePixelRatio + +if physicalWidth >= 3200 (roughly 4K) AND platform is desktop: + initialScale = 1.5 +else: + initialScale = 1.0 +``` + +The heuristic only runs when no preference is saved. Once the user sets a manual value, it is always used. + +**Context resolution**: `autoDetect` accepts raw `Size` and `double dpr` parameters (not a `BuildContext`) so it can be called before any `MediaQuery` override is applied. In `app.dart`, the auto-detection runs inside a `Builder` widget that sits **above** the `MediaQuery` text-scale override, reading the device's real `MediaQuery.of(context)` before it is wrapped. + +### Manual Control + +- **Settings icon** (gear) added to the app's main `AppBar` (home screen) and annotation editor `AppBar` +- Tapping opens a `BottomSheet` with: + - `Slider` from 0.5 to 3.0, step 0.1 + - Live preview text: "Sample text at {scale}x" + - "Reset to auto" button +- Value persisted immediately on slider change + +### Integration Point + +In `app.dart`, wrap `MaterialApp.router` in: + +```dart +BlocBuilder( + builder: (context, state) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(state.scaleFactor), + ), + child: MaterialApp.router(...), + ), +) +``` + +`TextScaleCubit` is provided in the `MultiBlocProvider` block in `app.dart` (alongside `ScriptImportCubit` and `SrsReviewCubit`), initialized with `loadScale()` in `main.dart`. Auto-detection is triggered from a `Builder` widget above the `MediaQuery` override. + +### Files + +| File | Action | +| -------------------------------------------------------------- | ---------------------------------------------- | +| `horatio_app/lib/bloc/text_scale/text_scale_cubit.dart` | NEW | +| `horatio_app/lib/bloc/text_scale/text_scale_state.dart` | NEW | +| `horatio_app/lib/app.dart` | MODIFY — wrap MaterialApp, add BlocProvider | +| `horatio_app/lib/main.dart` | MODIFY — init SharedPreferences, pass to cubit | +| `horatio_app/lib/widgets/text_scale_settings_sheet.dart` | NEW | +| `horatio_app/lib/screens/annotation_editor_screen.dart` | MODIFY — add settings icon to AppBar | +| `horatio_app/lib/screens/home_screen.dart` | MODIFY — add settings icon to AppBar | +| `horatio_app/pubspec.yaml` | MODIFY — add `shared_preferences` | +| `horatio_app/test/bloc/text_scale_cubit_test.dart` | NEW | +| `horatio_app/test/widgets/text_scale_settings_sheet_test.dart` | NEW | + +--- + +## Section 2: Word-Level Mark Selection + +### Interaction Flow + +``` +Tap line → line becomes "selected" (existing selectLine) + → plain text switches to SelectableText.rich + → user drags to select a word/phrase range + +Selection change → floating MarkSelectionToolbar appears + above the selection with 6 colored chips + +Tap chip → addMark(lineIndex, startOffset, endOffset, type) + → toolbar dismisses, mark renders as colored span + +Tap existing mark span → "Remove mark?" option +``` + +### Widget Changes + +**`_LineTile` (in `annotation_editor_screen.dart`)**: + +- When `isSelected == false`: render as current `MarkOverlay` (read-only `RichText`) +- When `isSelected == true`: render as `SelectableText.rich` with: + - Same colored spans from marks + - `onSelectionChanged` callback that captures `TextSelection` + - `contextMenuBuilder` or `CompositedTransformFollower` for the toolbar + +**`MarkSelectionToolbar` (new widget)**: + +- Row of 6 `ActionChip` widgets, one per `MarkType`, colored with `markColors` +- Receives `onMarkSelected(MarkType)` callback +- Also includes "Cancel" button +- **Positioning**: Use `CompositedTransformTarget` on the `SelectableText` with a `LayerLink`. When selection changes, compute selection bounds via `RenderParagraph.getBoxesForSelection(selection)` to get the vertical offset, then show a `CompositedTransformFollower` with `OverlayEntry` anchored above the selection boxes. If the selection is near the top of the screen, position below instead. + +**`AnnotationCubit` changes**: + +- `addMark` already accepts `startOffset` / `endOffset` — no cubit changes needed +- `removeMark(markId)` already exists + +### Selection-to-Offset Mapping + +`SelectableText.rich` provides `TextSelection` with `baseOffset` and `extentOffset`. These map directly to character offsets in the line text, which match `TextMark.startOffset` / `endOffset`. + +Edge case: if the user selects across an existing mark boundary, the new mark overlaps. This is fine — `MarkOverlay` already handles overlapping marks via boundary events. + +### Files + +| File | Action | +| ------------------------------------------------------------- | ----------------------------------- | +| `horatio_app/lib/widgets/mark_selection_toolbar.dart` | NEW | +| `horatio_app/lib/screens/annotation_editor_screen.dart` | MODIFY — `_LineTile` selected state | +| `horatio_app/test/widgets/mark_selection_toolbar_test.dart` | NEW | +| `horatio_app/test/screens/annotation_editor_screen_test.dart` | MODIFY — word selection tests | + +--- + +## Section 3: Voice Recording Per Line + +### New Model (horatio_core) + +```dart +final class LineRecording { + const LineRecording({ + required this.id, + required this.scriptId, + required this.lineIndex, + required this.filePath, + required this.durationMs, + required this.createdAt, + this.grade, + }); + + final String id; + final String scriptId; + final int lineIndex; + final String filePath; + final int durationMs; + final DateTime createdAt; + final int? grade; // 0-5, matches SM-2 quality scale +} +``` + +### Drift Table + +```dart +class LineRecordingsTable extends Table { + TextColumn get id => text()(); + TextColumn get scriptId => text()(); + IntColumn get lineIndex => integer()(); + TextColumn get filePath => text()(); + IntColumn get durationMs => integer()(); + DateTimeColumn get createdAt => dateTime()(); + IntColumn get grade => integer().nullable()(); + + @override + Set get primaryKey => {id}; +} +``` + +Added to `AppDatabase` tables list. + +### Database Migration + +Bump `schemaVersion` from 1 to 2. Add `MigrationStrategy` with: + +```dart +@override +MigrationStrategy get migration => MigrationStrategy( + onCreate: (m) => m.createAll(), + onUpgrade: (m, from, to) async { + if (from < 2) { + await m.createTable(lineRecordingsTable); + } + }, +); +``` + +### RecordingDao (new, separate from AnnotationDao) + +A new `@DriftAccessor(tables: [LineRecordingsTable])` class with: + +- `insertRecording(...)` / `deleteRecording(id)` / `updateRecordingGrade(id, grade)` +- `watchRecordingsForScript(scriptId)` → `Stream>` + +Keeping it separate from `AnnotationDao` maintains single-responsibility. Injected via its own `RepositoryProvider` in `app.dart`. + +### Services + +**`RecordingService`** (wraps `record` package): + +- `Future startRecording(String filePath)` — starts microphone capture to `.m4a` +- `Future stopRecording()` — stops, returns file path +- `Stream get isRecording` +- `Stream get amplitude` — periodic updates (~100ms) from the `record` package's amplitude stream, used by `RecordingCubit` to update `RecordingInProgress.elapsed` via a `Timer.periodic(Duration(milliseconds: 100))` that increments the elapsed counter +- `Future hasPermission()` / `Future requestPermission()` +- File naming: `recordings/{scriptId}/line_{lineIndex}_{timestamp}.m4a` +- Storage dir: `path_provider` `getApplicationDocumentsDirectory()` +- **Directory creation**: `startRecording` ensures the parent directory exists (`Directory.create(recursive: true)`) before starting capture +- **Linux note**: `hasPermission()` / `requestPermission()` are no-ops on desktop Linux (PulseAudio/PipeWire handles access). Tests mock both paths regardless. + +**`AudioPlaybackService`** (new, wraps `audioplayers` package): + +- `Future play(String filePath)` +- `Future stop()` +- `Stream get position` +- `Stream get status` — enum: `idle`, `playing`, `completed`. `RecordingCubit` listens to this stream to transition from `RecordingPlayback` to `RecordingGrading` when status becomes `completed`. +- `Future getDuration(String filePath)` + +Both services are injected via `RepositoryProvider` in `app.dart`. + +### RecordingCubit + +``` +States: + RecordingInitial + RecordingIdle(recordings: List) + RecordingInProgress(lineIndex: int, elapsed: Duration) + RecordingPlayback(recording: LineRecording, position: Duration) + RecordingGrading(recording: LineRecording) + RecordingError(message: String) + +Events/methods: + loadRecordings(scriptId) + startRecording(scriptId, lineIndex) + stopRecording() + playRecording(recordingId) + stopPlayback() + gradeRecording(recordingId, int grade) // 0-5 + deleteRecording(recordingId) +``` + +`RecordingCubit` uses a `Timer.periodic(Duration(milliseconds: 100))` during recording to emit updated `RecordingInProgress` states with incrementing `elapsed`. The timer is cancelled on `stopRecording()` or `close()`. + +For playback, the cubit subscribes to `AudioPlaybackService.status`. When status becomes `PlaybackStatus.completed`, it transitions to `RecordingGrading`. The `StreamSubscription` is cancelled on `stopPlayback()` and `close()` (same pattern as `AnnotationCubit`'s stream subscriptions). + +Error handling: `playRecording` catches `FileNotFoundException`, calls `deleteRecording` on the DAO, and emits `RecordingError('Recording file not found')`. The UI shows a SnackBar via `BlocListener`. + +### UI in Annotation Editor + +When a line is selected, a **bottom action bar** appears below the line list: + +``` +┌─────────────────────────────────────────────┐ +│ 🎤 Record │ ▶ Play (last) │ ⭐ Grade │ +│ [hold or toggle] │ [tap] │ [0-5 stars]│ +└─────────────────────────────────────────────┘ +``` + +- **Mic button**: Tap to start, tap again to stop. Shows recording duration while active. +- **Play button**: Plays the most recent recording for the selected line. Disabled if no recordings. +- **Grade section**: After playback finishes, shows a `GradeStars` widget. Displays 5 tappable star icons (1-5) plus a dedicated "0" button labeled "Blackout" for grade 0 (complete failure in SM-2). The `null` grade (not-yet-graded) is visually distinct: all stars are outlined/empty with no "0" highlight. Grade saves to DB immediately on tap. +- **Recording badge**: Next to `NoteIndicator`, a small mic icon with count shows recordings per line. + +**Recording list**: Tap the recording badge to see all recordings for that line in a bottom sheet. Each item shows: duration, date, grade stars. Swipe to delete. + +### Dependencies + +| Package | Version | Purpose | +| -------------------- | ------------------ | ---------------------- | +| `record` | already in pubspec | Microphone recording | +| `audioplayers` | ^6.1.0 | Audio playback | +| `shared_preferences` | ^2.3.0 | Font scale persistence | + +### Files + +| File | Action | +| ------------------------------------------------------------ | ------------------------------------------------- | +| `horatio_core/lib/src/models/line_recording.dart` | NEW | +| `horatio_core/lib/src/models/models.dart` | MODIFY — barrel export | +| `horatio_app/lib/database/tables/line_recordings_table.dart` | NEW | +| `horatio_app/lib/database/app_database.dart` | MODIFY — add table, bump schema, add migration | +| `horatio_app/lib/database/daos/recording_dao.dart` | NEW — recording CRUD | +| `horatio_app/lib/services/recording_service.dart` | NEW | +| `horatio_app/lib/services/audio_playback_service.dart` | NEW | +| `horatio_app/lib/bloc/recording/recording_cubit.dart` | NEW | +| `horatio_app/lib/bloc/recording/recording_state.dart` | NEW | +| `horatio_app/lib/widgets/recording_badge.dart` | NEW | +| `horatio_app/lib/widgets/recording_action_bar.dart` | NEW | +| `horatio_app/lib/widgets/recording_list_sheet.dart` | NEW | +| `horatio_app/lib/widgets/grade_stars.dart` | NEW | +| `horatio_app/lib/screens/annotation_editor_screen.dart` | MODIFY — integrate recording UI | +| `horatio_app/lib/app.dart` | MODIFY — provide RecordingDao + services | +| `horatio_app/pubspec.yaml` | MODIFY — add `audioplayers`, `shared_preferences` | +| `horatio_app/test/database/recording_dao_test.dart` | NEW | +| `horatio_app/test/services/recording_service_test.dart` | NEW | +| `horatio_app/test/services/audio_playback_service_test.dart` | NEW | +| `horatio_app/test/bloc/recording_cubit_test.dart` | NEW | +| `horatio_app/test/widgets/recording_badge_test.dart` | NEW | +| `horatio_app/test/widgets/recording_action_bar_test.dart` | NEW | +| `horatio_app/test/widgets/recording_list_sheet_test.dart` | NEW | +| `horatio_app/test/widgets/grade_stars_test.dart` | NEW | + +--- + +## Section 4: Note UX Improvements + +### Inline Note Expansion + +When a line is selected and has notes: + +- Notes render as expandable `Chip` widgets below the line text (inside `_LineTile`) +- Each chip shows: category icon + truncated text (max 30 chars) +- Tap chip → `NoteEditorSheet` pre-filled with existing text + category (for editing) +- Long-press or swipe chip → delete confirmation + +### Note Editing + +`NoteEditorSheet` already supports `initialText` and `initialCategory`. The edit flow: + +1. Tap existing note chip +2. Sheet opens with pre-filled values +3. Save calls `cubit.updateNote(noteId, newCategory, newText)` instead of `addNote` + +**Signature changes required**: + +- `AnnotationDao`: add `updateNoteCategory(String id, NoteCategory category)` method +- `AnnotationCubit`: change `updateNote` to accept `(String id, {String? text, NoteCategory? category})` and call the appropriate DAO methods +- `NoteEditorSheet`: add optional `noteId` parameter. When `noteId` is non-null, `onSave` includes it in the callback so the caller can distinguish create vs update. Callback type becomes `void Function(NoteCategory, String, {String? noteId})`. + +### Files + +| File | Action | +| ------------------------------------------------------------- | -------------------------------------- | +| `horatio_app/lib/screens/annotation_editor_screen.dart` | MODIFY — note chips in `_LineTile` | +| `horatio_app/lib/widgets/note_chip.dart` | NEW — tappable note chip widget | +| `horatio_app/lib/widgets/note_editor_sheet.dart` | MODIFY — add `noteId` parameter | +| `horatio_app/lib/bloc/annotation/annotation_cubit.dart` | MODIFY — `updateNote` accepts category | +| `horatio_app/lib/database/daos/annotation_dao.dart` | MODIFY — add `updateNoteCategory` | +| `horatio_app/test/widgets/note_chip_test.dart` | NEW | +| `horatio_app/test/screens/annotation_editor_screen_test.dart` | MODIFY — note editing tests | + +--- + +## Section 5: Error Handling + +| Scenario | Handling | Actor | +| ------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Microphone permission denied | SnackBar: "Microphone permission required for recording" | `RecordingCubit` emits `RecordingError`, UI shows via `BlocListener` | +| Recording fails (no mic, disk full) | SnackBar with error message, state returns to `RecordingIdle` | `RecordingCubit` catches, emits `RecordingError` then `RecordingIdle` | +| Audio file not found on playback | SnackBar: "Recording file not found", remove from DB | `RecordingCubit.playRecording` catches `FileNotFoundException`, calls `dao.deleteRecording(id)`, emits `RecordingError` | +| SharedPreferences unavailable | Fall back to default scale 1.0, no persistence | `TextScaleCubit.loadScale` catches, uses default | +| Text selection empty (0-length) | Don't show toolbar, ignore | `_LineTile.onSelectionChanged` checks `selection.isCollapsed` | +| Already recording when start pressed | Ignore (button disabled while `RecordingInProgress`) | UI disables mic button via state check | + +--- + +## Section 6: Testing Strategy + +### General + +- **100% branch coverage** maintained, `.g.dart` and table files filtered in `run.sh` +- `SharedPreferences.setMockInitialValues({})` required in `setUp` for all `TextScaleCubit` tests +- All `RecordingService` and `AudioPlaybackService` interactions mocked — no real mic or audio + +### Branch Coverage Matrix + +**TextScaleCubit**: + +- `loadScale`: (a) no saved value → default 1.0, (b) saved value → load it +- `autoDetect`: (a) 4K desktop → 1.5, (b) non-4K → 1.0, (c) mobile platform → 1.0, (d) already has saved pref → skip +- `setScale`: persist + emit, slider interaction widget test + +**RecordingCubit**: + +- `startRecording`: (a) success → `RecordingInProgress`, (b) permission denied → `RecordingError`, (c) already recording → ignored +- `stopRecording`: success → `RecordingIdle` with new recording in list +- `playRecording`: (a) success → `RecordingPlayback`, (b) file not found → `RecordingError` + DB delete +- `stopPlayback`: → `RecordingIdle` +- Playback completion: `PlaybackStatus.completed` → `RecordingGrading` +- `gradeRecording`: (a) grade 0 → save, (b) grade 5 → save, (c) null (not yet graded) +- `deleteRecording`: removes from list + DB +- `Timer.periodic` cancel on `close()` + +**MarkSelectionToolbar**: Chip tap callbacks, cancel button, positioning above/below + +**Word selection**: Widget test with simulated `TextSelection` on `SelectableText.rich`, verify `addMark` called with correct start/end offsets. Test collapsed selection → no toolbar. + +**RecordingDao**: CRUD integration tests (insert, delete, update grade, watch stream) + +**Note chips**: Tap → edit (pre-filled sheet), long-press → delete confirmation, rendering with truncation, category icon display + +**NoteEditorSheet**: Create mode (no noteId) vs edit mode (with noteId), category change vs text-only change + +**GradeStars**: Tap star 1-5, tap "Blackout" (grade 0), display for null grade vs graded + +--- + +## Data Flow Diagram + +``` +User taps line + → AnnotationCubit.selectLine(index) + → _LineTile re-renders as SelectableText.rich + +User drags text selection + → onSelectionChanged(TextSelection) + → MarkSelectionToolbar appears with 6 chips + +User taps chip + → AnnotationCubit.addMark(line, start, end, type) + → Drift insert → stream update → UI re-renders with colored span + +User taps mic + → RecordingCubit.startRecording(scriptId, lineIndex) + → RecordingService.startRecording(filePath) + → UI shows elapsed timer + +User taps mic again + → RecordingCubit.stopRecording() + → RecordingService.stopRecording() → file on disk + → Drift insert → stream update → recording badge count updates + +User taps play + → RecordingCubit.playRecording(id) + → AudioPlaybackService.play(filePath) + → Position stream updates progress bar + +Playback finishes + → RecordingGrading state + → GradeStars widget visible + → User taps star → RecordingCubit.gradeRecording(id, grade) + → Drift update + +User adjusts font slider + → TextScaleCubit.setScale(value) + → SharedPreferences persist + → MediaQuery textScaler override → entire app re-renders +``` diff --git a/horatio/horatio_app/.metadata b/horatio/horatio_app/.metadata index 02c33e9..9114285 100644 --- a/horatio/horatio_app/.metadata +++ b/horatio/horatio_app/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a - - platform: linux + - platform: web create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a diff --git a/horatio/horatio_app/lib/app.dart b/horatio/horatio_app/lib/app.dart index 4319cf5..e485afc 100644 --- a/horatio/horatio_app/lib/app.dart +++ b/horatio/horatio_app/lib/app.dart @@ -1,51 +1,117 @@ import 'package:device_preview/device_preview.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:horatio_app/bloc/script_import/script_import_cubit.dart'; import 'package:horatio_app/bloc/srs_review/srs_review_cubit.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_state.dart'; import 'package:horatio_app/database/app_database.dart'; import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; import 'package:horatio_app/router.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; import 'package:horatio_app/services/script_repository.dart'; import 'package:horatio_app/theme/app_theme.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// Root widget for the Horatio app. class HoratioApp extends StatelessWidget { /// Creates the [HoratioApp]. - const HoratioApp({required this.database, super.key}); + const HoratioApp({ + required this.database, + required this.recordingsDir, + required this.prefs, + super.key, + }); /// The drift database instance. final AppDatabase database; + /// SharedPreferences for text scale persistence. + final SharedPreferences prefs; + + /// Directory where line recordings are stored. + final String recordingsDir; + @override Widget build(BuildContext context) => MultiRepositoryProvider( - providers: [ - RepositoryProvider( - create: (_) => ScriptRepository(), - ), - RepositoryProvider( - create: (_) => database.annotationDao, - ), - ], - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => ScriptImportCubit( - repository: context.read(), - ), - ), - BlocProvider( - create: (_) => SrsReviewCubit(), - ), - ], - child: MaterialApp.router( - title: 'Horatio', - theme: AppTheme.light, - darkTheme: AppTheme.dark, - locale: DevicePreview.locale(context), - builder: DevicePreview.appBuilder, - routerConfig: appRouter, - ), + providers: [ + RepositoryProvider(create: (_) => ScriptRepository()), + RepositoryProvider(create: (_) => database.annotationDao), + RepositoryProvider(create: (_) => database.recordingDao), + RepositoryProvider( + create: (_) => RecordingService(), + dispose: (service) => service.dispose(), + ), + RepositoryProvider( + create: (_) => AudioPlaybackService(), + dispose: (service) => service.dispose(), + ), + RepositoryProvider.value(value: recordingsDir), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => + ScriptImportCubit(repository: context.read()), ), - ); + BlocProvider(create: (_) => SrsReviewCubit()), + BlocProvider( + create: (_) => TextScaleCubit(prefs: prefs)..loadScale(), + ), + ], + child: const _AutoDetectWrapper(), + ), + ); +} + +/// Runs auto-detect once in didChangeDependencies, then wraps child +/// with a [MediaQuery] override for the user's text scale. +class _AutoDetectWrapper extends StatefulWidget { + const _AutoDetectWrapper(); + + @override + State<_AutoDetectWrapper> createState() => _AutoDetectWrapperState(); +} + +class _AutoDetectWrapperState extends State<_AutoDetectWrapper> { + bool _hasAutoDetected = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_hasAutoDetected) { + _hasAutoDetected = true; + final mq = MediaQuery.of(context); + final isDesktop = + defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows; + context.read().autoDetect( + mq.size, + mq.devicePixelRatio, + isDesktop: isDesktop, + ); + } + } + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context); + return BlocBuilder( + builder: (context, state) => MediaQuery( + data: mq.copyWith(textScaler: TextScaler.linear(state.scaleFactor)), + child: MaterialApp.router( + title: 'Horatio', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + locale: DevicePreview.locale(context), + builder: DevicePreview.appBuilder, + routerConfig: appRouter, + ), + ), + ); + } } diff --git a/horatio/horatio_app/lib/bloc/annotation/annotation_cubit.dart b/horatio/horatio_app/lib/bloc/annotation/annotation_cubit.dart index 583ea83..8a2ea90 100644 --- a/horatio/horatio_app/lib/bloc/annotation/annotation_cubit.dart +++ b/horatio/horatio_app/lib/bloc/annotation/annotation_cubit.dart @@ -10,8 +10,8 @@ import 'package:uuid/uuid.dart'; class AnnotationCubit extends Cubit { /// Creates an [AnnotationCubit]. AnnotationCubit({required AnnotationDao dao}) - : _dao = dao, - super(const AnnotationInitial()); + : _dao = dao, + super(const AnnotationInitial()); final AnnotationDao _dao; StreamSubscription>? _marksSub; @@ -46,14 +46,17 @@ class AnnotationCubit extends Cubit { List notes, ) { final current = state; - emit(AnnotationLoaded( - scriptId: scriptId, - marks: marks, - notes: notes, - selectedLineIndex: - current is AnnotationLoaded ? current.selectedLineIndex : null, - editing: current is AnnotationLoaded ? current.editing : null, - )); + 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. @@ -68,13 +71,13 @@ class AnnotationCubit extends Cubit { 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, + emit( + current.copyWith( + selectedLineIndex: () => lineIndex, + editing: () => + EditingContext(lineIndex: lineIndex, isAddingMark: isAddingMark), ), - )); + ); } } @@ -127,9 +130,19 @@ class AnnotationCubit extends Cubit { await _dao.insertNote(scriptId, note); } - /// Updates a note's text. - Future updateNote(String id, String text) => - _dao.updateNoteText(id, text); + /// Updates a note's text and/or category. + Future updateNote( + String id, { + String? text, + NoteCategory? category, + }) async { + if (text != null) { + await _dao.updateNoteText(id, text); + } + if (category != null) { + await _dao.updateNoteCategory(id, category); + } + } /// Removes a note. Future removeNote(String id) => _dao.deleteNote(id); diff --git a/horatio/horatio_app/lib/bloc/recording/recording_cubit.dart b/horatio/horatio_app/lib/bloc/recording/recording_cubit.dart new file mode 100644 index 0000000..25d4137 --- /dev/null +++ b/horatio/horatio_app/lib/bloc/recording/recording_cubit.dart @@ -0,0 +1,233 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/recording/recording_state.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:uuid/uuid.dart'; + +/// Manages the record -> play -> grade lifecycle for voice recordings. +class RecordingCubit extends Cubit { + /// Creates a [RecordingCubit]. + /// + /// [recordingsDir] is the base directory for storing recordings + /// (from path_provider's getApplicationDocumentsDirectory). + RecordingCubit({ + required RecordingDao dao, + required RecordingService recordingService, + required AudioPlaybackService playbackService, + required String recordingsDir, + bool disposeServicesOnClose = true, + }) : _dao = dao, + _recordingService = recordingService, + _playbackService = playbackService, + _recordingsDir = recordingsDir, + _disposeServicesOnClose = disposeServicesOnClose, + super(const RecordingInitial()); + + final RecordingDao _dao; + final RecordingService _recordingService; + final AudioPlaybackService _playbackService; + final String _recordingsDir; + final bool _disposeServicesOnClose; + + static const _uuid = Uuid(); + + StreamSubscription>? _recordingsSub; + StreamSubscription? _statusSub; + StreamSubscription? _positionSub; + Timer? _elapsedTimer; + + String? _scriptId; + int? _recordingLineIndex; + DateTime? _recordingStartedAt; + List _latestRecordings = []; + + /// Subscribes to recording streams for a script. + void loadRecordings(String scriptId) { + _scriptId = scriptId; + _recordingsSub?.cancel(); + _recordingsSub = _dao.watchRecordingsForScript(scriptId).listen(( + recordings, + ) { + _latestRecordings = recordings; + final current = state; + if (current is RecordingInProgress) { + emit( + RecordingInProgress( + recordings: recordings, + lineIndex: current.lineIndex, + elapsed: current.elapsed, + ), + ); + } else if (current is RecordingPlayback) { + emit( + RecordingPlayback( + recordings: recordings, + recording: current.recording, + position: current.position, + ), + ); + } else if (current is RecordingGrading) { + emit( + RecordingGrading( + recordings: recordings, + recording: current.recording, + ), + ); + } else if (current is RecordingError) { + emit(RecordingError(recordings: recordings, message: current.message)); + } else { + emit(RecordingIdle(recordings: recordings)); + } + }); + } + + /// Starts recording for a line. + Future startRecording(String scriptId, int lineIndex) async { + if (state is RecordingInProgress) return; + if (state is RecordingInitial) return; + + final hasPermission = await _recordingService.hasPermission(); + if (!hasPermission) { + emit( + RecordingError( + recordings: _latestRecordings, + message: 'Microphone permission required for recording', + ), + ); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final filePath = + '$_recordingsDir/$scriptId/line_${lineIndex}_$timestamp.m4a'; + + await _recordingService.startRecording(filePath); + _recordingLineIndex = lineIndex; + _recordingStartedAt = DateTime.now(); + + var elapsed = Duration.zero; + emit( + RecordingInProgress( + recordings: _latestRecordings, + lineIndex: lineIndex, + elapsed: elapsed, + ), + ); + + _elapsedTimer?.cancel(); + _elapsedTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { + elapsed += const Duration(milliseconds: 100); + if (state is RecordingInProgress) { + emit( + RecordingInProgress( + recordings: _latestRecordings, + lineIndex: lineIndex, + elapsed: elapsed, + ), + ); + } + }); + } + + /// Stops recording and saves to database. + Future stopRecording() async { + if (state is! RecordingInProgress) return; + _elapsedTimer?.cancel(); + _elapsedTimer = null; + + final path = await _recordingService.stopRecording(); + if (path == null) { + emit(RecordingIdle(recordings: _latestRecordings)); + return; + } + + final scriptId = _scriptId!; + final lineIndex = _recordingLineIndex!; + + final elapsed = _recordingStartedAt != null + ? DateTime.now().difference(_recordingStartedAt!) + : Duration.zero; + + final recording = LineRecording( + id: _uuid.v4(), + scriptId: scriptId, + lineIndex: lineIndex, + filePath: path, + durationMs: elapsed.inMilliseconds, + createdAt: DateTime.now().toUtc(), + ); + + await _dao.insertRecording(scriptId, recording); + emit(RecordingIdle(recordings: _latestRecordings)); + } + + /// Plays a recording. + Future playRecording(LineRecording recording) async { + await _playbackService.play(recording.filePath); + emit( + RecordingPlayback( + recordings: _latestRecordings, + recording: recording, + position: Duration.zero, + ), + ); + + await _positionSub?.cancel(); + _positionSub = _playbackService.position.listen((position) { + if (state is RecordingPlayback) { + emit( + RecordingPlayback( + recordings: _latestRecordings, + recording: recording, + position: position, + ), + ); + } + }); + + await _statusSub?.cancel(); + _statusSub = _playbackService.status.listen((status) { + if (status == PlaybackStatus.completed && state is RecordingPlayback) { + unawaited(_positionSub?.cancel()); + unawaited(_statusSub?.cancel()); + emit( + RecordingGrading(recordings: _latestRecordings, recording: recording), + ); + } + }); + } + + /// Stops playback. + Future stopPlayback() async { + await _playbackService.stop(); + await _positionSub?.cancel(); + await _statusSub?.cancel(); + emit(RecordingIdle(recordings: _latestRecordings)); + } + + /// Grades a recording (0-5). + Future gradeRecording(String id, int grade) async { + await _dao.updateRecordingGrade(id, grade); + emit(RecordingIdle(recordings: _latestRecordings)); + } + + /// Deletes a recording. + Future deleteRecording(String id) => _dao.deleteRecording(id); + + @override + Future close() async { + await _recordingsSub?.cancel(); + await _statusSub?.cancel(); + await _positionSub?.cancel(); + _elapsedTimer?.cancel(); + if (_disposeServicesOnClose) { + await _recordingService.dispose(); + await _playbackService.dispose(); + } + return super.close(); + } +} diff --git a/horatio/horatio_app/lib/bloc/recording/recording_state.dart b/horatio/horatio_app/lib/bloc/recording/recording_state.dart new file mode 100644 index 0000000..d32ec2a --- /dev/null +++ b/horatio/horatio_app/lib/bloc/recording/recording_state.dart @@ -0,0 +1,116 @@ +import 'package:equatable/equatable.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// States for [RecordingCubit]. +sealed class RecordingState extends Equatable { + const RecordingState(); + + /// All recordings for the current script. + /// + /// Empty in [RecordingInitial], populated after + /// [RecordingCubit.loadRecordings]. + List get recordings; +} + +/// No recordings loaded. +final class RecordingInitial extends RecordingState { + const RecordingInitial(); + + @override + List get recordings => const []; + + @override + List get props => []; +} + +/// Idle — recordings loaded, nothing in progress. +final class RecordingIdle extends RecordingState { + const RecordingIdle({required this.recordings}); + + /// All recordings for the current script. + @override + final List recordings; + + @override + List get props => [recordings]; +} + +/// Recording in progress. +final class RecordingInProgress extends RecordingState { + const RecordingInProgress({ + required this.recordings, + required this.lineIndex, + required this.elapsed, + }); + + /// All recordings for the current script. + @override + final List recordings; + + /// The line being recorded. + final int lineIndex; + + /// Elapsed recording time. + final Duration elapsed; + + @override + List get props => [recordings, lineIndex, elapsed]; +} + +/// Playing back a recording. +final class RecordingPlayback extends RecordingState { + const RecordingPlayback({ + required this.recordings, + required this.recording, + required this.position, + }); + + /// All recordings for the current script. + @override + final List recordings; + + /// The recording being played. + final LineRecording recording; + + /// Current playback position. + final Duration position; + + @override + List get props => [recordings, recording, position]; +} + +/// Grading a recording after playback. +final class RecordingGrading extends RecordingState { + const RecordingGrading({ + required this.recordings, + required this.recording, + }); + + /// All recordings for the current script. + @override + final List recordings; + + /// The recording to grade. + final LineRecording recording; + + @override + List get props => [recordings, recording]; +} + +/// Error state. +final class RecordingError extends RecordingState { + const RecordingError({ + required this.recordings, + required this.message, + }); + + /// All recordings for the current script. + @override + final List recordings; + + /// Error message. + final String message; + + @override + List get props => [recordings, message]; +} diff --git a/horatio/horatio_app/lib/bloc/text_scale/text_scale_cubit.dart b/horatio/horatio_app/lib/bloc/text_scale/text_scale_cubit.dart new file mode 100644 index 0000000..0b05482 --- /dev/null +++ b/horatio/horatio_app/lib/bloc/text_scale/text_scale_cubit.dart @@ -0,0 +1,47 @@ +import 'dart:ui'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Manages text scale factor with SharedPreferences persistence. +class TextScaleCubit extends Cubit { + /// Creates a [TextScaleCubit]. + TextScaleCubit({required SharedPreferences prefs}) + : _prefs = prefs, + super(const TextScaleState(scaleFactor: 1)); + + final SharedPreferences _prefs; + + static const _key = 'text_scale_factor'; + + bool get _hasSavedPreference => _prefs.containsKey(_key); + + /// Loads the saved scale factor from SharedPreferences. + void loadScale() { + final saved = _prefs.getDouble(_key); + if (saved != null) { + emit(TextScaleState(scaleFactor: saved)); + } + } + + /// Sets the scale factor, persisting to SharedPreferences. + Future setScale(double value) async { + await _prefs.setDouble(_key, value); + emit(TextScaleState(scaleFactor: value)); + } + + /// Auto-detects scale for 4K displays. Only runs when no preference saved. + void autoDetect(Size logicalSize, double dpr, {required bool isDesktop}) { + if (_hasSavedPreference) return; + final physicalWidth = logicalSize.width * dpr; + final scale = (physicalWidth >= 3200 && isDesktop) ? 1.5 : 1; + emit(TextScaleState(scaleFactor: scale.toDouble())); + } + + /// Clears the saved preference and resets to default 1.0. + Future resetToAuto() async { + await _prefs.remove(_key); + emit(const TextScaleState(scaleFactor: 1)); + } +} diff --git a/horatio/horatio_app/lib/bloc/text_scale/text_scale_state.dart b/horatio/horatio_app/lib/bloc/text_scale/text_scale_state.dart new file mode 100644 index 0000000..237a20b --- /dev/null +++ b/horatio/horatio_app/lib/bloc/text_scale/text_scale_state.dart @@ -0,0 +1,13 @@ +import 'package:equatable/equatable.dart'; + +/// State for [TextScaleCubit]. +final class TextScaleState extends Equatable { + /// Creates a [TextScaleState]. + const TextScaleState({required this.scaleFactor}); + + /// The text scale multiplier (0.5 – 3.0). + final double scaleFactor; + + @override + List get props => [scaleFactor]; +} diff --git a/horatio/horatio_app/lib/database/app_database.dart b/horatio/horatio_app/lib/database/app_database.dart index 3023332..554e46a 100644 --- a/horatio/horatio_app/lib/database/app_database.dart +++ b/horatio/horatio_app/lib/database/app_database.dart @@ -1,23 +1,39 @@ import 'package:drift/drift.dart'; import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_app/database/daos/recording_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/line_recordings_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). +/// Schema version 2: adds line_recordings table for voice recordings. @DriftDatabase( - tables: [TextMarksTable, LineNotesTable, AnnotationSnapshotsTable], - daos: [AnnotationDao], + tables: [ + TextMarksTable, + LineNotesTable, + AnnotationSnapshotsTable, + LineRecordingsTable, + ], + daos: [AnnotationDao, RecordingDao], ) class AppDatabase extends _$AppDatabase { /// Creates an [AppDatabase] with the given [QueryExecutor]. AppDatabase(super.e); @override - int get schemaVersion => 1; + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (m) => m.createAll(), + onUpgrade: (m, from, to) async { + if (from < 2) { + await m.createTable(lineRecordingsTable); + } + }, + ); } diff --git a/horatio/horatio_app/lib/database/app_database.g.dart b/horatio/horatio_app/lib/database/app_database.g.dart index 7427087..f1d89d7 100644 --- a/horatio/horatio_app/lib/database/app_database.g.dart +++ b/horatio/horatio_app/lib/database/app_database.g.dart @@ -1210,6 +1210,476 @@ class AnnotationSnapshotsTableCompanion } } +class $LineRecordingsTableTable extends LineRecordingsTable + with TableInfo<$LineRecordingsTableTable, LineRecordingsTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $LineRecordingsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _scriptIdMeta = const VerificationMeta( + 'scriptId', + ); + @override + late final GeneratedColumn scriptId = GeneratedColumn( + 'script_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lineIndexMeta = const VerificationMeta( + 'lineIndex', + ); + @override + late final GeneratedColumn lineIndex = GeneratedColumn( + 'line_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _filePathMeta = const VerificationMeta( + 'filePath', + ); + @override + late final GeneratedColumn filePath = GeneratedColumn( + 'file_path', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _durationMsMeta = const VerificationMeta( + 'durationMs', + ); + @override + late final GeneratedColumn durationMs = GeneratedColumn( + 'duration_ms', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _gradeMeta = const VerificationMeta('grade'); + @override + late final GeneratedColumn grade = GeneratedColumn( + 'grade', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + scriptId, + lineIndex, + filePath, + durationMs, + createdAt, + grade, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'line_recordings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('script_id')) { + context.handle( + _scriptIdMeta, + scriptId.isAcceptableOrUnknown(data['script_id']!, _scriptIdMeta), + ); + } else if (isInserting) { + context.missing(_scriptIdMeta); + } + if (data.containsKey('line_index')) { + context.handle( + _lineIndexMeta, + lineIndex.isAcceptableOrUnknown(data['line_index']!, _lineIndexMeta), + ); + } else if (isInserting) { + context.missing(_lineIndexMeta); + } + if (data.containsKey('file_path')) { + context.handle( + _filePathMeta, + filePath.isAcceptableOrUnknown(data['file_path']!, _filePathMeta), + ); + } else if (isInserting) { + context.missing(_filePathMeta); + } + if (data.containsKey('duration_ms')) { + context.handle( + _durationMsMeta, + durationMs.isAcceptableOrUnknown(data['duration_ms']!, _durationMsMeta), + ); + } else if (isInserting) { + context.missing(_durationMsMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('grade')) { + context.handle( + _gradeMeta, + grade.isAcceptableOrUnknown(data['grade']!, _gradeMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + LineRecordingsTableData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LineRecordingsTableData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + scriptId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}script_id'], + )!, + lineIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}line_index'], + )!, + filePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}file_path'], + )!, + durationMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_ms'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + grade: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}grade'], + ), + ); + } + + @override + $LineRecordingsTableTable createAlias(String alias) { + return $LineRecordingsTableTable(attachedDatabase, alias); + } +} + +class LineRecordingsTableData extends DataClass + implements Insertable { + final String id; + final String scriptId; + final int lineIndex; + final String filePath; + final int durationMs; + final DateTime createdAt; + final int? grade; + const LineRecordingsTableData({ + required this.id, + required this.scriptId, + required this.lineIndex, + required this.filePath, + required this.durationMs, + required this.createdAt, + this.grade, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['script_id'] = Variable(scriptId); + map['line_index'] = Variable(lineIndex); + map['file_path'] = Variable(filePath); + map['duration_ms'] = Variable(durationMs); + map['created_at'] = Variable(createdAt); + if (!nullToAbsent || grade != null) { + map['grade'] = Variable(grade); + } + return map; + } + + LineRecordingsTableCompanion toCompanion(bool nullToAbsent) { + return LineRecordingsTableCompanion( + id: Value(id), + scriptId: Value(scriptId), + lineIndex: Value(lineIndex), + filePath: Value(filePath), + durationMs: Value(durationMs), + createdAt: Value(createdAt), + grade: grade == null && nullToAbsent + ? const Value.absent() + : Value(grade), + ); + } + + factory LineRecordingsTableData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LineRecordingsTableData( + id: serializer.fromJson(json['id']), + scriptId: serializer.fromJson(json['scriptId']), + lineIndex: serializer.fromJson(json['lineIndex']), + filePath: serializer.fromJson(json['filePath']), + durationMs: serializer.fromJson(json['durationMs']), + createdAt: serializer.fromJson(json['createdAt']), + grade: serializer.fromJson(json['grade']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'scriptId': serializer.toJson(scriptId), + 'lineIndex': serializer.toJson(lineIndex), + 'filePath': serializer.toJson(filePath), + 'durationMs': serializer.toJson(durationMs), + 'createdAt': serializer.toJson(createdAt), + 'grade': serializer.toJson(grade), + }; + } + + LineRecordingsTableData copyWith({ + String? id, + String? scriptId, + int? lineIndex, + String? filePath, + int? durationMs, + DateTime? createdAt, + Value grade = const Value.absent(), + }) => LineRecordingsTableData( + id: id ?? this.id, + scriptId: scriptId ?? this.scriptId, + lineIndex: lineIndex ?? this.lineIndex, + filePath: filePath ?? this.filePath, + durationMs: durationMs ?? this.durationMs, + createdAt: createdAt ?? this.createdAt, + grade: grade.present ? grade.value : this.grade, + ); + LineRecordingsTableData copyWithCompanion(LineRecordingsTableCompanion data) { + return LineRecordingsTableData( + id: data.id.present ? data.id.value : this.id, + scriptId: data.scriptId.present ? data.scriptId.value : this.scriptId, + lineIndex: data.lineIndex.present ? data.lineIndex.value : this.lineIndex, + filePath: data.filePath.present ? data.filePath.value : this.filePath, + durationMs: data.durationMs.present + ? data.durationMs.value + : this.durationMs, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + grade: data.grade.present ? data.grade.value : this.grade, + ); + } + + @override + String toString() { + return (StringBuffer('LineRecordingsTableData(') + ..write('id: $id, ') + ..write('scriptId: $scriptId, ') + ..write('lineIndex: $lineIndex, ') + ..write('filePath: $filePath, ') + ..write('durationMs: $durationMs, ') + ..write('createdAt: $createdAt, ') + ..write('grade: $grade') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + scriptId, + lineIndex, + filePath, + durationMs, + createdAt, + grade, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LineRecordingsTableData && + other.id == this.id && + other.scriptId == this.scriptId && + other.lineIndex == this.lineIndex && + other.filePath == this.filePath && + other.durationMs == this.durationMs && + other.createdAt == this.createdAt && + other.grade == this.grade); +} + +class LineRecordingsTableCompanion + extends UpdateCompanion { + final Value id; + final Value scriptId; + final Value lineIndex; + final Value filePath; + final Value durationMs; + final Value createdAt; + final Value grade; + final Value rowid; + const LineRecordingsTableCompanion({ + this.id = const Value.absent(), + this.scriptId = const Value.absent(), + this.lineIndex = const Value.absent(), + this.filePath = const Value.absent(), + this.durationMs = const Value.absent(), + this.createdAt = const Value.absent(), + this.grade = const Value.absent(), + this.rowid = const Value.absent(), + }); + LineRecordingsTableCompanion.insert({ + required String id, + required String scriptId, + required int lineIndex, + required String filePath, + required int durationMs, + required DateTime createdAt, + this.grade = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + scriptId = Value(scriptId), + lineIndex = Value(lineIndex), + filePath = Value(filePath), + durationMs = Value(durationMs), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? scriptId, + Expression? lineIndex, + Expression? filePath, + Expression? durationMs, + Expression? createdAt, + Expression? grade, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (scriptId != null) 'script_id': scriptId, + if (lineIndex != null) 'line_index': lineIndex, + if (filePath != null) 'file_path': filePath, + if (durationMs != null) 'duration_ms': durationMs, + if (createdAt != null) 'created_at': createdAt, + if (grade != null) 'grade': grade, + if (rowid != null) 'rowid': rowid, + }); + } + + LineRecordingsTableCompanion copyWith({ + Value? id, + Value? scriptId, + Value? lineIndex, + Value? filePath, + Value? durationMs, + Value? createdAt, + Value? grade, + Value? rowid, + }) { + return LineRecordingsTableCompanion( + id: id ?? this.id, + scriptId: scriptId ?? this.scriptId, + lineIndex: lineIndex ?? this.lineIndex, + filePath: filePath ?? this.filePath, + durationMs: durationMs ?? this.durationMs, + createdAt: createdAt ?? this.createdAt, + grade: grade ?? this.grade, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (scriptId.present) { + map['script_id'] = Variable(scriptId.value); + } + if (lineIndex.present) { + map['line_index'] = Variable(lineIndex.value); + } + if (filePath.present) { + map['file_path'] = Variable(filePath.value); + } + if (durationMs.present) { + map['duration_ms'] = Variable(durationMs.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (grade.present) { + map['grade'] = Variable(grade.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LineRecordingsTableCompanion(') + ..write('id: $id, ') + ..write('scriptId: $scriptId, ') + ..write('lineIndex: $lineIndex, ') + ..write('filePath: $filePath, ') + ..write('durationMs: $durationMs, ') + ..write('createdAt: $createdAt, ') + ..write('grade: $grade, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -1217,7 +1687,10 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $LineNotesTableTable lineNotesTable = $LineNotesTableTable(this); late final $AnnotationSnapshotsTableTable annotationSnapshotsTable = $AnnotationSnapshotsTableTable(this); + late final $LineRecordingsTableTable lineRecordingsTable = + $LineRecordingsTableTable(this); late final AnnotationDao annotationDao = AnnotationDao(this as AppDatabase); + late final RecordingDao recordingDao = RecordingDao(this as AppDatabase); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -1226,6 +1699,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { textMarksTable, lineNotesTable, annotationSnapshotsTable, + lineRecordingsTable, ]; } @@ -1902,6 +2376,262 @@ typedef $$AnnotationSnapshotsTableTableProcessedTableManager = AnnotationSnapshotsTableData, PrefetchHooks Function() >; +typedef $$LineRecordingsTableTableCreateCompanionBuilder = + LineRecordingsTableCompanion Function({ + required String id, + required String scriptId, + required int lineIndex, + required String filePath, + required int durationMs, + required DateTime createdAt, + Value grade, + Value rowid, + }); +typedef $$LineRecordingsTableTableUpdateCompanionBuilder = + LineRecordingsTableCompanion Function({ + Value id, + Value scriptId, + Value lineIndex, + Value filePath, + Value durationMs, + Value createdAt, + Value grade, + Value rowid, + }); + +class $$LineRecordingsTableTableFilterComposer + extends Composer<_$AppDatabase, $LineRecordingsTableTable> { + $$LineRecordingsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scriptId => $composableBuilder( + column: $table.scriptId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lineIndex => $composableBuilder( + column: $table.lineIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get filePath => $composableBuilder( + column: $table.filePath, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get durationMs => $composableBuilder( + column: $table.durationMs, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get grade => $composableBuilder( + column: $table.grade, + builder: (column) => ColumnFilters(column), + ); +} + +class $$LineRecordingsTableTableOrderingComposer + extends Composer<_$AppDatabase, $LineRecordingsTableTable> { + $$LineRecordingsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scriptId => $composableBuilder( + column: $table.scriptId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lineIndex => $composableBuilder( + column: $table.lineIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get filePath => $composableBuilder( + column: $table.filePath, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get durationMs => $composableBuilder( + column: $table.durationMs, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get grade => $composableBuilder( + column: $table.grade, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$LineRecordingsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $LineRecordingsTableTable> { + $$LineRecordingsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get scriptId => + $composableBuilder(column: $table.scriptId, builder: (column) => column); + + GeneratedColumn get lineIndex => + $composableBuilder(column: $table.lineIndex, builder: (column) => column); + + GeneratedColumn get filePath => + $composableBuilder(column: $table.filePath, builder: (column) => column); + + GeneratedColumn get durationMs => $composableBuilder( + column: $table.durationMs, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get grade => + $composableBuilder(column: $table.grade, builder: (column) => column); +} + +class $$LineRecordingsTableTableTableManager + extends + RootTableManager< + _$AppDatabase, + $LineRecordingsTableTable, + LineRecordingsTableData, + $$LineRecordingsTableTableFilterComposer, + $$LineRecordingsTableTableOrderingComposer, + $$LineRecordingsTableTableAnnotationComposer, + $$LineRecordingsTableTableCreateCompanionBuilder, + $$LineRecordingsTableTableUpdateCompanionBuilder, + ( + LineRecordingsTableData, + BaseReferences< + _$AppDatabase, + $LineRecordingsTableTable, + LineRecordingsTableData + >, + ), + LineRecordingsTableData, + PrefetchHooks Function() + > { + $$LineRecordingsTableTableTableManager( + _$AppDatabase db, + $LineRecordingsTableTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$LineRecordingsTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$LineRecordingsTableTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$LineRecordingsTableTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value scriptId = const Value.absent(), + Value lineIndex = const Value.absent(), + Value filePath = const Value.absent(), + Value durationMs = const Value.absent(), + Value createdAt = const Value.absent(), + Value grade = const Value.absent(), + Value rowid = const Value.absent(), + }) => LineRecordingsTableCompanion( + id: id, + scriptId: scriptId, + lineIndex: lineIndex, + filePath: filePath, + durationMs: durationMs, + createdAt: createdAt, + grade: grade, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String scriptId, + required int lineIndex, + required String filePath, + required int durationMs, + required DateTime createdAt, + Value grade = const Value.absent(), + Value rowid = const Value.absent(), + }) => LineRecordingsTableCompanion.insert( + id: id, + scriptId: scriptId, + lineIndex: lineIndex, + filePath: filePath, + durationMs: durationMs, + createdAt: createdAt, + grade: grade, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$LineRecordingsTableTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $LineRecordingsTableTable, + LineRecordingsTableData, + $$LineRecordingsTableTableFilterComposer, + $$LineRecordingsTableTableOrderingComposer, + $$LineRecordingsTableTableAnnotationComposer, + $$LineRecordingsTableTableCreateCompanionBuilder, + $$LineRecordingsTableTableUpdateCompanionBuilder, + ( + LineRecordingsTableData, + BaseReferences< + _$AppDatabase, + $LineRecordingsTableTable, + LineRecordingsTableData + >, + ), + LineRecordingsTableData, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -1915,4 +2645,6 @@ class $AppDatabaseManager { _db, _db.annotationSnapshotsTable, ); + $$LineRecordingsTableTableTableManager get lineRecordingsTable => + $$LineRecordingsTableTableTableManager(_db, _db.lineRecordingsTable); } diff --git a/horatio/horatio_app/lib/database/daos/annotation_dao.dart b/horatio/horatio_app/lib/database/daos/annotation_dao.dart index cfc0dcd..a8144ca 100644 --- a/horatio/horatio_app/lib/database/daos/annotation_dao.dart +++ b/horatio/horatio_app/lib/database/daos/annotation_dao.dart @@ -32,24 +32,19 @@ class AnnotationDao extends DatabaseAccessor .map((rows) => rows.map(_rowToMark).toList()); /// Gets marks for a specific line. - Future> getMarksForLine( - String scriptId, - int lineIndex, - ) async { - final rows = await (select(textMarksTable) - ..where( - (t) => - t.scriptId.equals(scriptId) & - t.lineIndex.equals(lineIndex), - )) - .get(); + Future> getMarksForLine(String scriptId, int lineIndex) async { + final rows = + await (select(textMarksTable)..where( + (t) => + t.scriptId.equals(scriptId) & t.lineIndex.equals(lineIndex), + )) + .get(); return rows.map(_rowToMark).toList(); } /// Inserts a text mark. - Future insertMark(String scriptId, TextMark mark) => into( - textMarksTable, - ).insert( + Future insertMark(String scriptId, TextMark mark) => + into(textMarksTable).insert( TextMarksTableCompanion.insert( id: mark.id, scriptId: scriptId, @@ -66,13 +61,13 @@ class AnnotationDao extends DatabaseAccessor (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, - ); + id: row.id, + lineIndex: row.lineIndex, + startOffset: row.startOffset, + endOffset: row.endOffset, + type: MarkType.values.byName(row.markType), + createdAt: row.createdAt, + ); // -- LineNote CRUD -------------------------------------------------------- @@ -85,24 +80,19 @@ class AnnotationDao extends DatabaseAccessor .map((rows) => rows.map(_rowToNote).toList()); /// Gets notes for a specific line. - Future> getNotesForLine( - String scriptId, - int lineIndex, - ) async { - final rows = await (select(lineNotesTable) - ..where( - (t) => - t.scriptId.equals(scriptId) & - t.lineIndex.equals(lineIndex), - )) - .get(); + Future> getNotesForLine(String scriptId, int lineIndex) async { + final rows = + await (select(lineNotesTable)..where( + (t) => + t.scriptId.equals(scriptId) & t.lineIndex.equals(lineIndex), + )) + .get(); return rows.map(_rowToNote).toList(); } /// Inserts a line note. - Future insertNote(String scriptId, LineNote note) => into( - lineNotesTable, - ).insert( + Future insertNote(String scriptId, LineNote note) => + into(lineNotesTable).insert( LineNotesTableCompanion.insert( id: note.id, scriptId: scriptId, @@ -115,27 +105,32 @@ class AnnotationDao extends DatabaseAccessor /// Updates the text of a note. Future updateNoteText(String id, String text) => - (update(lineNotesTable)..where((t) => t.id.equals(id))) - .write(LineNotesTableCompanion(noteText: Value(text))); + (update(lineNotesTable)..where((t) => t.id.equals(id))).write( + LineNotesTableCompanion(noteText: Value(text)), + ); + + /// Updates the category of a note. + Future updateNoteCategory(String id, NoteCategory category) => + (update(lineNotesTable)..where((t) => t.id.equals(id))).write( + LineNotesTableCompanion(category: Value(category.name)), + ); /// Deletes a note by ID. Future deleteNote(String id) => (delete(lineNotesTable)..where((t) => t.id.equals(id))).go(); LineNote _rowToNote(LineNotesTableData row) => LineNote( - id: row.id, - lineIndex: row.lineIndex, - category: NoteCategory.values.byName(row.category), - text: row.noteText, - createdAt: row.createdAt, - ); + id: row.id, + lineIndex: row.lineIndex, + category: NoteCategory.values.byName(row.category), + text: row.noteText, + createdAt: row.createdAt, + ); // -- Snapshot management -------------------------------------------------- /// Watches all snapshots for a script, newest first. - Stream> watchSnapshotsForScript( - String scriptId, - ) => + Stream> watchSnapshotsForScript(String scriptId) => (select(annotationSnapshotsTable) ..where((t) => t.scriptId.equals(scriptId)) ..orderBy([(t) => OrderingTerm.desc(t.timestamp)])) @@ -143,9 +138,8 @@ class AnnotationDao extends DatabaseAccessor .map((rows) => rows.map(_rowToSnapshot).toList()); /// Inserts a snapshot. - Future insertSnapshot(AnnotationSnapshot snapshot) => into( - annotationSnapshotsTable, - ).insert( + Future insertSnapshot(AnnotationSnapshot snapshot) => + into(annotationSnapshotsTable).insert( AnnotationSnapshotsTableCompanion.insert( id: snapshot.id, scriptId: snapshot.scriptId, @@ -158,10 +152,10 @@ class AnnotationDao extends DatabaseAccessor AnnotationSnapshot.fromJson( json.decode(row.snapshotJson) as Map, ); - // Note: scriptId and timestamp exist in both the table columns (for - // efficient WHERE/ORDER BY filtering) AND in the JSON blob (for complete - // deserialization). The columns are the source of truth for queries; - // the JSON is the source of truth for the full snapshot data. + // 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) ------------------------------- @@ -172,12 +166,12 @@ class AnnotationDao extends DatabaseAccessor required List marks, required List notes, }) => transaction(() async { - await (delete(textMarksTable) - ..where((t) => t.scriptId.equals(scriptId))) - .go(); - await (delete(lineNotesTable) - ..where((t) => t.scriptId.equals(scriptId))) - .go(); + 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); } diff --git a/horatio/horatio_app/lib/database/daos/recording_dao.dart b/horatio/horatio_app/lib/database/daos/recording_dao.dart new file mode 100644 index 0000000..5d5c852 --- /dev/null +++ b/horatio/horatio_app/lib/database/daos/recording_dao.dart @@ -0,0 +1,56 @@ +import 'package:drift/drift.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:horatio_app/database/tables/line_recordings_table.dart'; +import 'package:horatio_core/horatio_core.dart'; + +part 'recording_dao.g.dart'; + +/// Data access object for voice recording persistence. +@DriftAccessor(tables: [LineRecordingsTable]) +class RecordingDao extends DatabaseAccessor + with _$RecordingDaoMixin { + /// Creates a [RecordingDao]. + RecordingDao(super.db); + + /// Watches all recordings for a script, ordered by lineIndex. + Stream> watchRecordingsForScript(String scriptId) => + (select(lineRecordingsTable) + ..where((t) => t.scriptId.equals(scriptId)) + ..orderBy([(t) => OrderingTerm.asc(t.lineIndex)])) + .watch() + .map((rows) => rows.map(_rowToRecording).toList()); + + /// Inserts a recording. + Future insertRecording(String scriptId, LineRecording recording) => + into(lineRecordingsTable).insert( + LineRecordingsTableCompanion.insert( + id: recording.id, + scriptId: scriptId, + lineIndex: recording.lineIndex, + filePath: recording.filePath, + durationMs: recording.durationMs, + createdAt: recording.createdAt, + grade: Value(recording.grade), + ), + ); + + /// Deletes a recording by ID. + Future deleteRecording(String id) => + (delete(lineRecordingsTable)..where((t) => t.id.equals(id))).go(); + + /// Updates or clears the grade of a recording. + Future updateRecordingGrade(String id, int? grade) => + (update(lineRecordingsTable)..where((t) => t.id.equals(id))) + .write(LineRecordingsTableCompanion(grade: Value(grade))); + + LineRecording _rowToRecording(LineRecordingsTableData row) => + LineRecording( + id: row.id, + scriptId: row.scriptId, + lineIndex: row.lineIndex, + filePath: row.filePath, + durationMs: row.durationMs, + createdAt: row.createdAt, + grade: row.grade, + ); +} diff --git a/horatio/horatio_app/lib/database/daos/recording_dao.g.dart b/horatio/horatio_app/lib/database/daos/recording_dao.g.dart new file mode 100644 index 0000000..4750c7b --- /dev/null +++ b/horatio/horatio_app/lib/database/daos/recording_dao.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recording_dao.dart'; + +// ignore_for_file: type=lint +mixin _$RecordingDaoMixin on DatabaseAccessor { + $LineRecordingsTableTable get lineRecordingsTable => + attachedDatabase.lineRecordingsTable; + RecordingDaoManager get managers => RecordingDaoManager(this); +} + +class RecordingDaoManager { + final _$RecordingDaoMixin _db; + RecordingDaoManager(this._db); + $$LineRecordingsTableTableTableManager get lineRecordingsTable => + $$LineRecordingsTableTableTableManager( + _db.attachedDatabase, + _db.lineRecordingsTable, + ); +} diff --git a/horatio/horatio_app/lib/database/tables/line_recordings_table.dart b/horatio/horatio_app/lib/database/tables/line_recordings_table.dart new file mode 100644 index 0000000..52066b5 --- /dev/null +++ b/horatio/horatio_app/lib/database/tables/line_recordings_table.dart @@ -0,0 +1,18 @@ +import 'package:drift/drift.dart'; + +/// Drift table for per-line voice recordings. +class LineRecordingsTable extends Table { + @override + String get tableName => 'line_recordings'; + + TextColumn get id => text()(); + TextColumn get scriptId => text()(); + IntColumn get lineIndex => integer()(); + TextColumn get filePath => text()(); + IntColumn get durationMs => integer()(); + DateTimeColumn get createdAt => dateTime()(); + IntColumn get grade => integer().nullable()(); + + @override + Set get primaryKey => {id}; +} diff --git a/horatio/horatio_app/lib/main.dart b/horatio/horatio_app/lib/main.dart index 41f78c7..4920fb8 100644 --- a/horatio/horatio_app/lib/main.dart +++ b/horatio/horatio_app/lib/main.dart @@ -7,6 +7,7 @@ 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'; +import 'package:shared_preferences/shared_preferences.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -14,10 +15,16 @@ void main() async { final dbFolder = await getApplicationDocumentsDirectory(); final dbFile = File(p.join(dbFolder.path, 'horatio.sqlite')); final database = AppDatabase(NativeDatabase(dbFile)); + final recordingsDir = p.join(dbFolder.path, 'horatio_recordings'); + final prefs = await SharedPreferences.getInstance(); runApp( DevicePreview( - builder: (_) => HoratioApp(database: database), + builder: (_) => HoratioApp( + database: database, + recordingsDir: recordingsDir, + prefs: prefs, + ), ), ); } diff --git a/horatio/horatio_app/lib/screens/annotation_editor_screen.dart b/horatio/horatio_app/lib/screens/annotation_editor_screen.dart index 07eac36..c067231 100644 --- a/horatio/horatio_app/lib/screens/annotation_editor_screen.dart +++ b/horatio/horatio_app/lib/screens/annotation_editor_screen.dart @@ -1,15 +1,27 @@ +import 'package:flutter/gestures.dart'; 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/bloc/recording/recording_cubit.dart'; +import 'package:horatio_app/bloc/recording/recording_state.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; import 'package:horatio_app/router.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; import 'package:horatio_app/widgets/mark_overlay.dart'; -import 'package:horatio_app/widgets/mark_type_picker.dart'; +import 'package:horatio_app/widgets/mark_selection_toolbar.dart'; +import 'package:horatio_app/widgets/note_chip.dart'; import 'package:horatio_app/widgets/note_editor_sheet.dart'; import 'package:horatio_app/widgets/note_indicator.dart'; +import 'package:horatio_app/widgets/recording_action_bar.dart'; +import 'package:horatio_app/widgets/recording_badge.dart'; +import 'package:horatio_app/widgets/recording_list_sheet.dart'; +import 'package:horatio_app/widgets/text_scale_settings_sheet.dart'; import 'package:horatio_core/horatio_core.dart'; /// Screen for editing text marks and line notes on a script. @@ -22,16 +34,26 @@ class AnnotationEditorScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final dao = context.read(); + final annotationDao = context.read(); return MultiBlocProvider( providers: [ BlocProvider( create: (_) => - AnnotationCubit(dao: dao)..loadAnnotations(script.id), + AnnotationCubit(dao: annotationDao)..loadAnnotations(script.id), ), BlocProvider( create: (_) => - AnnotationHistoryCubit(dao: dao)..loadSnapshots(script.id), + AnnotationHistoryCubit(dao: annotationDao) + ..loadSnapshots(script.id), + ), + BlocProvider( + create: (context) => RecordingCubit( + dao: context.read(), + recordingService: context.read(), + playbackService: context.read(), + recordingsDir: context.read(), + disposeServicesOnClose: false, + )..loadRecordings(script.id), ), ], child: _AnnotationEditorBody(script: script), @@ -49,38 +71,87 @@ class _AnnotationEditorBody extends StatelessWidget { @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), + appBar: AppBar( + title: Text('Annotate: ${script.title}'), + actions: [ + IconButton( + icon: const Icon(Icons.text_fields), + tooltip: 'Text Size', + onPressed: () => showModalBottomSheet( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: const TextScaleSettingsSheet(), ), + ), + ), + IconButton( + icon: const Icon(Icons.history), + tooltip: 'History', + onPressed: () => + context.push(RoutePaths.annotationHistory, extra: script), + ), + ], + ), + floatingActionButton: BlocBuilder( + builder: (context, state) { + if (state is! AnnotationLoaded) { + return const SizedBox.shrink(); + } + return FloatingActionButton( + onPressed: () => _saveSnapshot(context, state), + tooltip: 'Save Snapshot', + child: const Icon(Icons.save), + ); + }, + ), + body: BlocBuilder( + builder: (context, annotationState) => switch (annotationState) { + AnnotationInitial() => const Center(child: CircularProgressIndicator()), + AnnotationLoaded() => Column( + children: [ + Expanded(child: _buildLineList(context, annotationState)), + if (annotationState.selectedLineIndex != null) + BlocBuilder( + builder: (context, recordingState) { + final lineIndex = annotationState.selectedLineIndex!; + final recordingsForLine = recordingState.recordings + .where((r) => r.lineIndex == lineIndex) + .toList(); + final latestRecording = recordingsForLine.isNotEmpty + ? recordingsForLine.last + : null; + final isRecording = + recordingState is RecordingInProgress && + recordingState.lineIndex == lineIndex; + final elapsed = isRecording + ? (recordingState as RecordingInProgress).elapsed + : Duration.zero; + + return RecordingActionBar( + isRecording: isRecording, + elapsed: elapsed, + latestRecording: latestRecording, + onRecord: () => context + .read() + .startRecording(script.id, lineIndex), + onStop: () => + context.read().stopRecording(), + onPlay: () { + if (latestRecording != null) { + context.read().playRecording( + latestRecording, + ); + } + }, + ); + }, + ), ], ), - floatingActionButton: - BlocBuilder( - builder: (context, state) { - if (state is! AnnotationLoaded) { - return const SizedBox.shrink(); - } - return FloatingActionButton( - onPressed: () => _saveSnapshot(context, state), - tooltip: 'Save Snapshot', - child: const Icon(Icons.save), - ); - }, - ), - body: BlocBuilder( - builder: (context, state) => switch (state) { - AnnotationInitial() => - const Center(child: CircularProgressIndicator()), - AnnotationLoaded() => _buildLineList(context, state), - }, - ), - ); + }, + ), + ); Widget _buildLineList(BuildContext context, AnnotationLoaded state) { final lines = _allLines; @@ -88,10 +159,12 @@ class _AnnotationEditorBody extends StatelessWidget { 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 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, @@ -106,13 +179,13 @@ class _AnnotationEditorBody extends StatelessWidget { void _saveSnapshot(BuildContext context, AnnotationLoaded state) { context.read().saveSnapshot( - marks: state.marks, - notes: state.notes, - ); + marks: state.marks, + notes: state.notes, + ); } } -class _LineTile extends StatelessWidget { +class _LineTile extends StatefulWidget { const _LineTile({ required this.line, required this.lineIndex, @@ -127,53 +200,267 @@ class _LineTile extends StatelessWidget { final List notes; final bool isSelected; + @override + State<_LineTile> createState() => _LineTileState(); +} + +class _LineTileState extends State<_LineTile> { + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _toolbarOverlay; + TextSelection? _selection; + final List _recognizers = []; + + @override + void dispose() { + _removeToolbar(); + _disposeRecognizers(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _LineTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.isSelected && oldWidget.isSelected) { + _removeToolbar(); + } + } + + void _disposeRecognizers() { + for (final r in _recognizers) { + r.dispose(); + } + _recognizers.clear(); + } + + void _removeToolbar() { + _toolbarOverlay?.remove(); + _toolbarOverlay = null; + } + + void _onSelectionChanged( + TextSelection selection, + SelectionChangedCause? cause, + ) { + _removeToolbar(); + if (selection.isCollapsed) { + _selection = null; + return; + } + _selection = selection; + _showToolbar(); + } + + void _showToolbar() { + final overlay = Overlay.of(context); + _toolbarOverlay = OverlayEntry( + builder: (context) => Positioned( + width: MediaQuery.of(context).size.width, + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: const Offset(0, -48), + child: Align( + alignment: Alignment.centerLeft, + child: MarkSelectionToolbar( + onMarkSelected: _applyMark, + onCancelled: _removeToolbar, + ), + ), + ), + ), + ); + overlay.insert(_toolbarOverlay!); + } + + void _applyMark(MarkType type) { + final sel = _selection; + if (sel == null || sel.isCollapsed) return; + context.read().addMark( + lineIndex: widget.lineIndex, + startOffset: sel.start, + endOffset: sel.end, + type: type, + ); + _removeToolbar(); + } + + void _showRemoveMarkDialog(String markId) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Remove mark?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('No'), + ), + TextButton( + onPressed: () { + context.read().removeMark(markId); + Navigator.pop(dialogContext); + }, + child: const Text('Yes'), + ), + ], + ), + ); + } + + List _buildSpans() { + _disposeRecognizers(); + final text = widget.line.text; + final marks = widget.marks; + if (marks.isEmpty) return [TextSpan(text: text)]; + + final length = text.length; + final events = <({int offset, bool isStart, MarkType type})>[]; + for (final mark in marks) { + final s = mark.startOffset.clamp(0, length); + final e = mark.endOffset.clamp(0, length); + if (s >= e) continue; + events + ..add((offset: s, isStart: true, type: mark.type)) + ..add((offset: e, isStart: false, type: mark.type)); + } + events.sort((a, b) => a.offset.compareTo(b.offset)); + + final spans = []; + var cursor = 0; + final activeTypes = []; + for (final event in events) { + final pos = event.offset.clamp(0, length); + if (pos > cursor) { + if (activeTypes.isNotEmpty) { + final markForSpan = marks.firstWhere( + (m) => + m.startOffset <= cursor && + m.endOffset >= pos && + m.type == activeTypes.last, + ); + final recognizer = TapGestureRecognizer() + ..onTap = () => _showRemoveMarkDialog(markForSpan.id); + _recognizers.add(recognizer); + spans.add( + TextSpan( + text: text.substring(cursor, pos), + style: TextStyle(backgroundColor: markColors[activeTypes.last]), + recognizer: recognizer, + ), + ); + } else { + spans.add(TextSpan(text: text.substring(cursor, pos))); + } + 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; + } + @override Widget build(BuildContext context) => Container( - color: isSelected - ? Theme.of(context).colorScheme.primaryContainer.withValues( - alpha: 0.3, - ) - : null, - child: InkWell( - onTap: () => - context.read().selectLine(lineIndex), - onLongPress: () => _showMarkPicker(context), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( + color: widget.isSelected + ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3) + : null, + child: InkWell( + onTap: () => context.read().selectLine(widget.lineIndex), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ Expanded( - child: MarkOverlay(text: line.text, marks: marks), + child: widget.isSelected + ? CompositedTransformTarget( + link: _layerLink, + child: SelectableText.rich( + TextSpan( + style: DefaultTextStyle.of(context).style, + children: _buildSpans(), + ), + onSelectionChanged: _onSelectionChanged, + ), + ) + : MarkOverlay( + text: widget.line.text, + marks: widget.marks, + ), + ), + BlocBuilder( + builder: (context, recordingState) { + final recordingsForLine = recordingState.recordings + .where((r) => r.lineIndex == widget.lineIndex) + .toList(); + return RecordingBadge( + recordingCount: recordingsForLine.length, + onTap: () => + _showRecordingList(context, recordingsForLine), + ); + }, + ), + IconButton( + icon: const Icon(Icons.note_add_outlined), + tooltip: 'Add Note', + onPressed: () => _showNoteEditor(context), ), NoteIndicator( - noteCount: notes.length, + noteCount: widget.notes.length, onTap: () => _showNoteEditor(context), ), ], ), - ), + if (widget.isSelected && widget.notes.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: widget.notes + .map( + (note) => NoteChip( + note: note, + onTap: () => _showNoteEditorForEdit(context, note), + onDelete: () => context + .read() + .removeNote(note.id), + ), + ) + .toList(), + ), + ), + ], ), - ); + ), + ), + ); - void _showMarkPicker(BuildContext context) { - final cubit = context.read(); - showDialog( + void _showRecordingList( + BuildContext context, + List recordings, + ) { + showModalBottomSheet( 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), - ), + builder: (_) => RecordingListSheet( + recordings: recordings, + onPlay: (recording) { + Navigator.pop(context); + context.read().playRecording(recording); + }, + onGrade: (id, grade) { + context.read().gradeRecording(id, grade); + }, + onDelete: (id) { + context.read().deleteRecording(id); + }, ), ); } @@ -188,9 +475,9 @@ class _LineTile extends StatelessWidget { bottom: MediaQuery.of(context).viewInsets.bottom, ), child: NoteEditorSheet( - onSave: (category, text) { + onSave: (category, text, {String? noteId}) { cubit.addNote( - lineIndex: lineIndex, + lineIndex: widget.lineIndex, category: category, text: text, ); @@ -201,4 +488,27 @@ class _LineTile extends StatelessWidget { ), ); } + + void _showNoteEditorForEdit(BuildContext context, LineNote note) { + final cubit = context.read(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: NoteEditorSheet( + initialCategory: note.category, + initialText: note.text, + noteId: note.id, + onSave: (category, text, {String? noteId}) { + cubit.updateNote(noteId ?? note.id, text: text, category: category); + Navigator.pop(context); + }, + onCancel: () => Navigator.pop(context), + ), + ), + ); + } } diff --git a/horatio/horatio_app/lib/screens/home_screen.dart b/horatio/horatio_app/lib/screens/home_screen.dart index 9bc93a5..39e1b95 100644 --- a/horatio/horatio_app/lib/screens/home_screen.dart +++ b/horatio/horatio_app/lib/screens/home_screen.dart @@ -6,9 +6,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:horatio_app/bloc/script_import/script_import_cubit.dart'; import 'package:horatio_app/bloc/script_import/script_import_state.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; import 'package:horatio_app/router.dart'; import 'package:horatio_app/services/file_import_service.dart'; import 'package:horatio_app/widgets/script_card_widget.dart'; +import 'package:horatio_app/widgets/text_scale_settings_sheet.dart'; /// Main screen — shows the script library with drag-and-drop import. class HomeScreen extends StatefulWidget { @@ -50,7 +52,22 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Horatio')), + appBar: AppBar( + title: const Text('Horatio'), + actions: [ + IconButton( + icon: const Icon(Icons.text_fields), + tooltip: 'Text Size', + onPressed: () => showModalBottomSheet( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: const TextScaleSettingsSheet(), + ), + ), + ), + ], + ), body: DropTarget( onDragDone: _handleDrop, onDragEntered: (_) => setState(() => _isDragging = true), diff --git a/horatio/horatio_app/lib/services/audio_playback_service.dart b/horatio/horatio_app/lib/services/audio_playback_service.dart new file mode 100644 index 0000000..a4a5b40 --- /dev/null +++ b/horatio/horatio_app/lib/services/audio_playback_service.dart @@ -0,0 +1,50 @@ +import 'package:audioplayers/audioplayers.dart'; + +/// Playback status for the audio player. +enum PlaybackStatus { + /// Not playing. + idle, + + /// Currently playing audio. + playing, + + /// Playback finished. + completed, +} + +/// Wraps [AudioPlayer] for audio playback. +class AudioPlaybackService { + /// Creates an [AudioPlaybackService]. + AudioPlaybackService({AudioPlayer? player}) + : _player = player; + + AudioPlayer? _player; + + AudioPlayer get _activePlayer => _player ??= AudioPlayer(); + + /// Plays audio from a local file path. + Future play(String filePath) => + _activePlayer.play(DeviceFileSource(filePath)); + + /// Stops playback. + Future stop() => _activePlayer.stop(); + + /// Stream of playback status changes. + Stream get status => + _activePlayer.onPlayerStateChanged.map((state) => switch (state) { + PlayerState.playing => PlaybackStatus.playing, + PlayerState.completed => PlaybackStatus.completed, + _ => PlaybackStatus.idle, + }); + + /// Stream of playback position. + Stream get position => _activePlayer.onPositionChanged; + + /// Releases the player resources. + Future dispose() async { + final player = _player; + if (player != null) { + await player.dispose(); + } + } +} diff --git a/horatio/horatio_app/lib/services/recording_service.dart b/horatio/horatio_app/lib/services/recording_service.dart new file mode 100644 index 0000000..fb0fa65 --- /dev/null +++ b/horatio/horatio_app/lib/services/recording_service.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:record/record.dart'; + +/// Wraps the [AudioRecorder] for microphone recording. +class RecordingService { + /// Creates a [RecordingService]. + RecordingService({AudioRecorder? recorder}) + : _recorder = recorder; + + AudioRecorder? _recorder; + + AudioRecorder get _activeRecorder => _recorder ??= AudioRecorder(); + + /// Whether the app has microphone permission. + Future hasPermission() => _activeRecorder.hasPermission(); + + /// Starts recording to the given file path. + /// + /// Creates the parent directory if it doesn't exist. + Future startRecording(String filePath) async { + final parent = File(filePath).parent; + if (!parent.existsSync()) { + await parent.create(recursive: true); + } + await _activeRecorder.start(const RecordConfig(), path: filePath); + } + + /// Stops recording and returns the file path. + Future stopRecording() => _activeRecorder.stop(); + + /// Releases the recorder resources. + Future dispose() async { + final recorder = _recorder; + if (recorder != null) { + await recorder.dispose(); + } + } +} diff --git a/horatio/horatio_app/lib/widgets/grade_stars.dart b/horatio/horatio_app/lib/widgets/grade_stars.dart new file mode 100644 index 0000000..848523b --- /dev/null +++ b/horatio/horatio_app/lib/widgets/grade_stars.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +/// A 0-5 grade widget with tappable stars and a "Blackout" (grade 0) button. +class GradeStars extends StatelessWidget { + /// Creates a [GradeStars]. + const GradeStars({ + required this.grade, + required this.onGrade, + super.key, + }); + + /// Current grade (0-5), null if not yet graded. + final int? grade; + + /// Called with the selected grade (0-5). + final ValueChanged onGrade; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => onGrade(0), + style: TextButton.styleFrom( + foregroundColor: grade == 0 ? Colors.red : null, + ), + child: const Text('Blackout'), + ), + for (var i = 1; i <= 5; i++) + IconButton( + icon: Icon( + grade != null && i <= grade! ? Icons.star : Icons.star_border, + color: Colors.amber, + ), + onPressed: () => onGrade(i), + ), + ], + ); +} diff --git a/horatio/horatio_app/lib/widgets/mark_selection_toolbar.dart b/horatio/horatio_app/lib/widgets/mark_selection_toolbar.dart new file mode 100644 index 0000000..ee8b0be --- /dev/null +++ b/horatio/horatio_app/lib/widgets/mark_selection_toolbar.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:horatio_app/widgets/mark_overlay.dart'; +import 'package:horatio_app/widgets/mark_type_picker.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// Floating toolbar showing mark type chips for text selection annotation. +class MarkSelectionToolbar extends StatelessWidget { + /// Creates a [MarkSelectionToolbar]. + const MarkSelectionToolbar({ + required this.onMarkSelected, + required this.onCancelled, + super.key, + }); + + /// Called when a mark type chip is tapped. + final ValueChanged onMarkSelected; + + /// Called when the action is cancelled. + final VoidCallback onCancelled; + + @override + Widget build(BuildContext context) => Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...MarkType.values.map( + (type) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: ActionChip( + label: Text(markTypeLabel(type)), + backgroundColor: markColors[type], + onPressed: () => onMarkSelected(type), + ), + ), + ), + const SizedBox(width: 4), + TextButton( + onPressed: onCancelled, + child: const Text('Cancel'), + ), + ], + ), + ), + ); +} diff --git a/horatio/horatio_app/lib/widgets/note_chip.dart b/horatio/horatio_app/lib/widgets/note_chip.dart new file mode 100644 index 0000000..8edec2a --- /dev/null +++ b/horatio/horatio_app/lib/widgets/note_chip.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// Category icons for note chips. +const Map noteCategoryIcons = { + NoteCategory.intention: Icons.psychology, + NoteCategory.subtext: Icons.chat_bubble_outline, + NoteCategory.blocking: Icons.directions_walk, + NoteCategory.emotion: Icons.favorite, + NoteCategory.delivery: Icons.record_voice_over, + NoteCategory.general: Icons.note, +}; + +/// An inline chip displaying a note's category icon and truncated text. +class NoteChip extends StatelessWidget { + /// Creates a [NoteChip]. + const NoteChip({ + required this.note, + required this.onTap, + required this.onDelete, + super.key, + }); + + /// The note to display. + final LineNote note; + + /// Called when the chip is tapped (edit). + final VoidCallback onTap; + + /// Called when the chip is long-pressed (delete). + final VoidCallback onDelete; + + String get _truncatedText => + note.text.length > 30 ? '${note.text.substring(0, 30)}...' : note.text; + + @override + Widget build(BuildContext context) => GestureDetector( + onLongPress: onDelete, + child: ActionChip( + avatar: Icon(noteCategoryIcons[note.category] ?? Icons.note, size: 16), + label: Text(_truncatedText), + onPressed: onTap, + ), + ); +} diff --git a/horatio/horatio_app/lib/widgets/note_editor_sheet.dart b/horatio/horatio_app/lib/widgets/note_editor_sheet.dart index 5c94592..4c9d3f4 100644 --- a/horatio/horatio_app/lib/widgets/note_editor_sheet.dart +++ b/horatio/horatio_app/lib/widgets/note_editor_sheet.dart @@ -3,13 +3,13 @@ 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', - }; + 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 { @@ -19,11 +19,13 @@ class NoteEditorSheet extends StatefulWidget { required this.onCancel, this.initialCategory, this.initialText, + this.noteId, super.key, }); - /// Called with the chosen category and text on save. - final void Function(NoteCategory category, String text) onSave; + /// Called with the chosen category, text, and optional noteId on save. + final void Function(NoteCategory category, String text, {String? noteId}) + onSave; /// Called when the user cancels editing. final VoidCallback onCancel; @@ -34,6 +36,9 @@ class NoteEditorSheet extends StatefulWidget { /// Pre-filled text when editing an existing note. final String? initialText; + /// Non-null when editing an existing note. + final String? noteId; + @override State createState() => _NoteEditorSheetState(); } @@ -58,64 +63,66 @@ class _NoteEditorSheetState extends State { @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: _category, + decoration: const InputDecoration(labelText: 'Category'), + items: NoteCategory.values + .map( + (c) => DropdownMenuItem( + value: c, + child: Text(noteCategoryLabel(c)), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + setState(() => _category = value); + } + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _textController, + decoration: const InputDecoration( + labelText: 'Note', + hintText: 'Enter your note...', + ), + maxLines: 3, + validator: (value) => value == null || value.trim().isEmpty + ? 'Note cannot be empty' + : null, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, children: [ - DropdownButtonFormField( - initialValue: _category, - decoration: const InputDecoration(labelText: 'Category'), - items: NoteCategory.values - .map( - (c) => DropdownMenuItem( - value: c, - child: Text(noteCategoryLabel(c)), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - setState(() => _category = value); - } - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _textController, - decoration: const InputDecoration( - labelText: 'Note', - hintText: 'Enter your note...', - ), - maxLines: 3, - validator: (value) => - value == null || value.trim().isEmpty ? 'Note cannot be empty' : null, - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: widget.onCancel, - child: const Text('Cancel'), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _submit, - child: const Text('Save'), - ), - ], + 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()); + widget.onSave( + _category, + _textController.text.trim(), + noteId: widget.noteId, + ); } } } diff --git a/horatio/horatio_app/lib/widgets/recording_action_bar.dart b/horatio/horatio_app/lib/widgets/recording_action_bar.dart new file mode 100644 index 0000000..20f26e5 --- /dev/null +++ b/horatio/horatio_app/lib/widgets/recording_action_bar.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// Bottom action bar for record/play controls on a selected line. +class RecordingActionBar extends StatelessWidget { + /// Creates a [RecordingActionBar]. + const RecordingActionBar({ + required this.isRecording, + required this.elapsed, + required this.latestRecording, + required this.onRecord, + required this.onStop, + required this.onPlay, + super.key, + }); + + /// Whether currently recording. + final bool isRecording; + + /// Elapsed recording time. + final Duration elapsed; + + /// Most recent recording for the selected line (null if none). + final LineRecording? latestRecording; + + /// Start recording callback. + final VoidCallback onRecord; + + /// Stop recording callback. + final VoidCallback onStop; + + /// Play last recording callback. + final VoidCallback onPlay; + + String _formatDuration(Duration d) { + final minutes = d.inMinutes; + final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border( + top: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isRecording) ...[ + IconButton( + icon: const Icon(Icons.stop, color: Colors.red), + onPressed: onStop, + tooltip: 'Stop Recording', + ), + Text(_formatDuration(elapsed)), + ] else ...[ + IconButton( + icon: const Icon(Icons.mic), + onPressed: onRecord, + tooltip: 'Record', + ), + ], + const SizedBox(width: 16), + IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: latestRecording != null ? onPlay : null, + tooltip: 'Play Last Recording', + ), + ], + ), + ); +} diff --git a/horatio/horatio_app/lib/widgets/recording_badge.dart b/horatio/horatio_app/lib/widgets/recording_badge.dart new file mode 100644 index 0000000..ab1ac21 --- /dev/null +++ b/horatio/horatio_app/lib/widgets/recording_badge.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +/// A small mic icon with count badge, showing recordings per line. +class RecordingBadge extends StatelessWidget { + /// Creates a [RecordingBadge]. + const RecordingBadge({ + required this.recordingCount, + required this.onTap, + super.key, + }); + + /// Number of recordings for the line. + final int recordingCount; + + /// Callback when tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + if (recordingCount == 0) return const SizedBox.shrink(); + return GestureDetector( + onTap: onTap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.mic, size: 16), + const SizedBox(width: 2), + Text( + '$recordingCount', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} diff --git a/horatio/horatio_app/lib/widgets/recording_list_sheet.dart b/horatio/horatio_app/lib/widgets/recording_list_sheet.dart new file mode 100644 index 0000000..1f956b4 --- /dev/null +++ b/horatio/horatio_app/lib/widgets/recording_list_sheet.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:horatio_app/widgets/grade_stars.dart'; +import 'package:intl/intl.dart'; + +/// Bottom sheet listing all recordings for a line. +class RecordingListSheet extends StatelessWidget { + /// Creates a [RecordingListSheet]. + const RecordingListSheet({ + required this.recordings, + required this.onPlay, + required this.onGrade, + required this.onDelete, + super.key, + }); + + /// Recordings to display. + final List recordings; + + /// Called when play is tapped for a recording. + final ValueChanged onPlay; + + /// Called when a grade is selected for a recording. + final void Function(String id, int grade) onGrade; + + /// Called when delete is tapped for a recording. + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Recordings', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (recordings.isEmpty) + const Center(child: Text('No recordings')) + else + ...recordings.map( + (r) => ListTile( + leading: IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () => onPlay(r), + ), + title: Text( + '${(r.durationMs / 1000).toStringAsFixed(1)}s - ' + '${DateFormat.yMd().format(r.createdAt)}', + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GradeStars( + grade: r.grade, + onGrade: (grade) => onGrade(r.id, grade), + ), + if (r.grade == null) const Text('Not graded'), + ], + ), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => onDelete(r.id), + ), + ), + ), + ], + ), + ); +} diff --git a/horatio/horatio_app/lib/widgets/text_scale_settings_sheet.dart b/horatio/horatio_app/lib/widgets/text_scale_settings_sheet.dart new file mode 100644 index 0000000..624e779 --- /dev/null +++ b/horatio/horatio_app/lib/widgets/text_scale_settings_sheet.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_state.dart'; + +/// A bottom sheet with a slider for adjusting text scale factor. +class TextScaleSettingsSheet extends StatelessWidget { + /// Creates a [TextScaleSettingsSheet]. + const TextScaleSettingsSheet({super.key}); + + @override + Widget build(BuildContext context) => + BlocBuilder( + builder: (context, state) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Text Size', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Text( + 'Sample text at ${state.scaleFactor.toStringAsFixed(1)}x', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + Slider( + value: state.scaleFactor, + min: 0.5, + max: 3, + divisions: 25, + label: '${state.scaleFactor.toStringAsFixed(1)}x', + onChanged: (value) => + context.read().setScale(value), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => + context.read().resetToAuto(), + child: const Text('Reset to auto'), + ), + ), + ], + ), + ), + ); +} diff --git a/horatio/horatio_app/linux/flutter/generated_plugin_registrant.cc b/horatio/horatio_app/linux/flutter/generated_plugin_registrant.cc index 4fb7949..b91a424 100644 --- a/horatio/horatio_app/linux/flutter/generated_plugin_registrant.cc +++ b/horatio/horatio_app/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) desktop_drop_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin"); desktop_drop_plugin_register_with_registrar(desktop_drop_registrar); diff --git a/horatio/horatio_app/linux/flutter/generated_plugins.cmake b/horatio/horatio_app/linux/flutter/generated_plugins.cmake index a49f733..663836b 100644 --- a/horatio/horatio_app/linux/flutter/generated_plugins.cmake +++ b/horatio/horatio_app/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux desktop_drop record_linux ) diff --git a/horatio/horatio_app/pubspec.lock b/horatio/horatio_app/pubspec.lock index ddd0878..0f46fab 100644 --- a/horatio/horatio_app/pubspec.lock +++ b/horatio/horatio_app/pubspec.lock @@ -41,6 +41,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 + url: "https://pub.dev" + source: hosted + version: "6.6.0" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 + url: "https://pub.dev" + source: hosted + version: "5.2.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 + url: "https://pub.dev" + source: hosted + version: "4.3.0" bloc: dependency: transitive description: @@ -436,6 +492,14 @@ packages: relative: true source: path version: "0.1.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -797,7 +861,7 @@ packages: source: hosted version: "1.0.7" shared_preferences: - dependency: transitive + dependency: "direct main" description: name: shared_preferences sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf @@ -1001,6 +1065,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: diff --git a/horatio/horatio_app/pubspec.yaml b/horatio/horatio_app/pubspec.yaml index 8a2417b..0d4d325 100644 --- a/horatio/horatio_app/pubspec.yaml +++ b/horatio/horatio_app/pubspec.yaml @@ -25,6 +25,8 @@ dependencies: path_provider: ^2.1.0 path: ^1.9.0 intl: ^0.20.2 + shared_preferences: ^2.3.0 + audioplayers: ^6.1.0 horatio_core: path: ../horatio_core speech_to_text: ^7.3.0 diff --git a/horatio/horatio_app/test/app_test.dart b/horatio/horatio_app/test/app_test.dart index 3b0ddeb..9cee361 100644 --- a/horatio/horatio_app/test/app_test.dart +++ b/horatio/horatio_app/test/app_test.dart @@ -4,32 +4,63 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:horatio_app/app.dart'; import 'package:horatio_app/router.dart'; import 'package:horatio_core/horatio_core.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'helpers/test_database.dart'; void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + testWidgets('HoratioApp builds without crashing', (tester) async { - await tester.pumpWidget(HoratioApp(database: createTestDatabase())); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + HoratioApp( + database: createTestDatabase(), + recordingsDir: '/tmp/test_recordings', + prefs: prefs, + ), + ); await tester.pumpAndSettle(); expect(find.text('Horatio'), findsOneWidget); }); - testWidgets('SrsReviewCubit is created when srs-review route is visited', - (tester) async { - await tester.pumpWidget(HoratioApp(database: createTestDatabase())); + testWidgets('SrsReviewCubit is created when srs-review route is visited', ( + tester, + ) async { + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + HoratioApp( + database: createTestDatabase(), + recordingsDir: '/tmp/test_recordings', + prefs: prefs, + ), + ); await tester.pumpAndSettle(); - unawaited(appRouter.push(RoutePaths.srsReview, extra: [ - SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans'), - ])); + unawaited( + appRouter.push( + RoutePaths.srsReview, + extra: [SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans')], + ), + ); await tester.pumpAndSettle(); expect(find.text('No review session active.'), findsOneWidget); }); - testWidgets('AnnotationDao is provided when annotation route is visited', - (tester) async { + testWidgets('AnnotationDao is provided when annotation route is visited', ( + tester, + ) async { final db = createTestDatabase(); - await tester.pumpWidget(HoratioApp(database: db)); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + HoratioApp( + database: db, + recordingsDir: '/tmp/test_recordings', + prefs: prefs, + ), + ); await tester.pumpAndSettle(); const role = Role(name: 'Hero'); @@ -40,12 +71,7 @@ void main() { scenes: [ Scene( lines: [ - ScriptLine( - text: 'Hello.', - role: role, - sceneIndex: 0, - lineIndex: 0, - ), + ScriptLine(text: 'Hello.', role: role, sceneIndex: 0, lineIndex: 0), ], ), ], diff --git a/horatio/horatio_app/test/bloc/annotation_cubit_test.dart b/horatio/horatio_app/test/bloc/annotation_cubit_test.dart index 5c5d18f..09db0cd 100644 --- a/horatio/horatio_app/test/bloc/annotation_cubit_test.dart +++ b/horatio/horatio_app/test/bloc/annotation_cubit_test.dart @@ -38,10 +38,12 @@ void main() { marksController = StreamController>.broadcast(); notesController = StreamController>.broadcast(); - when(() => dao.watchMarksForScript(scriptId)) - .thenAnswer((_) => marksController.stream); - when(() => dao.watchNotesForScript(scriptId)) - .thenAnswer((_) => notesController.stream); + when( + () => dao.watchMarksForScript(scriptId), + ).thenAnswer((_) => marksController.stream); + when( + () => dao.watchNotesForScript(scriptId), + ).thenAnswer((_) => notesController.stream); }); tearDown(() { @@ -52,6 +54,7 @@ void main() { setUpAll(() { registerFallbackValue(testMark); registerFallbackValue(testNote); + registerFallbackValue(NoteCategory.intention); }); group('AnnotationCubit', () { @@ -102,8 +105,8 @@ void main() { }); test('selectLine is no-op when state is AnnotationInitial', () { - final cubit = AnnotationCubit(dao: dao); - cubit.selectLine(3); // Should not throw + final cubit = AnnotationCubit(dao: dao) + ..selectLine(3); // Should not throw expect(cubit.state, isA()); cubit.close(); }); @@ -133,15 +136,14 @@ void main() { }); test('startEditing is no-op when state is AnnotationInitial', () { - final cubit = AnnotationCubit(dao: dao); - cubit.startEditing(lineIndex: 0, isAddingMark: true); + final cubit = AnnotationCubit(dao: dao) + ..startEditing(lineIndex: 0, isAddingMark: true); expect(cubit.state, isA()); cubit.close(); }); test('cancelEditing is no-op when state is AnnotationInitial', () { - final cubit = AnnotationCubit(dao: dao); - cubit.cancelEditing(); + final cubit = AnnotationCubit(dao: dao)..cancelEditing(); expect(cubit.state, isA()); cubit.close(); }); @@ -220,14 +222,51 @@ void main() { }); test('updateNote calls dao.updateNoteText', () async { - when(() => dao.updateNoteText('n1', 'new')) - .thenAnswer((_) async {}); + when(() => dao.updateNoteText('n1', 'new')).thenAnswer((_) async {}); final cubit = AnnotationCubit(dao: dao); - await cubit.updateNote('n1', 'new'); + await cubit.updateNote('n1', text: 'new'); verify(() => dao.updateNoteText('n1', 'new')).called(1); await cubit.close(); }); + test('updateNote calls dao.updateNoteCategory', () async { + when( + () => dao.updateNoteCategory('n1', NoteCategory.emotion), + ).thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.updateNote('n1', category: NoteCategory.emotion); + verify( + () => dao.updateNoteCategory('n1', NoteCategory.emotion), + ).called(1); + await cubit.close(); + }); + + test('updateNote with both text and category', () async { + when(() => dao.updateNoteText('n1', 'new')).thenAnswer((_) async {}); + when( + () => dao.updateNoteCategory('n1', NoteCategory.blocking), + ).thenAnswer((_) async {}); + final cubit = AnnotationCubit(dao: dao); + await cubit.updateNote( + 'n1', + text: 'new', + category: NoteCategory.blocking, + ); + verify(() => dao.updateNoteText('n1', 'new')).called(1); + verify( + () => dao.updateNoteCategory('n1', NoteCategory.blocking), + ).called(1); + await cubit.close(); + }); + + test('updateNote with no arguments is no-op', () async { + final cubit = AnnotationCubit(dao: dao); + await cubit.updateNote('n1'); + verifyNever(() => dao.updateNoteText(any(), any())); + verifyNever(() => dao.updateNoteCategory(any(), any())); + await cubit.close(); + }); + test('removeNote calls dao.deleteNote', () async { when(() => dao.deleteNote('n1')).thenAnswer((_) async {}); final cubit = AnnotationCubit(dao: dao); @@ -236,33 +275,37 @@ void main() { await cubit.close(); }); - test('loadAnnotations with new scriptId cancels previous streams', - () async { - final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); - marksController.add([testMark]); - await Future.delayed(Duration.zero); + test( + 'loadAnnotations with new scriptId cancels previous streams', + () async { + final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); + marksController.add([testMark]); + await Future.delayed(Duration.zero); - final marks2 = StreamController>.broadcast(); - final notes2 = StreamController>.broadcast(); - when(() => dao.watchMarksForScript('script-2')) - .thenAnswer((_) => marks2.stream); - when(() => dao.watchNotesForScript('script-2')) - .thenAnswer((_) => notes2.stream); + final marks2 = StreamController>.broadcast(); + final notes2 = StreamController>.broadcast(); + when( + () => dao.watchMarksForScript('script-2'), + ).thenAnswer((_) => marks2.stream); + when( + () => dao.watchNotesForScript('script-2'), + ).thenAnswer((_) => notes2.stream); - cubit.loadAnnotations('script-2'); - marks2.add([]); - notes2.add([]); - await Future.delayed(Duration.zero); + cubit.loadAnnotations('script-2'); + marks2.add([]); + notes2.add([]); + await Future.delayed(Duration.zero); - final state = cubit.state; - expect(state, isA()); - expect((state as AnnotationLoaded).scriptId, 'script-2'); - expect(state.marks, isEmpty); + final state = cubit.state; + expect(state, isA()); + expect((state as AnnotationLoaded).scriptId, 'script-2'); + expect(state.marks, isEmpty); - await cubit.close(); - await marks2.close(); - await notes2.close(); - }); + await cubit.close(); + await marks2.close(); + await notes2.close(); + }, + ); test('close cancels stream subscriptions', () async { final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId); diff --git a/horatio/horatio_app/test/bloc/recording_cubit_test.dart b/horatio/horatio_app/test/bloc/recording_cubit_test.dart new file mode 100644 index 0000000..7cfcf1d --- /dev/null +++ b/horatio/horatio_app/test/bloc/recording_cubit_test.dart @@ -0,0 +1,401 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/bloc/recording/recording_cubit.dart'; +import 'package:horatio_app/bloc/recording/recording_state.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; +import 'package:horatio_core/horatio_core.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockRecordingDao extends Mock implements RecordingDao {} + +class MockRecordingService extends Mock implements RecordingService {} + +class MockAudioPlaybackService extends Mock implements AudioPlaybackService {} + +void main() { + late MockRecordingDao dao; + late MockRecordingService recordingService; + late MockAudioPlaybackService playbackService; + late StreamController> recordingsController; + late StreamController statusController; + late StreamController positionController; + + const scriptId = 'script-1'; + + final testRecording = LineRecording( + id: 'r1', + scriptId: scriptId, + lineIndex: 0, + filePath: '/path/to/file.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ); + + setUpAll(() { + registerFallbackValue(testRecording); + }); + + setUp(() { + dao = MockRecordingDao(); + recordingService = MockRecordingService(); + playbackService = MockAudioPlaybackService(); + recordingsController = StreamController>.broadcast(); + statusController = StreamController.broadcast(); + positionController = StreamController.broadcast(); + + when(() => dao.watchRecordingsForScript(scriptId)) + .thenAnswer((_) => recordingsController.stream); + when(() => playbackService.status) + .thenAnswer((_) => statusController.stream); + when(() => playbackService.position) + .thenAnswer((_) => positionController.stream); + when(() => recordingService.dispose()).thenAnswer((_) async {}); + when(() => playbackService.dispose()).thenAnswer((_) async {}); + }); + + tearDown(() async { + await recordingsController.close(); + await statusController.close(); + await positionController.close(); + }); + + RecordingCubit createCubit() => RecordingCubit( + dao: dao, + recordingService: recordingService, + playbackService: playbackService, + recordingsDir: '/tmp/test_recordings', + ); + + group('RecordingCubit', () { + test('initial state is RecordingInitial', () { + final cubit = createCubit(); + expect(cubit.state, isA()); + cubit.close(); + }); + + test('loadRecordings emits RecordingIdle on stream data', () async { + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + expect(cubit.state, isA()); + expect((cubit.state as RecordingIdle).recordings, [testRecording]); + await cubit.close(); + }); + + test('startRecording transitions to RecordingInProgress', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + + expect(cubit.state, isA()); + expect((cubit.state as RecordingInProgress).lineIndex, 0); + await cubit.close(); + }); + + test('recording timer updates elapsed while in progress', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + await Future.delayed(const Duration(milliseconds: 120)); + + final state = cubit.state; + expect(state, isA()); + expect((state as RecordingInProgress).elapsed, isNot(Duration.zero)); + await cubit.close(); + }); + + test('startRecording emits error on permission denied', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => false); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + + expect(cubit.state, isA()); + expect( + (cubit.state as RecordingError).message, + 'Microphone permission required for recording', + ); + await cubit.close(); + }); + + test('startRecording is no-op when already recording', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + await cubit.startRecording(scriptId, 1); + + expect((cubit.state as RecordingInProgress).lineIndex, 0); + verify(() => recordingService.startRecording(any())).called(1); + await cubit.close(); + }); + + test('stopRecording transitions to RecordingIdle', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + when(() => recordingService.stopRecording()) + .thenAnswer((_) async => '/path/to/file.m4a'); + when(() => dao.insertRecording(any(), any())) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + await cubit.stopRecording(); + + verify(() => dao.insertRecording(scriptId, any())).called(1); + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('stopRecording handles null path', () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + when(() => recordingService.stopRecording()) + .thenAnswer((_) async => null); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + await cubit.stopRecording(); + + verifyNever(() => dao.insertRecording(any(), any())); + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('playRecording transitions to RecordingPlayback', () async { + when(() => playbackService.play(any())).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.playRecording(testRecording); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('playback completion transitions to RecordingGrading', () async { + when(() => playbackService.play(any())).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.playRecording(testRecording); + statusController.add(PlaybackStatus.completed); + await Future.delayed(Duration.zero); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('stopPlayback transitions to RecordingIdle', () async { + when(() => playbackService.play(any())).thenAnswer((_) async {}); + when(() => playbackService.stop()).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.playRecording(testRecording); + await cubit.stopPlayback(); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('gradeRecording calls dao and returns to idle', () async { + when(() => dao.updateRecordingGrade('r1', 4)) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.gradeRecording('r1', 4); + + verify(() => dao.updateRecordingGrade('r1', 4)).called(1); + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('deleteRecording calls dao', () async { + when(() => dao.deleteRecording('r1')).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.deleteRecording('r1'); + + verify(() => dao.deleteRecording('r1')).called(1); + await cubit.close(); + }); + + test('close cancels subscriptions and timer and disposes services', + () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + await cubit.startRecording(scriptId, 0); + + await cubit.close(); + + verify(() => recordingService.dispose()).called(1); + verify(() => playbackService.dispose()).called(1); + }); + + test('startRecording is no-op when not in loaded state', () async { + final cubit = createCubit(); + + await cubit.startRecording(scriptId, 0); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('stopRecording is no-op when not recording', () async { + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.stopRecording(); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('position stream updates RecordingPlayback position', () async { + when(() => playbackService.play(any())).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.playRecording(testRecording); + positionController.add(const Duration(seconds: 2)); + await Future.delayed(Duration.zero); + + final state = cubit.state; + expect(state, isA()); + expect( + (state as RecordingPlayback).position, + const Duration(seconds: 2), + ); + await cubit.close(); + }); + + test('loadRecordings keeps error state while updating recordings', + () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => false); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + final state = cubit.state; + expect(state, isA()); + expect((state as RecordingError).recordings, [testRecording]); + await cubit.close(); + }); + + test('loadRecordings keeps in-progress state while updating recordings', + () async { + when(() => recordingService.hasPermission()) + .thenAnswer((_) async => true); + when(() => recordingService.startRecording(any())) + .thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([]); + await Future.delayed(Duration.zero); + + await cubit.startRecording(scriptId, 0); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + final state = cubit.state; + expect(state, isA()); + expect((state as RecordingInProgress).recordings, [testRecording]); + await cubit.close(); + }); + + test('loadRecordings keeps playback state while updating recordings', + () async { + when(() => playbackService.play(any())).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.playRecording(testRecording); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('loadRecordings keeps grading state while updating recordings', + () async { + when(() => playbackService.play(any())).thenAnswer((_) async {}); + final cubit = createCubit()..loadRecordings(scriptId); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + await cubit.playRecording(testRecording); + statusController.add(PlaybackStatus.completed); + await Future.delayed(Duration.zero); + recordingsController.add([testRecording]); + await Future.delayed(Duration.zero); + + expect(cubit.state, isA()); + await cubit.close(); + }); + + test('RecordingState equality', () { + const initial = RecordingInitial(); + expect(initial.recordings, isEmpty); + expect(initial.props, isEmpty); + expect(initial, const RecordingInitial()); + expect( + RecordingIdle(recordings: [testRecording]), + RecordingIdle(recordings: [testRecording]), + ); + }); + }); +} diff --git a/horatio/horatio_app/test/bloc/text_scale_cubit_test.dart b/horatio/horatio_app/test/bloc/text_scale_cubit_test.dart new file mode 100644 index 0000000..ba3f910 --- /dev/null +++ b/horatio/horatio_app/test/bloc/text_scale_cubit_test.dart @@ -0,0 +1,100 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_state.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('TextScaleCubit', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('initial state has scaleFactor 1.0', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs); + expect(cubit.state, const TextScaleState(scaleFactor: 1)); + await cubit.close(); + }); + + test('loadScale reads saved value', () async { + SharedPreferences.setMockInitialValues({'text_scale_factor': 2.0}); + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs)..loadScale(); + await Future.delayed(Duration.zero); + expect(cubit.state, const TextScaleState(scaleFactor: 2)); + await cubit.close(); + }); + + test('loadScale uses 1.0 when no saved value', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs)..loadScale(); + await Future.delayed(Duration.zero); + expect(cubit.state, const TextScaleState(scaleFactor: 1)); + await cubit.close(); + }); + + test('setScale persists and emits', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs); + await cubit.setScale(1.8); + expect(cubit.state, const TextScaleState(scaleFactor: 1.8)); + expect(prefs.getDouble('text_scale_factor'), 1.8); + await cubit.close(); + }); + + test('autoDetect sets 1.5 for 4K desktop', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs) + ..autoDetect(const Size(1920, 1080), 2, isDesktop: true); + expect(cubit.state, const TextScaleState(scaleFactor: 1.5)); + await cubit.close(); + }); + + test('autoDetect sets 1.0 for non-4K', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs) + ..autoDetect(const Size(1920, 1080), 1, isDesktop: true); + expect(cubit.state, const TextScaleState(scaleFactor: 1)); + await cubit.close(); + }); + + test('autoDetect sets 1.0 for mobile even at high resolution', () async { + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs) + ..autoDetect(const Size(1920, 1080), 2, isDesktop: false); + expect(cubit.state, const TextScaleState(scaleFactor: 1)); + await cubit.close(); + }); + + test('autoDetect skips when preference already saved', () async { + SharedPreferences.setMockInitialValues({'text_scale_factor': 2.5}); + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs)..loadScale(); + await Future.delayed(Duration.zero); + cubit.autoDetect(const Size(1920, 1080), 2, isDesktop: true); + expect(cubit.state, const TextScaleState(scaleFactor: 2.5)); + await cubit.close(); + }); + + test('resetToAuto clears preference and resets to default', () async { + SharedPreferences.setMockInitialValues({'text_scale_factor': 2.5}); + final prefs = await SharedPreferences.getInstance(); + final cubit = TextScaleCubit(prefs: prefs)..loadScale(); + await Future.delayed(Duration.zero); + await cubit.resetToAuto(); + expect(prefs.containsKey('text_scale_factor'), isFalse); + expect(cubit.state, const TextScaleState(scaleFactor: 1)); + await cubit.close(); + }); + + test('TextScaleState equality', () { + const a = TextScaleState(scaleFactor: 1); + const b = TextScaleState(scaleFactor: 1); + const c = TextScaleState(scaleFactor: 2); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); +} diff --git a/horatio/horatio_app/test/database/annotation_dao_test.dart b/horatio/horatio_app/test/database/annotation_dao_test.dart index 03bae32..ec4d958 100644 --- a/horatio/horatio_app/test/database/annotation_dao_test.dart +++ b/horatio/horatio_app/test/database/annotation_dao_test.dart @@ -23,29 +23,27 @@ void main() { 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), - ); + }) => 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), - ); + }) => LineNote( + id: id, + lineIndex: lineIndex, + category: category, + text: text, + createdAt: DateTime.utc(2026, 3, 29), + ); group('TextMark CRUD', () { test('insertMark and getMarksForLine', () async { @@ -65,13 +63,7 @@ void main() { test('watchMarksForScript emits on insert', () async { final stream = dao.watchMarksForScript(scriptId); - final future = expectLater( - stream, - emitsInOrder([ - isEmpty, - hasLength(1), - ]), - ); + final future = expectLater(stream, emitsInOrder([isEmpty, hasLength(1)])); await Future.delayed(Duration.zero); await dao.insertMark(scriptId, makeMark()); await future; @@ -101,6 +93,13 @@ void main() { expect(notes.first.text, 'updated text'); }); + test('updateNoteCategory modifies category', () async { + await dao.insertNote(scriptId, makeNote()); + await dao.updateNoteCategory('n1', NoteCategory.emotion); + final notes = await dao.getNotesForLine(scriptId, 0); + expect(notes.first.category, NoteCategory.emotion); + }); + test('deleteNote removes note', () async { await dao.insertNote(scriptId, makeNote()); await dao.deleteNote('n1'); @@ -110,10 +109,7 @@ void main() { test('watchNotesForScript emits on insert', () async { final stream = dao.watchNotesForScript(scriptId); - final future = expectLater( - stream, - emitsInOrder([isEmpty, hasLength(1)]), - ); + final future = expectLater(stream, emitsInOrder([isEmpty, hasLength(1)])); await Future.delayed(Duration.zero); await dao.insertNote(scriptId, makeNote()); await future; @@ -130,10 +126,7 @@ void main() { notes: [makeNote()], ); final stream = dao.watchSnapshotsForScript(scriptId); - final future = expectLater( - stream, - emitsInOrder([isEmpty, hasLength(1)]), - ); + final future = expectLater(stream, emitsInOrder([isEmpty, hasLength(1)])); await Future.delayed(Duration.zero); await dao.insertSnapshot(snapshot); await future; @@ -162,11 +155,7 @@ void main() { test('does not affect other scripts', () async { await dao.insertMark('other-script', makeMark(id: 'keep-m')); - await dao.replaceAllAnnotations( - scriptId: scriptId, - marks: [], - notes: [], - ); + await dao.replaceAllAnnotations(scriptId: scriptId, marks: [], notes: []); final marks = await dao.getMarksForLine('other-script', 0); expect(marks.length, 1); expect(marks.first.id, 'keep-m'); diff --git a/horatio/horatio_app/test/database/app_database_test.dart b/horatio/horatio_app/test/database/app_database_test.dart new file mode 100644 index 0000000..9768f6e --- /dev/null +++ b/horatio/horatio_app/test/database/app_database_test.dart @@ -0,0 +1,42 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/database/app_database.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockMigrator extends Mock implements Migrator {} + +void main() { + group('AppDatabase', () { + test('schema version is 2', () { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + expect(db.schemaVersion, 2); + }); + + test('migration from v1 creates lineRecordingsTable', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + final migrator = _MockMigrator(); + when( + () => migrator.createTable(db.lineRecordingsTable), + ).thenAnswer((_) async {}); + + final onUpgrade = db.migration.onUpgrade; + await onUpgrade(migrator, 1, 2); + + verify(() => migrator.createTable(db.lineRecordingsTable)).called(1); + }); + + test('migration from v2 does not create lineRecordingsTable', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + final migrator = _MockMigrator(); + + final onUpgrade = db.migration.onUpgrade; + await onUpgrade(migrator, 2, 2); + + verifyNever(() => migrator.createTable(db.lineRecordingsTable)); + }); + }); +} diff --git a/horatio/horatio_app/test/database/recording_dao_test.dart b/horatio/horatio_app/test/database/recording_dao_test.dart new file mode 100644 index 0000000..79a4ae4 --- /dev/null +++ b/horatio/horatio_app/test/database/recording_dao_test.dart @@ -0,0 +1,80 @@ +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/recording_dao.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + late AppDatabase db; + late RecordingDao dao; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + dao = db.recordingDao; + }); + + tearDown(() => db.close()); + + final recording = LineRecording( + id: 'r1', + scriptId: 's1', + lineIndex: 0, + filePath: '/path/to/file.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ); + + group('RecordingDao', () { + test('insert and watch recordings', () async { + await dao.insertRecording('s1', recording); + final stream = dao.watchRecordingsForScript('s1'); + final recordings = await stream.first; + expect(recordings, hasLength(1)); + expect(recordings.first.id, 'r1'); + expect(recordings.first.filePath, '/path/to/file.m4a'); + }); + + test('delete recording', () async { + await dao.insertRecording('s1', recording); + await dao.deleteRecording('r1'); + final recordings = await dao.watchRecordingsForScript('s1').first; + expect(recordings, isEmpty); + }); + + test('update grade', () async { + await dao.insertRecording('s1', recording); + await dao.updateRecordingGrade('r1', 4); + final recordings = await dao.watchRecordingsForScript('s1').first; + expect(recordings.first.grade, 4); + }); + + test('update grade to null', () async { + await dao.insertRecording('s1', recording); + await dao.updateRecordingGrade('r1', 4); + await dao.updateRecordingGrade('r1', null); + final recordings = await dao.watchRecordingsForScript('s1').first; + expect(recordings.first.grade, isNull); + }); + + test('watch returns empty for unknown script', () async { + final recordings = await dao.watchRecordingsForScript('unknown').first; + expect(recordings, isEmpty); + }); + + test('recordings ordered by lineIndex', () async { + final r2 = LineRecording( + id: 'r2', + scriptId: 's1', + lineIndex: 5, + filePath: '/p2.m4a', + durationMs: 1000, + createdAt: DateTime.utc(2026), + ); + await dao.insertRecording('s1', r2); + await dao.insertRecording('s1', recording); + final recordings = await dao.watchRecordingsForScript('s1').first; + expect(recordings[0].lineIndex, 0); + expect(recordings[1].lineIndex, 5); + }); + }); +} diff --git a/horatio/horatio_app/test/router_test.dart b/horatio/horatio_app/test/router_test.dart index 9f4116f..c1497bf 100644 --- a/horatio/horatio_app/test/router_test.dart +++ b/horatio/horatio_app/test/router_test.dart @@ -6,26 +6,66 @@ 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/database/daos/recording_dao.dart'; import 'package:horatio_app/router.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.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 {} +class _MockRecordingDao extends Mock implements RecordingDao {} + +class _MockRecordingService extends Mock implements RecordingService {} + +class _MockAudioPlaybackService extends Mock implements AudioPlaybackService {} + 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([])); + final mockRecordingDao = _MockRecordingDao(); + final mockRecordingService = _MockRecordingService(); + final mockPlaybackService = _MockAudioPlaybackService(); + when( + () => mockDao.watchMarksForScript(any()), + ).thenAnswer((_) => Stream.value([])); + when( + () => mockDao.watchNotesForScript(any()), + ).thenAnswer((_) => Stream.value([])); + when( + () => mockDao.watchSnapshotsForScript(any()), + ).thenAnswer((_) => Stream.value([])); + when( + () => mockRecordingDao.watchRecordingsForScript(any()), + ).thenAnswer((_) => Stream.value([])); + + when( + () => mockRecordingService.hasPermission(), + ).thenAnswer((_) async => true); + when( + () => mockRecordingService.startRecording(any()), + ).thenAnswer((_) async {}); + when( + () => mockRecordingService.stopRecording(), + ).thenAnswer((_) async => null); + + when(() => mockPlaybackService.play(any())).thenAnswer((_) async {}); + when(() => mockPlaybackService.stop()).thenAnswer((_) async {}); + when(() => mockPlaybackService.status).thenAnswer((_) => Stream.empty()); + when(() => mockPlaybackService.position).thenAnswer((_) => Stream.empty()); + return MultiRepositoryProvider( providers: [ RepositoryProvider(create: (_) => repository), RepositoryProvider.value(value: mockDao), + RepositoryProvider.value(value: mockRecordingDao), + RepositoryProvider.value(value: mockRecordingService), + RepositoryProvider.value( + value: mockPlaybackService, + ), + RepositoryProvider.value(value: '/tmp/test_recordings'), ], child: MultiBlocProvider( providers: [ @@ -92,21 +132,18 @@ void main() { scenes: [ Scene( lines: [ - ScriptLine( - text: 'Hi.', - role: role, - sceneIndex: 0, - lineIndex: 0, - ), + ScriptLine(text: 'Hi.', role: role, sceneIndex: 0, lineIndex: 0), ], ), ], ); - unawaited(appRouter.push( - RoutePaths.schedule, - extra: {'script': script, 'role': role}, - )); + unawaited( + appRouter.push( + RoutePaths.schedule, + extra: {'script': script, 'role': role}, + ), + ); await tester.pumpAndSettle(); expect(find.text('Memorization Schedule'), findsOneWidget); @@ -124,21 +161,18 @@ void main() { scenes: [ Scene( lines: [ - ScriptLine( - text: 'A.', - role: role, - sceneIndex: 0, - lineIndex: 0, - ), + ScriptLine(text: 'A.', role: role, sceneIndex: 0, lineIndex: 0), ], ), ], ); - unawaited(appRouter.push( - RoutePaths.rehearsal, - extra: {'script': script, 'role': role}, - )); + unawaited( + appRouter.push( + RoutePaths.rehearsal, + extra: {'script': script, 'role': role}, + ), + ); await tester.pumpAndSettle(); expect(find.text('Rehearsing: Hero'), findsOneWidget); @@ -148,9 +182,7 @@ void main() { await tester.pumpWidget(_wrapRouter()); await tester.pumpAndSettle(); - final cards = [ - SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans'), - ]; + final cards = [SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans')]; unawaited(appRouter.push(RoutePaths.srsReview, extra: cards)); await tester.pumpAndSettle(); @@ -169,8 +201,9 @@ void main() { expect(find.text('Not Found'), findsOneWidget); }); - testWidgets('schedule route with wrong extra type falls back', - (tester) async { + testWidgets('schedule route with wrong extra type falls back', ( + tester, + ) async { await tester.pumpWidget(_wrapRouter()); await tester.pumpAndSettle(); @@ -182,8 +215,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('annotations route with Script extra shows editor', - (tester) async { + testWidgets('annotations route with Script extra shows editor', ( + tester, + ) async { await tester.pumpWidget(_wrapRouter()); await tester.pumpAndSettle(); @@ -216,8 +250,9 @@ void main() { expect(find.text('Annotate: Annotate Play'), findsOneWidget); }); - testWidgets('annotations route with null extra redirects home', - (tester) async { + testWidgets('annotations route with null extra redirects home', ( + tester, + ) async { await tester.pumpWidget(_wrapRouter()); await tester.pumpAndSettle(); @@ -228,8 +263,9 @@ void main() { expect(find.text('Horatio'), findsOneWidget); }); - testWidgets('annotation-history route with Script extra shows history', - (tester) async { + testWidgets('annotation-history route with Script extra shows history', ( + tester, + ) async { await tester.pumpWidget(_wrapRouter()); await tester.pumpAndSettle(); @@ -252,16 +288,15 @@ void main() { ], ); - unawaited( - appRouter.push(RoutePaths.annotationHistory, extra: script), - ); + 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 { + testWidgets('annotation-history route with null extra redirects home', ( + tester, + ) async { await tester.pumpWidget(_wrapRouter()); await tester.pumpAndSettle(); diff --git a/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart b/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart index 1fedf2d..8dc070c 100644 --- a/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart +++ b/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart @@ -4,74 +4,144 @@ 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/bloc/recording/recording_state.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; import 'package:horatio_app/database/daos/annotation_dao.dart'; +import 'package:horatio_app/database/daos/recording_dao.dart'; import 'package:horatio_app/screens/annotation_editor_screen.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:horatio_app/services/recording_service.dart'; +import 'package:horatio_app/widgets/mark_overlay.dart'; +import 'package:horatio_app/widgets/mark_selection_toolbar.dart'; +import 'package:horatio_app/widgets/note_chip.dart'; +import 'package:horatio_app/widgets/recording_action_bar.dart'; +import 'package:horatio_app/widgets/recording_badge.dart'; +import 'package:horatio_app/widgets/text_scale_settings_sheet.dart'; import 'package:horatio_core/horatio_core.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class MockAnnotationDao extends Mock implements AnnotationDao {} +class MockRecordingDao extends Mock implements RecordingDao {} + +class MockRecordingService extends Mock implements RecordingService {} + +class MockAudioPlaybackService extends Mock implements AudioPlaybackService {} + 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, - ), - ], + 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 MockRecordingDao _recordingDao; +late MockRecordingService _recordingService; +late MockAudioPlaybackService _playbackService; late StreamController> _marksCtrl; late StreamController> _notesCtrl; +late StreamController> _recordingsCtrl; late StreamController> _snapshotsCtrl; +late TextScaleCubit _textScaleCubit; void _setUpDao() { _dao = MockAnnotationDao(); _marksCtrl = StreamController>.broadcast(); _notesCtrl = StreamController>.broadcast(); + _recordingsCtrl = StreamController>.broadcast(); _snapshotsCtrl = StreamController>.broadcast(); + _recordingDao = MockRecordingDao(); + _recordingService = MockRecordingService(); + _playbackService = MockAudioPlaybackService(); - when(() => _dao.watchMarksForScript(any())) - .thenAnswer((_) => _marksCtrl.stream); - when(() => _dao.watchNotesForScript(any())) - .thenAnswer((_) => _notesCtrl.stream); - when(() => _dao.watchSnapshotsForScript(any())) - .thenAnswer((_) => _snapshotsCtrl.stream); + when( + () => _dao.watchMarksForScript(any()), + ).thenAnswer((_) => _marksCtrl.stream); + when( + () => _dao.watchNotesForScript(any()), + ).thenAnswer((_) => _notesCtrl.stream); + when( + () => _recordingDao.watchRecordingsForScript(any()), + ).thenAnswer((_) => _recordingsCtrl.stream); + when( + () => _dao.watchSnapshotsForScript(any()), + ).thenAnswer((_) => _snapshotsCtrl.stream); when(() => _dao.insertSnapshot(any())).thenAnswer((_) async {}); when(() => _dao.insertMark(any(), any())).thenAnswer((_) async {}); + when(() => _dao.deleteMark(any())).thenAnswer((_) async {}); when(() => _dao.insertNote(any(), any())).thenAnswer((_) async {}); + when(() => _dao.deleteNote(any())).thenAnswer((_) async {}); + when(() => _dao.updateNoteText(any(), any())).thenAnswer((_) async {}); + when(() => _dao.updateNoteCategory(any(), any())).thenAnswer((_) async {}); + + when(() => _recordingService.hasPermission()).thenAnswer((_) async => true); + when(() => _recordingService.startRecording(any())).thenAnswer((_) async {}); + when( + () => _recordingService.stopRecording(), + ).thenAnswer((_) async => '/tmp/test_recordings/fake.m4a'); + when( + () => _recordingDao.insertRecording(any(), any()), + ).thenAnswer((_) async {}); + when(() => _recordingDao.deleteRecording(any())).thenAnswer((_) async {}); + when( + () => _recordingDao.updateRecordingGrade(any(), any()), + ).thenAnswer((_) async {}); + + when(() => _playbackService.play(any())).thenAnswer((_) async {}); + when(() => _playbackService.stop()).thenAnswer((_) async {}); + when(() => _playbackService.status).thenAnswer((_) => Stream.empty()); + when(() => _playbackService.position).thenAnswer((_) => Stream.empty()); +} + +Future _initTextScale() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + _textScaleCubit = TextScaleCubit(prefs: prefs); } void _tearDownStreams() { _marksCtrl.close(); _notesCtrl.close(); + _recordingsCtrl.close(); _snapshotsCtrl.close(); + _textScaleCubit.close(); } -Widget _buildScreen(Script script) => RepositoryProvider.value( - value: _dao, - child: MaterialApp( - home: AnnotationEditorScreen(script: script), - ), - ); +Widget _buildScreen(Script script) => BlocProvider.value( + value: _textScaleCubit, + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: _dao), + RepositoryProvider.value(value: _recordingDao), + RepositoryProvider.value(value: _recordingService), + RepositoryProvider.value(value: _playbackService), + RepositoryProvider.value(value: '/tmp/test_recordings'), + ], + child: MaterialApp(home: AnnotationEditorScreen(script: script)), + ), +); Widget _buildScreenWithRouter(Script script) { final router = GoRouter( @@ -79,10 +149,7 @@ Widget _buildScreenWithRouter(Script script) { routes: [ GoRoute( path: '/annotations', - builder: (context, state) => RepositoryProvider.value( - value: _dao, - child: AnnotationEditorScreen(script: script), - ), + builder: (context, state) => AnnotationEditorScreen(script: script), ), GoRoute( path: '/annotation-history', @@ -91,7 +158,19 @@ Widget _buildScreenWithRouter(Script script) { ), ], ); - return MaterialApp.router(routerConfig: router); + return BlocProvider.value( + value: _textScaleCubit, + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: _dao), + RepositoryProvider.value(value: _recordingDao), + RepositoryProvider.value(value: _recordingService), + RepositoryProvider.value(value: _playbackService), + RepositoryProvider.value(value: '/tmp/test_recordings'), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); } void main() { @@ -124,10 +203,24 @@ void main() { createdAt: DateTime.utc(2026), ), ); + registerFallbackValue( + LineRecording( + id: 'fb-rec', + scriptId: 'fb-script', + lineIndex: 0, + filePath: '/tmp/fb.m4a', + durationMs: 1000, + createdAt: DateTime.utc(2026), + ), + ); + registerFallbackValue(NoteCategory.general); }); group('AnnotationEditorScreen', () { - setUp(_setUpDao); + setUp(() async { + await _initTextScale(); + _setUpDao(); + }); tearDown(_tearDownStreams); testWidgets('shows loading indicator in initial state', (tester) async { @@ -149,10 +242,7 @@ void main() { find.text('To be or not to be.', findRichText: true), findsOneWidget, ); - expect( - find.text('Indeed, my lord.', findRichText: true), - findsOneWidget, - ); + expect(find.text('Indeed, my lord.', findRichText: true), findsOneWidget); }); testWidgets('lines with marks show colored overlay', (tester) async { @@ -177,8 +267,7 @@ void main() { expect(find.text('To be or not to be.'), findsNothing); }); - testWidgets('lines with notes show note indicator badge', - (tester) async { + testWidgets('lines with notes show note indicator badge', (tester) async { final script = _testScript(); await tester.pumpWidget(_buildScreen(script)); _marksCtrl.add([]); @@ -205,9 +294,7 @@ void main() { _snapshotsCtrl.add([]); await tester.pumpAndSettle(); - await tester.tap( - find.text('To be or not to be.', findRichText: true), - ); + 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. @@ -232,8 +319,7 @@ void main() { expect(find.text('History Screen'), findsOneWidget); }); - testWidgets('long-press on a line shows mark type picker', - (tester) async { + testWidgets('selected line shows SelectableText', (tester) async { final script = _testScript(); await tester.pumpWidget(_buildScreen(script)); _marksCtrl.add([]); @@ -241,18 +327,16 @@ void main() { _snapshotsCtrl.add([]); await tester.pumpAndSettle(); - await tester.longPress( - find.text('To be or not to be.', findRichText: true), - ); - await tester.pumpAndSettle(); + // Tap to select the first line. + await tester.tap(find.text('To be or not to be.', findRichText: true)); + await tester.pump(); - expect(find.text('Add Mark'), findsOneWidget); - expect(find.text('Stress'), findsOneWidget); - expect(find.text('Pause'), findsOneWidget); + expect(find.byType(SelectableText), findsOneWidget); }); - testWidgets('selecting mark type in picker calls addMark', - (tester) async { + testWidgets('unselected line shows MarkOverlay not SelectableText', ( + tester, + ) async { final script = _testScript(); await tester.pumpWidget(_buildScreen(script)); _marksCtrl.add([]); @@ -260,38 +344,521 @@ void main() { _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); + // No line selected — should be MarkOverlay. + expect(find.byType(SelectableText), findsNothing); + expect(find.byType(MarkOverlay), findsWidgets); }); - testWidgets('cancel in mark picker dismisses dialog', (tester) async { + testWidgets('shows recording action bar when line selected', ( + tester, + ) async { final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([]); + _recordingsCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('To be or not to be.', findRichText: true)); + await tester.pumpAndSettle(); + + expect(find.byType(RecordingActionBar), findsOneWidget); + expect(find.byIcon(Icons.mic), findsOneWidget); + }); + + testWidgets('hides recording action bar when no line selected', ( + tester, + ) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([]); + _recordingsCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + expect(find.byType(RecordingActionBar), findsNothing); + }); + + testWidgets('recording action bar record and stop invoke services', ( + tester, + ) async { + final script = _testScript(); + when( + () => _recordingDao.watchRecordingsForScript(any()), + ).thenAnswer((_) => Stream.value([])); + 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.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.mic)); + await tester.pump(); + + verify(() => _recordingService.startRecording(any())).called(1); + expect(find.byIcon(Icons.stop), findsOneWidget); + + await tester.tap(find.byIcon(Icons.stop)); + await tester.pumpAndSettle(); + + verify(() => _recordingService.stopRecording()).called(1); + verify(() => _recordingDao.insertRecording(any(), any())).called(1); + }); + + testWidgets('recording action bar play uses latest recording', ( + tester, + ) async { + final script = _testScript(); + when(() => _recordingDao.watchRecordingsForScript(any())).thenAnswer( + (_) => Stream.value([ + LineRecording( + id: 'r1', + scriptId: 'editor-screen-test', + lineIndex: 0, + filePath: '/tmp/rec.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ), + ]), + ); + 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.tap(find.text('To be or not to be.', findRichText: true)); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.play_arrow)); + await tester.pumpAndSettle(); + + verify(() => _playbackService.play('/tmp/rec.m4a')).called(1); + }); + + testWidgets('shows note chips for selected line', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([ + LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.general, + text: 'A test note', + createdAt: DateTime.utc(2026), + ), + ]); + _recordingsCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('To be or not to be.', findRichText: true)); + await tester.pumpAndSettle(); + + expect(find.byType(NoteChip), findsOneWidget); + expect(find.text('A test note'), findsOneWidget); + }); + + testWidgets('recording badge shows count for line', (tester) async { + final script = _testScript(); + when(() => _recordingDao.watchRecordingsForScript(any())).thenAnswer( + (_) => Stream.value([ + LineRecording( + id: 'r1', + scriptId: 'editor-screen-test', + lineIndex: 0, + filePath: '/tmp/rec.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ), + ]), ); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + expect(find.byType(RecordingBadge), findsNWidgets(2)); + final badges = tester.widgetList( + find.byType(RecordingBadge), + ); + expect(badges.any((badge) => badge.recordingCount == 1), isTrue); + expect(find.text('1'), findsOneWidget); + }); + + testWidgets('tapping recording badge opens list and plays recording', ( + tester, + ) async { + final script = _testScript(); + when(() => _recordingDao.watchRecordingsForScript(any())).thenAnswer( + (_) => Stream.value([ + LineRecording( + id: 'r1', + scriptId: 'editor-screen-test', + lineIndex: 0, + filePath: '/tmp/rec.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ), + ]), + ); + + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('1').first); + await tester.pumpAndSettle(); + + expect(find.text('Recordings'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.play_arrow).first); + await tester.pumpAndSettle(); + + verify(() => _playbackService.play('/tmp/rec.m4a')).called(1); + }); + + testWidgets('recording list grade and delete actions call dao', ( + tester, + ) async { + final script = _testScript(); + when(() => _recordingDao.watchRecordingsForScript(any())).thenAnswer( + (_) => Stream.value([ + LineRecording( + id: 'r1', + scriptId: 'editor-screen-test', + lineIndex: 0, + filePath: '/tmp/rec.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ), + ]), + ); + + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('1').first); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Blackout').first); + await tester.pumpAndSettle(); + + verify(() => _recordingDao.updateRecordingGrade('r1', 0)).called(1); + + await tester.tap(find.byIcon(Icons.delete).first); + await tester.pumpAndSettle(); + + verify(() => _recordingDao.deleteRecording('r1')).called(1); + }); + + testWidgets('long-press note chip calls removeNote', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + final note = LineNote( + id: 'n-del', + lineIndex: 0, + category: NoteCategory.general, + text: 'Delete me', + createdAt: DateTime.utc(2026), + ); + + _marksCtrl.add([]); + _notesCtrl.add([note]); + _recordingsCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('To be or not to be.', findRichText: true)); + await tester.pumpAndSettle(); + + await tester.longPress(find.byType(NoteChip)); + await tester.pumpAndSettle(); + + verify(() => _dao.deleteNote('n-del')).called(1); + }); + + testWidgets('tapping note chip opens editor and saves updates', ( + tester, + ) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([ + LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'old text', + createdAt: DateTime.utc(2026), + ), + ]); + _recordingsCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('To be or not to be.', findRichText: true)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(NoteChip)); + await tester.pumpAndSettle(); + + expect(find.text('old text'), findsWidgets); + + await tester.enterText(find.byType(TextFormField), 'new text'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + verify(() => _dao.updateNoteText('n1', 'new text')).called(1); + verify( + () => _dao.updateNoteCategory('n1', NoteCategory.intention), + ).called(1); + }); + + testWidgets('tapping add-note icon opens note editor', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([]); + _recordingsCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.note_add_outlined).first); + await tester.pumpAndSettle(); + + expect(find.text('Category'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('editing note sheet cancel dismisses without updates', ( + tester, + ) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + + _marksCtrl.add([]); + _notesCtrl.add([ + LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'old text', + createdAt: DateTime.utc(2026), + ), + ]); + _recordingsCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('To be or not to be.', findRichText: true)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(NoteChip)); await tester.pumpAndSettle(); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); - expect(find.text('Add Mark'), findsNothing); + verifyNever(() => _dao.updateNoteText(any(), any())); + verifyNever(() => _dao.updateNoteCategory(any(), any())); }); - testWidgets('tapping note indicator shows note editor sheet', - (tester) async { + testWidgets('text selection shows toolbar and applies selected mark', ( + 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(); + + final selectable = tester.widget( + find.byType(SelectableText), + ); + selectable.onSelectionChanged!( + const TextSelection(baseOffset: 0, extentOffset: 5), + SelectionChangedCause.tap, + ); + await tester.pump(); + + expect(find.byType(MarkSelectionToolbar), findsOneWidget); + await tester.tap(find.text('Stress')); + await tester.pumpAndSettle(); + + verify(() => _dao.insertMark(any(), any())).called(1); + expect(find.byType(MarkSelectionToolbar), findsNothing); + }); + + testWidgets('collapsed selection does not show toolbar', (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(); + + final selectable = tester.widget( + find.byType(SelectableText), + ); + selectable.onSelectionChanged!( + const TextSelection.collapsed(offset: 0), + SelectionChangedCause.tap, + ); + await tester.pump(); + + expect(find.byType(MarkSelectionToolbar), findsNothing); + }); + + testWidgets('switching selected line removes toolbar overlay', ( + 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(); + + final selectable = tester.widget( + find.byType(SelectableText), + ); + selectable.onSelectionChanged!( + const TextSelection(baseOffset: 0, extentOffset: 5), + SelectionChangedCause.tap, + ); + await tester.pump(); + expect(find.byType(MarkSelectionToolbar), findsOneWidget); + + await tester.tap(find.text('Indeed, my lord.', findRichText: true)); + await tester.pumpAndSettle(); + + expect(find.byType(MarkSelectionToolbar), findsNothing); + }); + + testWidgets('selected line supports marks with unmarked prefix', ( + tester, + ) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + final mark = TextMark( + id: 'prefix-mark', + lineIndex: 0, + startOffset: 2, + endOffset: 7, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ); + _marksCtrl.add([mark]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('To be or not to be.', findRichText: true)); + await tester.pump(); + + expect(find.byType(SelectableText), findsOneWidget); + }); + + testWidgets('tapping a marked span shows remove dialog and No dismisses', ( + tester, + ) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + final mark = TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ); + _marksCtrl.add([mark]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + // Tap the line to select it. + await tester.tap(find.text('To be or not to be.', findRichText: true)); + await tester.pump(); + + // Tap the colored span area to trigger mark removal dialog. + final textTopLeft = tester.getTopLeft(find.byType(SelectableText)); + await tester.tapAt(textTopLeft + const Offset(24, 12)); + await tester.pump(); + + expect(find.text('Remove mark?'), findsOneWidget); + await tester.tap(find.text('No')); + await tester.pumpAndSettle(); + + verifyNever(() => _dao.deleteMark(any())); + expect(find.text('Remove mark?'), findsNothing); + }); + + testWidgets('confirming remove dialog calls removeMark', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + final mark = TextMark( + id: 'm1', + lineIndex: 0, + startOffset: 0, + endOffset: 5, + type: MarkType.stress, + createdAt: DateTime.utc(2026), + ); + _marksCtrl.add([mark]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + + await tester.tap(find.text('To be or not to be.', findRichText: true)); + await tester.pump(); + + final textTopLeft = tester.getTopLeft(find.byType(SelectableText)); + await tester.tapAt(textTopLeft + const Offset(24, 12)); + await tester.pump(); + + expect(find.text('Remove mark?'), findsOneWidget); + await tester.tap(find.text('Yes')); + await tester.pumpAndSettle(); + + verify(() => _dao.deleteMark('m1')).called(1); + }); + + testWidgets('tapping note indicator shows note editor sheet', ( + tester, + ) async { final script = _testScript(); await tester.pumpWidget(_buildScreen(script)); _marksCtrl.add([]); @@ -391,5 +958,17 @@ void main() { await tester.pumpWidget(_buildScreen(script)); expect(find.text('Annotate: Test Play'), findsOneWidget); }); + + testWidgets('text size button opens settings sheet', (tester) async { + final script = _testScript(); + await tester.pumpWidget(_buildScreen(script)); + _marksCtrl.add([]); + _notesCtrl.add([]); + _snapshotsCtrl.add([]); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.text_fields)); + await tester.pumpAndSettle(); + expect(find.byType(TextScaleSettingsSheet), findsOneWidget); + }); }); } diff --git a/horatio/horatio_app/test/screens/home_screen_test.dart b/horatio/horatio_app/test/screens/home_screen_test.dart index faa0a4c..7cca377 100644 --- a/horatio/horatio_app/test/screens/home_screen_test.dart +++ b/horatio/horatio_app/test/screens/home_screen_test.dart @@ -8,14 +8,19 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:horatio_app/bloc/script_import/script_import_cubit.dart'; import 'package:horatio_app/bloc/script_import/script_import_state.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; import 'package:horatio_app/screens/home_screen.dart'; import 'package:horatio_app/services/script_repository.dart'; +import 'package:horatio_app/widgets/text_scale_settings_sheet.dart'; import 'package:horatio_core/horatio_core.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class MockScriptImportCubit extends MockCubit implements ScriptImportCubit {} +late TextScaleCubit _textScaleCubit; + Widget _wrap(ScriptImportCubit cubit) { final router = GoRouter( initialLocation: '/', @@ -30,14 +35,17 @@ Widget _wrap(ScriptImportCubit cubit) { ), ], ); - return MultiRepositoryProvider( + return MultiBlocProvider( providers: [ - RepositoryProvider( - create: (_) => ScriptRepository(), - ), + BlocProvider.value(value: cubit), + BlocProvider.value(value: _textScaleCubit), ], - child: BlocProvider.value( - value: cubit, + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (_) => ScriptRepository(), + ), + ], child: MaterialApp.router(routerConfig: router), ), ); @@ -46,7 +54,10 @@ Widget _wrap(ScriptImportCubit cubit) { void main() { late MockScriptImportCubit cubit; - setUp(() { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + _textScaleCubit = TextScaleCubit(prefs: prefs); cubit = MockScriptImportCubit(); when(() => cubit.loadScripts()).thenReturn(null); when(() => cubit.importFromFile()).thenAnswer((_) async {}); @@ -57,6 +68,8 @@ void main() { )).thenAnswer((_) async {}); }); + tearDown(() => _textScaleCubit.close()); + setUpAll(() { registerFallbackValue(Uint8List(0)); }); @@ -400,4 +413,18 @@ void main() { expect(painter.shouldRepaint(painter), isFalse); }); }); + + group('HomeScreen text scale', () { + testWidgets('text size button opens settings sheet', (tester) async { + when(() => cubit.state).thenReturn(const ScriptImportInitial()); + + await tester.pumpWidget(_wrap(cubit)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.text_fields)); + await tester.pumpAndSettle(); + + expect(find.byType(TextScaleSettingsSheet), findsOneWidget); + }); + }); } diff --git a/horatio/horatio_app/test/services/audio_playback_service_test.dart b/horatio/horatio_app/test/services/audio_playback_service_test.dart new file mode 100644 index 0000000..58e8141 --- /dev/null +++ b/horatio/horatio_app/test/services/audio_playback_service_test.dart @@ -0,0 +1,115 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/services/audio_playback_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAudioPlayer extends Mock implements AudioPlayer {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockAudioPlayer mockPlayer; + late AudioPlaybackService service; + + setUpAll(() { + registerFallbackValue(DeviceFileSource('')); + }); + + setUp(() { + mockPlayer = MockAudioPlayer(); + service = AudioPlaybackService(player: mockPlayer); + when(() => mockPlayer.onPlayerStateChanged) + .thenAnswer((_) => const Stream.empty()); + when(() => mockPlayer.onPositionChanged) + .thenAnswer((_) => const Stream.empty()); + when(() => mockPlayer.dispose()).thenAnswer((_) async {}); + }); + + tearDown(() async { + await service.dispose(); + }); + + group('AudioPlaybackService', () { + test('constructor works with default AudioPlayer', () async { + final defaultService = AudioPlaybackService(); + await defaultService.dispose(); + }); + + test('play calls player.play with DeviceFileSource', () async { + when(() => mockPlayer.play(any())).thenAnswer((_) async {}); + + await service.play('/tmp/test.m4a'); + + verify(() => mockPlayer.play(any(that: isA()))) + .called(1); + }); + + test('stop calls player.stop', () async { + when(() => mockPlayer.stop()).thenAnswer((_) async {}); + + await service.stop(); + + verify(() => mockPlayer.stop()).called(1); + }); + + test('status stream maps player state changes', () async { + final controller = StreamController(); + when(() => mockPlayer.onPlayerStateChanged) + .thenAnswer((_) => controller.stream); + final service2 = AudioPlaybackService(player: mockPlayer); + + final statuses = []; + final sub = service2.status.listen(statuses.add); + + controller + ..add(PlayerState.playing) + ..add(PlayerState.completed) + ..add(PlayerState.stopped) + ..add(PlayerState.paused); + + await Future.delayed(Duration.zero); + expect(statuses, [ + PlaybackStatus.playing, + PlaybackStatus.completed, + PlaybackStatus.idle, + PlaybackStatus.idle, + ]); + + await sub.cancel(); + await controller.close(); + await service2.dispose(); + }); + + test('position stream delegates', () async { + final controller = StreamController(); + when(() => mockPlayer.onPositionChanged) + .thenAnswer((_) => controller.stream); + final service2 = AudioPlaybackService(player: mockPlayer); + + final positions = []; + final sub = service2.position.listen(positions.add); + + controller + ..add(const Duration(seconds: 1)) + ..add(const Duration(seconds: 2)); + + await Future.delayed(Duration.zero); + expect(positions, [ + const Duration(seconds: 1), + const Duration(seconds: 2), + ]); + + await sub.cancel(); + await controller.close(); + await service2.dispose(); + }); + + test('dispose calls player.dispose', () async { + await service.dispose(); + + verify(() => mockPlayer.dispose()).called(1); + }); + }); +} diff --git a/horatio/horatio_app/test/services/recording_service_test.dart b/horatio/horatio_app/test/services/recording_service_test.dart new file mode 100644 index 0000000..bcab90c --- /dev/null +++ b/horatio/horatio_app/test/services/recording_service_test.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/services/recording_service.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; + +class MockAudioRecorder extends Mock implements AudioRecorder {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockAudioRecorder mockRecorder; + late RecordingService service; + + setUpAll(() { + registerFallbackValue(const RecordConfig()); + }); + + setUp(() { + mockRecorder = MockAudioRecorder(); + service = RecordingService(recorder: mockRecorder); + when(() => mockRecorder.dispose()).thenAnswer((_) async {}); + }); + + tearDown(() => service.dispose()); + + group('RecordingService', () { + test('constructor works with default AudioRecorder', () async { + final defaultService = RecordingService(); + await defaultService.dispose(); + }); + + test('startRecording starts recording to path', () async { + when(() => mockRecorder.start(any(), path: any(named: 'path'))) + .thenAnswer((_) async {}); + when(() => mockRecorder.hasPermission()) + .thenAnswer((_) async => true); + + await service.startRecording('/tmp/test.m4a'); + + verify( + () => mockRecorder.start( + any(), + path: '/tmp/test.m4a', + ), + ).called(1); + }); + + test('startRecording creates missing parent directory', () async { + final baseDir = await Directory.systemTemp.createTemp('rec_service_'); + try { + final filePath = '${baseDir.path}/nested/line_0.m4a'; + when(() => mockRecorder.start(any(), path: any(named: 'path'))) + .thenAnswer((_) async {}); + + await service.startRecording(filePath); + + expect(Directory('${baseDir.path}/nested').existsSync(), isTrue); + verify(() => mockRecorder.start(any(), path: filePath)).called(1); + } finally { + if (baseDir.existsSync()) { + await baseDir.delete(recursive: true); + } + } + }); + + test('stopRecording stops and returns path', () async { + when(() => mockRecorder.stop()).thenAnswer((_) async => '/tmp/test.m4a'); + + final path = await service.stopRecording(); + + expect(path, '/tmp/test.m4a'); + }); + + test('hasPermission delegates', () async { + when(() => mockRecorder.hasPermission()) + .thenAnswer((_) async => true); + + expect(await service.hasPermission(), isTrue); + }); + + test('hasPermission returns false', () async { + when(() => mockRecorder.hasPermission()) + .thenAnswer((_) async => false); + + expect(await service.hasPermission(), isFalse); + }); + + test('dispose calls recorder dispose', () async { + when(() => mockRecorder.dispose()).thenAnswer((_) async {}); + + await service.dispose(); + + verify(() => mockRecorder.dispose()).called(1); + }); + }); +} diff --git a/horatio/horatio_app/test/widgets/grade_stars_test.dart b/horatio/horatio_app/test/widgets/grade_stars_test.dart new file mode 100644 index 0000000..c0892e9 --- /dev/null +++ b/horatio/horatio_app/test/widgets/grade_stars_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/grade_stars.dart'; + +void main() { + group('GradeStars', () { + testWidgets('shows 5 star icons and blackout button', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: null, onGrade: (_) {}), + ), + ), + ); + expect(find.byIcon(Icons.star_border), findsNWidgets(5)); + expect(find.text('Blackout'), findsOneWidget); + }); + + testWidgets('filled stars match grade', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: 3, onGrade: (_) {}), + ), + ), + ); + expect(find.byIcon(Icons.star), findsNWidgets(3)); + expect(find.byIcon(Icons.star_border), findsNWidgets(2)); + }); + + testWidgets('tapping star calls onGrade', (tester) async { + int? graded; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: null, onGrade: (g) => graded = g), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.star_border).at(3)); + + expect(graded, 4); + }); + + testWidgets('tapping blackout calls onGrade with 0', (tester) async { + int? graded; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: null, onGrade: (g) => graded = g), + ), + ), + ); + + await tester.tap(find.text('Blackout')); + + expect(graded, 0); + }); + + testWidgets('grade 0 highlights blackout button', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: 0, onGrade: (_) {}), + ), + ), + ); + + expect(find.byIcon(Icons.star_border), findsNWidgets(5)); + }); + + testWidgets('grade 5 fills all stars', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GradeStars(grade: 5, onGrade: (_) {}), + ), + ), + ); + + expect(find.byIcon(Icons.star), findsNWidgets(5)); + expect(find.byIcon(Icons.star_border), findsNothing); + }); + }); +} diff --git a/horatio/horatio_app/test/widgets/mark_selection_toolbar_test.dart b/horatio/horatio_app/test/widgets/mark_selection_toolbar_test.dart new file mode 100644 index 0000000..9d77a9f --- /dev/null +++ b/horatio/horatio_app/test/widgets/mark_selection_toolbar_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/mark_selection_toolbar.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + group('MarkSelectionToolbar', () { + testWidgets('shows 6 mark type chips', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkSelectionToolbar( + onMarkSelected: (_) {}, + onCancelled: () {}, + ), + ), + ), + ); + expect(find.byType(ActionChip), findsNWidgets(6)); + expect(find.text('Stress'), findsOneWidget); + expect(find.text('Pause'), findsOneWidget); + expect(find.text('Breath'), findsOneWidget); + expect(find.text('Emphasis'), findsOneWidget); + expect(find.text('Slow Down'), findsOneWidget); + expect(find.text('Speed Up'), findsOneWidget); + }); + + testWidgets('tapping chip calls onMarkSelected', (tester) async { + MarkType? selected; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkSelectionToolbar( + onMarkSelected: (type) => selected = type, + onCancelled: () {}, + ), + ), + ), + ); + await tester.tap(find.text('Stress')); + expect(selected, MarkType.stress); + }); + + testWidgets('cancel button calls onCancelled', (tester) async { + var cancelled = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkSelectionToolbar( + onMarkSelected: (_) {}, + onCancelled: () => cancelled = true, + ), + ), + ), + ); + await tester.ensureVisible(find.text('Cancel')); + await tester.tap(find.text('Cancel')); + expect(cancelled, isTrue); + }); + }); +} diff --git a/horatio/horatio_app/test/widgets/note_chip_test.dart b/horatio/horatio_app/test/widgets/note_chip_test.dart new file mode 100644 index 0000000..c129680 --- /dev/null +++ b/horatio/horatio_app/test/widgets/note_chip_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/note_chip.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + group('NoteChip', () { + testWidgets('shows truncated text and category', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteChip( + note: LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.intention, + text: 'This is a very long note that should be truncated', + createdAt: DateTime.utc(2026), + ), + onTap: () {}, + onDelete: () {}, + ), + ), + ), + ); + expect( + find.textContaining('This is a very long note that '), + findsOneWidget, + ); + expect(find.byIcon(Icons.psychology), findsOneWidget); + }); + + testWidgets('short text not truncated', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteChip( + note: LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.emotion, + text: 'Short note', + createdAt: DateTime.utc(2026), + ), + onTap: () {}, + onDelete: () {}, + ), + ), + ), + ); + expect(find.text('Short note'), findsOneWidget); + }); + + testWidgets('tap calls onTap', (tester) async { + var tapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteChip( + note: LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.general, + text: 'Test', + createdAt: DateTime.utc(2026), + ), + onTap: () => tapped = true, + onDelete: () {}, + ), + ), + ), + ); + await tester.tap(find.byType(ActionChip)); + expect(tapped, isTrue); + }); + + testWidgets('long-press calls onDelete', (tester) async { + var deleted = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteChip( + note: LineNote( + id: 'n1', + lineIndex: 0, + category: NoteCategory.blocking, + text: 'Test', + createdAt: DateTime.utc(2026), + ), + onTap: () {}, + onDelete: () => deleted = true, + ), + ), + ), + ); + await tester.longPress(find.byType(GestureDetector).first); + expect(deleted, isTrue); + }); + }); +} diff --git a/horatio/horatio_app/test/widgets/note_editor_sheet_test.dart b/horatio/horatio_app/test/widgets/note_editor_sheet_test.dart index 579ea22..89c2572 100644 --- a/horatio/horatio_app/test/widgets/note_editor_sheet_test.dart +++ b/horatio/horatio_app/test/widgets/note_editor_sheet_test.dart @@ -5,12 +5,16 @@ import 'package:horatio_core/horatio_core.dart'; void main() { group('NoteEditorSheet', () { - testWidgets('displays all 6 NoteCategory values in dropdown', - (tester) async { + testWidgets('displays all 6 NoteCategory values in dropdown', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: NoteEditorSheet(onSave: (_, __) {}, onCancel: () {}), + body: NoteEditorSheet( + onSave: (_, __, {String? noteId}) {}, + onCancel: () {}, + ), ), ), ); @@ -36,7 +40,7 @@ void main() { MaterialApp( home: Scaffold( body: NoteEditorSheet( - onSave: (category, text) { + onSave: (category, text, {String? noteId}) { savedCategory = category; savedText = text; }, @@ -54,15 +58,16 @@ void main() { expect(savedText, 'My note'); }); - testWidgets('submit with empty text shows validation error', - (tester) async { + testWidgets('submit with empty text shows validation error', ( + tester, + ) async { var saveCalled = false; await tester.pumpWidget( MaterialApp( home: Scaffold( body: NoteEditorSheet( - onSave: (_, __) => saveCalled = true, + onSave: (_, __, {String? noteId}) => saveCalled = true, onCancel: () {}, ), ), @@ -83,7 +88,7 @@ void main() { MaterialApp( home: Scaffold( body: NoteEditorSheet( - onSave: (_, __) {}, + onSave: (_, __, {String? noteId}) {}, onCancel: () => cancelled = true, ), ), @@ -102,7 +107,7 @@ void main() { MaterialApp( home: Scaffold( body: NoteEditorSheet( - onSave: (category, text) { + onSave: (category, text, {String? noteId}) { savedCategory = category; savedText = text; }, @@ -135,7 +140,8 @@ void main() { MaterialApp( home: Scaffold( body: NoteEditorSheet( - onSave: (category, _) => savedCategory = category, + onSave: (category, _, {String? noteId}) => + savedCategory = category, onCancel: () {}, ), ), @@ -154,5 +160,35 @@ void main() { expect(savedCategory, NoteCategory.intention); }); + + testWidgets('submit passes noteId when provided', (tester) async { + NoteCategory? savedCategory; + String? savedText; + String? savedNoteId; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteEditorSheet( + noteId: 'n42', + onSave: (category, text, {String? noteId}) { + savedCategory = category; + savedText = text; + savedNoteId = noteId; + }, + onCancel: () {}, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'Edited note'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(savedCategory, NoteCategory.general); + expect(savedText, 'Edited note'); + expect(savedNoteId, 'n42'); + }); }); } diff --git a/horatio/horatio_app/test/widgets/recording_action_bar_test.dart b/horatio/horatio_app/test/widgets/recording_action_bar_test.dart new file mode 100644 index 0000000..74a3766 --- /dev/null +++ b/horatio/horatio_app/test/widgets/recording_action_bar_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/recording_action_bar.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + group('RecordingActionBar', () { + testWidgets('shows record button', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: false, + elapsed: Duration.zero, + latestRecording: null, + onRecord: () {}, + onStop: () {}, + onPlay: () {}, + ), + ), + ), + ); + expect(find.byIcon(Icons.mic), findsOneWidget); + }); + + testWidgets('shows stop button when recording', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: true, + elapsed: const Duration(seconds: 5), + latestRecording: null, + onRecord: () {}, + onStop: () {}, + onPlay: () {}, + ), + ), + ), + ); + expect(find.byIcon(Icons.stop), findsOneWidget); + expect(find.textContaining('0:05'), findsOneWidget); + }); + + testWidgets('play button enabled when recording exists', (tester) async { + final recording = LineRecording( + id: 'r1', + scriptId: 's1', + lineIndex: 0, + filePath: '/p.m4a', + durationMs: 3000, + createdAt: DateTime.utc(2026), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: false, + elapsed: Duration.zero, + latestRecording: recording, + onRecord: () {}, + onStop: () {}, + onPlay: () {}, + ), + ), + ), + ); + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + }); + + testWidgets('play button disabled when no recording', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: false, + elapsed: Duration.zero, + latestRecording: null, + onRecord: () {}, + onStop: () {}, + onPlay: () {}, + ), + ), + ), + ); + final playButton = tester.widget( + find.widgetWithIcon(IconButton, Icons.play_arrow), + ); + expect(playButton.onPressed, isNull); + }); + + testWidgets('tap record calls onRecord', (tester) async { + var called = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: false, + elapsed: Duration.zero, + latestRecording: null, + onRecord: () => called = true, + onStop: () {}, + onPlay: () {}, + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.mic)); + expect(called, isTrue); + }); + + testWidgets('tap stop calls onStop', (tester) async { + var called = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingActionBar( + isRecording: true, + elapsed: Duration.zero, + latestRecording: null, + onRecord: () {}, + onStop: () => called = true, + onPlay: () {}, + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.stop)); + expect(called, isTrue); + }); + }); +} diff --git a/horatio/horatio_app/test/widgets/recording_badge_test.dart b/horatio/horatio_app/test/widgets/recording_badge_test.dart new file mode 100644 index 0000000..b488614 --- /dev/null +++ b/horatio/horatio_app/test/widgets/recording_badge_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/recording_badge.dart'; + +void main() { + group('RecordingBadge', () { + testWidgets('hidden when count is 0', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingBadge(recordingCount: 0, onTap: () {}), + ), + ), + ); + expect(find.byType(SizedBox), findsOneWidget); + expect(find.byIcon(Icons.mic), findsNothing); + }); + + testWidgets('shows mic icon and count', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingBadge(recordingCount: 3, onTap: () {}), + ), + ), + ); + expect(find.byIcon(Icons.mic), findsOneWidget); + expect(find.text('3'), findsOneWidget); + }); + + testWidgets('tap calls onTap', (tester) async { + var tapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingBadge( + recordingCount: 1, + onTap: () => tapped = true, + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.mic)); + expect(tapped, isTrue); + }); + }); +} diff --git a/horatio/horatio_app/test/widgets/recording_list_sheet_test.dart b/horatio/horatio_app/test/widgets/recording_list_sheet_test.dart new file mode 100644 index 0000000..920eb39 --- /dev/null +++ b/horatio/horatio_app/test/widgets/recording_list_sheet_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/widgets/recording_list_sheet.dart'; +import 'package:horatio_core/horatio_core.dart'; + +void main() { + final recordings = [ + LineRecording( + id: 'r1', + scriptId: 's1', + lineIndex: 0, + filePath: '/p1.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + grade: 3, + ), + LineRecording( + id: 'r2', + scriptId: 's1', + lineIndex: 0, + filePath: '/p2.m4a', + durationMs: 3000, + createdAt: DateTime.utc(2026, 1, 2), + ), + ]; + + group('RecordingListSheet', () { + testWidgets('shows recordings', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: recordings, + onPlay: (_) {}, + onGrade: (_, __) {}, + onDelete: (_) {}, + ), + ), + ), + ); + expect(find.textContaining('5.0s'), findsOneWidget); + expect(find.textContaining('3.0s'), findsOneWidget); + }); + + testWidgets('shows empty message', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: const [], + onPlay: (_) {}, + onGrade: (_, __) {}, + onDelete: (_) {}, + ), + ), + ), + ); + expect(find.text('No recordings'), findsOneWidget); + }); + + testWidgets('tap play calls onPlay', (tester) async { + LineRecording? played; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: recordings, + onPlay: (r) => played = r, + onGrade: (_, __) {}, + onDelete: (_) {}, + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.play_arrow).first); + expect(played?.id, 'r1'); + }); + + testWidgets('tap delete calls onDelete', (tester) async { + String? deleted; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: recordings, + onPlay: (_) {}, + onGrade: (_, __) {}, + onDelete: (id) => deleted = id, + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.delete).first); + expect(deleted, 'r1'); + }); + + testWidgets('shows grade for graded recording', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: recordings, + onPlay: (_) {}, + onGrade: (_, __) {}, + onDelete: (_) {}, + ), + ), + ), + ); + expect(find.byIcon(Icons.star), findsWidgets); + }); + + testWidgets('tap grade calls onGrade', (tester) async { + String? gradedId; + int? gradedValue; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RecordingListSheet( + recordings: recordings, + onPlay: (_) {}, + onGrade: (id, grade) { + gradedId = id; + gradedValue = grade; + }, + onDelete: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.text('Blackout').first); + + expect(gradedId, 'r1'); + expect(gradedValue, 0); + }); + }); +} diff --git a/horatio/horatio_app/test/widgets/text_scale_settings_sheet_test.dart b/horatio/horatio_app/test/widgets/text_scale_settings_sheet_test.dart new file mode 100644 index 0000000..fdde3ee --- /dev/null +++ b/horatio/horatio_app/test/widgets/text_scale_settings_sheet_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart'; +import 'package:horatio_app/bloc/text_scale/text_scale_state.dart'; +import 'package:horatio_app/widgets/text_scale_settings_sheet.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late TextScaleCubit cubit; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + cubit = TextScaleCubit(prefs: prefs); + }); + + tearDown(() => cubit.close()); + + Widget buildSheet() => MaterialApp( + home: BlocProvider.value( + value: cubit, + child: const Scaffold(body: TextScaleSettingsSheet()), + ), + ); + + group('TextScaleSettingsSheet', () { + testWidgets('shows slider and preview text', (tester) async { + await tester.pumpWidget(buildSheet()); + expect(find.byType(Slider), findsOneWidget); + expect(find.textContaining('1.0x'), findsOneWidget); + }); + + testWidgets('slider changes scale', (tester) async { + await tester.pumpWidget(buildSheet()); + final slider = find.byType(Slider); + await tester.drag(slider, const Offset(100, 0)); + await tester.pumpAndSettle(); + expect(cubit.state.scaleFactor, isNot(1)); + }); + + testWidgets('reset button resets to default', (tester) async { + await cubit.setScale(2); + await tester.pumpWidget(buildSheet()); + await tester.tap(find.text('Reset to auto')); + await tester.pumpAndSettle(); + expect(cubit.state, const TextScaleState(scaleFactor: 1)); + }); + + testWidgets('shows current scale value', (tester) async { + await cubit.setScale(1.5); + await tester.pumpWidget(buildSheet()); + expect(find.textContaining('1.5x'), findsOneWidget); + }); + }); +} diff --git a/horatio/horatio_app/web/index.html b/horatio/horatio_app/web/index.html new file mode 100644 index 0000000..f4943e0 --- /dev/null +++ b/horatio/horatio_app/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + horatio_app + + + + + + + diff --git a/horatio/horatio_app/web/manifest.json b/horatio/horatio_app/web/manifest.json new file mode 100644 index 0000000..524bf09 --- /dev/null +++ b/horatio/horatio_app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "horatio_app", + "short_name": "horatio_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/horatio/horatio_core/lib/src/models/line_recording.dart b/horatio/horatio_core/lib/src/models/line_recording.dart new file mode 100644 index 0000000..9211494 --- /dev/null +++ b/horatio/horatio_core/lib/src/models/line_recording.dart @@ -0,0 +1,66 @@ +import 'package:meta/meta.dart'; + +/// A voice recording for a specific script line. +@immutable +final class LineRecording { + /// Creates a [LineRecording]. + const LineRecording({ + required this.id, + required this.scriptId, + required this.lineIndex, + required this.filePath, + required this.durationMs, + required this.createdAt, + this.grade, + }); + + /// Deserializes from a JSON map. + factory LineRecording.fromJson(Map json) => LineRecording( + id: json['id'] as String, + scriptId: json['scriptId'] as String, + lineIndex: json['lineIndex'] as int, + filePath: json['filePath'] as String, + durationMs: json['durationMs'] as int, + createdAt: DateTime.parse(json['createdAt'] as String), + grade: json['grade'] as int?, + ); + + /// Unique identifier (UUID). + final String id; + + /// The script this recording belongs to. + final String scriptId; + + /// Index of the line this recording is for. + final int lineIndex; + + /// Path to the audio file on disk. + final String filePath; + + /// Duration in milliseconds. + final int durationMs; + + /// When this recording was created. + final DateTime createdAt; + + /// Grade 0-5 (SM-2 quality scale), null if not yet graded. + final int? grade; + + @override + bool operator ==(Object other) => + identical(this, other) || other is LineRecording && id == other.id; + + @override + int get hashCode => id.hashCode; + + /// Serializes to a JSON-compatible map. + Map toJson() => { + 'id': id, + 'scriptId': scriptId, + 'lineIndex': lineIndex, + 'filePath': filePath, + 'durationMs': durationMs, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'grade': grade, + }; +} diff --git a/horatio/horatio_core/lib/src/models/models.dart b/horatio/horatio_core/lib/src/models/models.dart index f1f226f..9c7a461 100644 --- a/horatio/horatio_core/lib/src/models/models.dart +++ b/horatio/horatio_core/lib/src/models/models.dart @@ -1,5 +1,6 @@ export 'annotation_snapshot.dart'; export 'line_note.dart'; +export 'line_recording.dart'; export 'mark_type.dart'; export 'note_category.dart'; export 'role.dart'; diff --git a/horatio/horatio_core/test/models/line_recording_test.dart b/horatio/horatio_core/test/models/line_recording_test.dart new file mode 100644 index 0000000..316c70a --- /dev/null +++ b/horatio/horatio_core/test/models/line_recording_test.dart @@ -0,0 +1,89 @@ +import 'package:horatio_core/horatio_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('LineRecording', () { + final recording = LineRecording( + id: 'r1', + scriptId: 's1', + lineIndex: 0, + filePath: '/recordings/s1/line_0_123.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + grade: 3, + ); + + test('properties are accessible', () { + expect(recording.id, 'r1'); + expect(recording.scriptId, 's1'); + expect(recording.lineIndex, 0); + expect(recording.filePath, '/recordings/s1/line_0_123.m4a'); + expect(recording.durationMs, 5000); + expect(recording.createdAt, DateTime.utc(2026)); + expect(recording.grade, 3); + }); + + test('grade can be null', () { + final ungraded = LineRecording( + id: 'r2', + scriptId: 's1', + lineIndex: 0, + filePath: '/path.m4a', + durationMs: 1000, + createdAt: DateTime.utc(2026), + ); + expect(ungraded.grade, isNull); + }); + + test('equality based on id', () { + final same = LineRecording( + id: 'r1', + scriptId: 'different', + lineIndex: 99, + filePath: '/other.m4a', + durationMs: 0, + createdAt: DateTime.utc(2000), + ); + expect(recording, equals(same)); + expect(recording.hashCode, same.hashCode); + }); + + test('inequality with different id', () { + final different = LineRecording( + id: 'r99', + scriptId: 's1', + lineIndex: 0, + filePath: '/path.m4a', + durationMs: 5000, + createdAt: DateTime.utc(2026), + ); + expect(recording, isNot(equals(different))); + }); + + test('toJson roundtrip', () { + final json = recording.toJson(); + final restored = LineRecording.fromJson(json); + expect(restored.id, recording.id); + expect(restored.scriptId, recording.scriptId); + expect(restored.lineIndex, recording.lineIndex); + expect(restored.filePath, recording.filePath); + expect(restored.durationMs, recording.durationMs); + expect(restored.createdAt, recording.createdAt); + expect(restored.grade, recording.grade); + }); + + test('toJson roundtrip with null grade', () { + final ungraded = LineRecording( + id: 'r3', + scriptId: 's1', + lineIndex: 0, + filePath: '/path.m4a', + durationMs: 1000, + createdAt: DateTime.utc(2026), + ); + final json = ungraded.toJson(); + final restored = LineRecording.fromJson(json); + expect(restored.grade, isNull); + }); + }); +} diff --git a/horatio/horatio_core/test/planner/planner_test.dart b/horatio/horatio_core/test/planner/planner_test.dart index 7e2e423..6f7667e 100644 --- a/horatio/horatio_core/test/planner/planner_test.dart +++ b/horatio/horatio_core/test/planner/planner_test.dart @@ -3,6 +3,11 @@ import 'package:test/test.dart'; void main() { group('LineComparator', () { + test('constructor creates instance at runtime', () { + const runtimeComparator = LineComparator(); + expect(runtimeComparator.levenshteinDistance('x', 'x'), 0); + }); + const comparator = LineComparator(); group('levenshteinDistance', () { @@ -110,6 +115,11 @@ void main() { }); group('MemorizationPlanner', () { + test('constructor creates instance at runtime', () { + const runtimePlanner = MemorizationPlanner(); + expect(runtimePlanner, isA()); + }); + const planner = MemorizationPlanner(); Script makeTestScript() { diff --git a/horatio/run.sh b/horatio/run.sh index b25a875..923a9c8 100755 --- a/horatio/run.sh +++ b/horatio/run.sh @@ -208,7 +208,7 @@ core_test() { fi heading "Testing horatio_core (with coverage)" cd "$CORE_DIR" - dart run coverage:test_with_coverage + flutter test --coverage check_coverage "$CORE_DIR/coverage/lcov.info" "horatio_core" 100 cache_step core_test "$h" }