feat(screen): integrate recording UI, note chips, and recording badges

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-03-29 21:11:43 +02:00
parent 85edd6ba02
commit c52969d8bb
65 changed files with 10229 additions and 475 deletions

View File

@ -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.53.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` | 05 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) |
---

View File

@ -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<Object?> 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<void>.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<void>.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<void>.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<void>.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<TextScaleState> {
/// 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<void> 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<void> 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<TextScaleCubit>.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<TextScaleCubit, TextScaleState>(
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<TextScaleCubit>().setScale(value),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () =>
context.read<TextScaleCubit>().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<ScriptRepository>(
create: (_) => ScriptRepository(),
),
RepositoryProvider<AnnotationDao>(
create: (_) => database.annotationDao,
),
],
child: MultiBlocProvider(
providers: [
BlocProvider<ScriptImportCubit>(
create: (context) => ScriptImportCubit(
repository: context.read<ScriptRepository>(),
),
),
BlocProvider<SrsReviewCubit>(
create: (_) => SrsReviewCubit(),
),
BlocProvider<TextScaleCubit>(
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<TextScaleCubit>().autoDetect(
mq.size,
mq.devicePixelRatio,
isDesktop: isDesktop,
);
}
}
@override
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
return BlocBuilder<TextScaleCubit, TextScaleState>(
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>[
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<void>(
context: context,
builder: (_) => BlocProvider.value(
value: context.read<TextScaleCubit>(),
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<void>(
context: context,
builder: (_) => BlocProvider.value(
value: context.read<TextScaleCubit>(),
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<TextScaleCubit>.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.
---

View File

@ -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<MarkType> 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<TextMark> marks;
final List<LineNote> 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<AnnotationCubit>().addMark(
lineIndex: widget.lineIndex,
startOffset: start,
endOffset: end,
type: type,
);
_removeToolbar();
}
List<TextSpan> _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 = <TextSpan>[];
var cursor = 0;
final activeTypes = <MarkType>[];
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<AnnotationCubit>()
.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<AnnotationCubit>();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: NoteEditorSheet(
onSave: (category, text) {
cubit.addNote(
lineIndex: 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<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Remove mark?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('No'),
),
TextButton(
onPressed: () {
context.read<AnnotationCubit>().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**
---

View File

@ -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<String, dynamic> 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<String, dynamic> 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<Column> 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<AppDatabase>
with _$RecordingDaoMixin {
/// Creates a [RecordingDao].
RecordingDao(super.db);
/// Watches all recordings for a script, ordered by lineIndex.
Stream<List<LineRecording>> 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<void> 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<void> deleteRecording(String id) =>
(delete(lineRecordingsTable)..where((t) => t.id.equals(id))).go();
/// Updates or clears the grade of a recording.
Future<void> 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.
---

View File

@ -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<RecordingDao>(
create: (_) => database.recordingDao,
),
RepositoryProvider<RecordingService>(
create: (_) => RecordingService(),
dispose: (service) => service.dispose(),
),
RepositoryProvider<AudioPlaybackService>(
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<String>.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<ScriptRepository>.value(value: _scriptRepo),
RepositoryProvider<AnnotationDao>.value(value: _annotationDao),
RepositoryProvider<RecordingDao>.value(value: _recordingDao),
RepositoryProvider<RecordingService>.value(value: _recordingService),
RepositoryProvider<AudioPlaybackService>.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<String>` keyed by a typedef:
In `app.dart`, add the recordings dir as a named provider (added in Task 6.1):
```dart
RepositoryProvider<String>.value(
value: recordingsDir, // passed from main.dart
),
```
In `AnnotationEditorScreen.build`, update the `MultiBlocProvider.providers` list:
```dart
BlocProvider(
create: (context) => RecordingCubit(
dao: context.read<RecordingDao>(),
recordingService: context.read<RecordingService>(),
playbackService: context.read<AudioPlaybackService>(),
recordingsDir: context.read<String>(),
)..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<AnnotationCubit, AnnotationState>(
builder: (context, annotationState) => switch (annotationState) {
AnnotationInitial() =>
const Center(child: CircularProgressIndicator()),
AnnotationLoaded() => Column(
children: [
Expanded(child: _buildLineList(context, annotationState)),
if (annotationState.selectedLineIndex != null)
BlocBuilder<RecordingCubit, RecordingState>(
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<RecordingCubit>()
.startRecording(script.id, lineIndex),
onStop: () =>
context.read<RecordingCubit>().stopRecording(),
onPlay: () {
if (recordings.isNotEmpty) {
context
.read<RecordingCubit>()
.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<AnnotationCubit>().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<RecordingCubit, RecordingState>(
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<AnnotationCubit>()
.removeNote(note.id),
),
)
.toList(),
),
),
],
),
),
),
);
```
Add a helper to show the recording list bottom sheet:
```dart
void _showRecordingList(
BuildContext context,
List<LineRecording> recordings,
) {
showModalBottomSheet<void>(
context: context,
builder: (_) => RecordingListSheet(
recordings: recordings,
onPlay: (recording) {
Navigator.pop(context);
context.read<RecordingCubit>().playRecording(recording);
},
onGrade: (id, grade) =>
context.read<RecordingCubit>().gradeRecording(id, grade),
onDelete: (id) =>
context.read<RecordingCubit>().deleteRecording(id),
),
);
}
void _showNoteEditorForEdit(BuildContext context, LineNote note) {
final cubit = context.read<AnnotationCubit>();
showModalBottomSheet<void>(
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<List<LineRecording>> _recordingsCtrl;
late MockRecordingService _recordingService;
late MockAudioPlaybackService _playbackService;
void _setUpRecordingMocks() {
_recordingDao = MockRecordingDao();
_recordingsCtrl = StreamController<List<LineRecording>>.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<AnnotationDao>.value(value: _dao),
RepositoryProvider<RecordingDao>.value(value: _recordingDao),
RepositoryProvider<RecordingService>.value(value: _recordingService),
RepositoryProvider<AudioPlaybackService>.value(
value: _playbackService,
),
RepositoryProvider<String>.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.

View File

@ -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<TextScaleCubit, TextScaleState>(
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<Column> 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<List<LineRecording>>`
Keeping it separate from `AnnotationDao` maintains single-responsibility. Injected via its own `RepositoryProvider<RecordingDao>` in `app.dart`.
### Services
**`RecordingService`** (wraps `record` package):
- `Future<void> startRecording(String filePath)` — starts microphone capture to `.m4a`
- `Future<String> stopRecording()` — stops, returns file path
- `Stream<bool> get isRecording`
- `Stream<Duration> 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<bool> hasPermission()` / `Future<bool> 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<void> play(String filePath)`
- `Future<void> stop()`
- `Stream<Duration> get position`
- `Stream<PlaybackStatus> get status` — enum: `idle`, `playing`, `completed`. `RecordingCubit` listens to this stream to transition from `RecordingPlayback` to `RecordingGrading` when status becomes `completed`.
- `Future<Duration> getDuration(String filePath)`
Both services are injected via `RepositoryProvider` in `app.dart`.
### RecordingCubit
```
States:
RecordingInitial
RecordingIdle(recordings: List<LineRecording>)
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
```

View File

@ -15,7 +15,7 @@ migration:
- platform: root
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: linux
- platform: web
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a

View File

@ -1,43 +1,108 @@
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<ScriptRepository>(
create: (_) => ScriptRepository(),
RepositoryProvider<ScriptRepository>(create: (_) => ScriptRepository()),
RepositoryProvider<AnnotationDao>(create: (_) => database.annotationDao),
RepositoryProvider<RecordingDao>(create: (_) => database.recordingDao),
RepositoryProvider<RecordingService>(
create: (_) => RecordingService(),
dispose: (service) => service.dispose(),
),
RepositoryProvider<AnnotationDao>(
create: (_) => database.annotationDao,
RepositoryProvider<AudioPlaybackService>(
create: (_) => AudioPlaybackService(),
dispose: (service) => service.dispose(),
),
RepositoryProvider<String>.value(value: recordingsDir),
],
child: MultiBlocProvider(
providers: [
BlocProvider<ScriptImportCubit>(
create: (context) => ScriptImportCubit(
repository: context.read<ScriptRepository>(),
create: (context) =>
ScriptImportCubit(repository: context.read<ScriptRepository>()),
),
),
BlocProvider<SrsReviewCubit>(
create: (_) => SrsReviewCubit(),
BlocProvider<SrsReviewCubit>(create: (_) => SrsReviewCubit()),
BlocProvider<TextScaleCubit>(
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<TextScaleCubit>().autoDetect(
mq.size,
mq.devicePixelRatio,
isDesktop: isDesktop,
);
}
}
@override
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
return BlocBuilder<TextScaleCubit, TextScaleState>(
builder: (context, state) => MediaQuery(
data: mq.copyWith(textScaler: TextScaler.linear(state.scaleFactor)),
child: MaterialApp.router(
title: 'Horatio',
theme: AppTheme.light,
@ -48,4 +113,5 @@ class HoratioApp extends StatelessWidget {
),
),
);
}
}

View File

@ -46,14 +46,17 @@ class AnnotationCubit extends Cubit<AnnotationState> {
List<LineNote> notes,
) {
final current = state;
emit(AnnotationLoaded(
emit(
AnnotationLoaded(
scriptId: scriptId,
marks: marks,
notes: notes,
selectedLineIndex:
current is AnnotationLoaded ? current.selectedLineIndex : null,
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<AnnotationState> {
void startEditing({required int lineIndex, required bool isAddingMark}) {
final current = state;
if (current is AnnotationLoaded) {
emit(current.copyWith(
emit(
current.copyWith(
selectedLineIndex: () => lineIndex,
editing: () => EditingContext(
lineIndex: lineIndex,
isAddingMark: isAddingMark,
editing: () =>
EditingContext(lineIndex: lineIndex, isAddingMark: isAddingMark),
),
));
);
}
}
@ -127,9 +130,19 @@ class AnnotationCubit extends Cubit<AnnotationState> {
await _dao.insertNote(scriptId, note);
}
/// Updates a note's text.
Future<void> updateNote(String id, String text) =>
_dao.updateNoteText(id, text);
/// Updates a note's text and/or category.
Future<void> 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<void> removeNote(String id) => _dao.deleteNote(id);

View File

@ -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<RecordingState> {
/// 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<List<LineRecording>>? _recordingsSub;
StreamSubscription<PlaybackStatus>? _statusSub;
StreamSubscription<Duration>? _positionSub;
Timer? _elapsedTimer;
String? _scriptId;
int? _recordingLineIndex;
DateTime? _recordingStartedAt;
List<LineRecording> _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<void> 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<void> 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<void> 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<void> stopPlayback() async {
await _playbackService.stop();
await _positionSub?.cancel();
await _statusSub?.cancel();
emit(RecordingIdle(recordings: _latestRecordings));
}
/// Grades a recording (0-5).
Future<void> gradeRecording(String id, int grade) async {
await _dao.updateRecordingGrade(id, grade);
emit(RecordingIdle(recordings: _latestRecordings));
}
/// Deletes a recording.
Future<void> deleteRecording(String id) => _dao.deleteRecording(id);
@override
Future<void> close() async {
await _recordingsSub?.cancel();
await _statusSub?.cancel();
await _positionSub?.cancel();
_elapsedTimer?.cancel();
if (_disposeServicesOnClose) {
await _recordingService.dispose();
await _playbackService.dispose();
}
return super.close();
}
}

View File

@ -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<LineRecording> get recordings;
}
/// No recordings loaded.
final class RecordingInitial extends RecordingState {
const RecordingInitial();
@override
List<LineRecording> get recordings => const [];
@override
List<Object?> 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<LineRecording> recordings;
@override
List<Object?> 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<LineRecording> recordings;
/// The line being recorded.
final int lineIndex;
/// Elapsed recording time.
final Duration elapsed;
@override
List<Object?> 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<LineRecording> recordings;
/// The recording being played.
final LineRecording recording;
/// Current playback position.
final Duration position;
@override
List<Object?> 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<LineRecording> recordings;
/// The recording to grade.
final LineRecording recording;
@override
List<Object?> 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<LineRecording> recordings;
/// Error message.
final String message;
@override
List<Object?> get props => [recordings, message];
}

View File

@ -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<TextScaleState> {
/// 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<void> 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<void> resetToAuto() async {
await _prefs.remove(_key);
emit(const TextScaleState(scaleFactor: 1));
}
}

View File

@ -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<Object?> get props => [scaleFactor];
}

View File

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

View File

@ -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<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _scriptIdMeta = const VerificationMeta(
'scriptId',
);
@override
late final GeneratedColumn<String> scriptId = GeneratedColumn<String>(
'script_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _lineIndexMeta = const VerificationMeta(
'lineIndex',
);
@override
late final GeneratedColumn<int> lineIndex = GeneratedColumn<int>(
'line_index',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
static const VerificationMeta _filePathMeta = const VerificationMeta(
'filePath',
);
@override
late final GeneratedColumn<String> filePath = GeneratedColumn<String>(
'file_path',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _durationMsMeta = const VerificationMeta(
'durationMs',
);
@override
late final GeneratedColumn<int> durationMs = GeneratedColumn<int>(
'duration_ms',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
static const VerificationMeta _gradeMeta = const VerificationMeta('grade');
@override
late final GeneratedColumn<int> grade = GeneratedColumn<int>(
'grade',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> 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<LineRecordingsTableData> 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<GeneratedColumn> get $primaryKey => {id};
@override
LineRecordingsTableData map(
Map<String, dynamic> 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<LineRecordingsTableData> {
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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['script_id'] = Variable<String>(scriptId);
map['line_index'] = Variable<int>(lineIndex);
map['file_path'] = Variable<String>(filePath);
map['duration_ms'] = Variable<int>(durationMs);
map['created_at'] = Variable<DateTime>(createdAt);
if (!nullToAbsent || grade != null) {
map['grade'] = Variable<int>(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<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return LineRecordingsTableData(
id: serializer.fromJson<String>(json['id']),
scriptId: serializer.fromJson<String>(json['scriptId']),
lineIndex: serializer.fromJson<int>(json['lineIndex']),
filePath: serializer.fromJson<String>(json['filePath']),
durationMs: serializer.fromJson<int>(json['durationMs']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
grade: serializer.fromJson<int?>(json['grade']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'scriptId': serializer.toJson<String>(scriptId),
'lineIndex': serializer.toJson<int>(lineIndex),
'filePath': serializer.toJson<String>(filePath),
'durationMs': serializer.toJson<int>(durationMs),
'createdAt': serializer.toJson<DateTime>(createdAt),
'grade': serializer.toJson<int?>(grade),
};
}
LineRecordingsTableData copyWith({
String? id,
String? scriptId,
int? lineIndex,
String? filePath,
int? durationMs,
DateTime? createdAt,
Value<int?> 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<LineRecordingsTableData> {
final Value<String> id;
final Value<String> scriptId;
final Value<int> lineIndex;
final Value<String> filePath;
final Value<int> durationMs;
final Value<DateTime> createdAt;
final Value<int?> grade;
final Value<int> 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<LineRecordingsTableData> custom({
Expression<String>? id,
Expression<String>? scriptId,
Expression<int>? lineIndex,
Expression<String>? filePath,
Expression<int>? durationMs,
Expression<DateTime>? createdAt,
Expression<int>? grade,
Expression<int>? 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<String>? id,
Value<String>? scriptId,
Value<int>? lineIndex,
Value<String>? filePath,
Value<int>? durationMs,
Value<DateTime>? createdAt,
Value<int?>? grade,
Value<int>? 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (scriptId.present) {
map['script_id'] = Variable<String>(scriptId.value);
}
if (lineIndex.present) {
map['line_index'] = Variable<int>(lineIndex.value);
}
if (filePath.present) {
map['file_path'] = Variable<String>(filePath.value);
}
if (durationMs.present) {
map['duration_ms'] = Variable<int>(durationMs.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (grade.present) {
map['grade'] = Variable<int>(grade.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(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<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@ -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<int?> grade,
Value<int> rowid,
});
typedef $$LineRecordingsTableTableUpdateCompanionBuilder =
LineRecordingsTableCompanion Function({
Value<String> id,
Value<String> scriptId,
Value<int> lineIndex,
Value<String> filePath,
Value<int> durationMs,
Value<DateTime> createdAt,
Value<int?> grade,
Value<int> rowid,
});
class $$LineRecordingsTableTableFilterComposer
extends Composer<_$AppDatabase, $LineRecordingsTableTable> {
$$LineRecordingsTableTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get scriptId => $composableBuilder(
column: $table.scriptId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get lineIndex => $composableBuilder(
column: $table.lineIndex,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get filePath => $composableBuilder(
column: $table.filePath,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get durationMs => $composableBuilder(
column: $table.durationMs,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> 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<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get scriptId => $composableBuilder(
column: $table.scriptId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get lineIndex => $composableBuilder(
column: $table.lineIndex,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get filePath => $composableBuilder(
column: $table.filePath,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get durationMs => $composableBuilder(
column: $table.durationMs,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> 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<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get scriptId =>
$composableBuilder(column: $table.scriptId, builder: (column) => column);
GeneratedColumn<int> get lineIndex =>
$composableBuilder(column: $table.lineIndex, builder: (column) => column);
GeneratedColumn<String> get filePath =>
$composableBuilder(column: $table.filePath, builder: (column) => column);
GeneratedColumn<int> get durationMs => $composableBuilder(
column: $table.durationMs,
builder: (column) => column,
);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<int> 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<String> id = const Value.absent(),
Value<String> scriptId = const Value.absent(),
Value<int> lineIndex = const Value.absent(),
Value<String> filePath = const Value.absent(),
Value<int> durationMs = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<int?> grade = const Value.absent(),
Value<int> 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<int?> grade = const Value.absent(),
Value<int> 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);
}

View File

@ -32,24 +32,19 @@ class AnnotationDao extends DatabaseAccessor<AppDatabase>
.map((rows) => rows.map(_rowToMark).toList());
/// Gets marks for a specific line.
Future<List<TextMark>> getMarksForLine(
String scriptId,
int lineIndex,
) async {
final rows = await (select(textMarksTable)
..where(
Future<List<TextMark>> getMarksForLine(String scriptId, int lineIndex) async {
final rows =
await (select(textMarksTable)..where(
(t) =>
t.scriptId.equals(scriptId) &
t.lineIndex.equals(lineIndex),
t.scriptId.equals(scriptId) & t.lineIndex.equals(lineIndex),
))
.get();
return rows.map(_rowToMark).toList();
}
/// Inserts a text mark.
Future<void> insertMark(String scriptId, TextMark mark) => into(
textMarksTable,
).insert(
Future<void> insertMark(String scriptId, TextMark mark) =>
into(textMarksTable).insert(
TextMarksTableCompanion.insert(
id: mark.id,
scriptId: scriptId,
@ -85,24 +80,19 @@ class AnnotationDao extends DatabaseAccessor<AppDatabase>
.map((rows) => rows.map(_rowToNote).toList());
/// Gets notes for a specific line.
Future<List<LineNote>> getNotesForLine(
String scriptId,
int lineIndex,
) async {
final rows = await (select(lineNotesTable)
..where(
Future<List<LineNote>> getNotesForLine(String scriptId, int lineIndex) async {
final rows =
await (select(lineNotesTable)..where(
(t) =>
t.scriptId.equals(scriptId) &
t.lineIndex.equals(lineIndex),
t.scriptId.equals(scriptId) & t.lineIndex.equals(lineIndex),
))
.get();
return rows.map(_rowToNote).toList();
}
/// Inserts a line note.
Future<void> insertNote(String scriptId, LineNote note) => into(
lineNotesTable,
).insert(
Future<void> insertNote(String scriptId, LineNote note) =>
into(lineNotesTable).insert(
LineNotesTableCompanion.insert(
id: note.id,
scriptId: scriptId,
@ -115,8 +105,15 @@ class AnnotationDao extends DatabaseAccessor<AppDatabase>
/// Updates the text of a note.
Future<void> updateNoteText(String id, String text) =>
(update(lineNotesTable)..where((t) => t.id.equals(id)))
.write(LineNotesTableCompanion(noteText: Value(text)));
(update(lineNotesTable)..where((t) => t.id.equals(id))).write(
LineNotesTableCompanion(noteText: Value(text)),
);
/// Updates the category of a note.
Future<void> 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<void> deleteNote(String id) =>
@ -133,9 +130,7 @@ class AnnotationDao extends DatabaseAccessor<AppDatabase>
// -- Snapshot management --------------------------------------------------
/// Watches all snapshots for a script, newest first.
Stream<List<AnnotationSnapshot>> watchSnapshotsForScript(
String scriptId,
) =>
Stream<List<AnnotationSnapshot>> 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<AppDatabase>
.map((rows) => rows.map(_rowToSnapshot).toList());
/// Inserts a snapshot.
Future<void> insertSnapshot(AnnotationSnapshot snapshot) => into(
annotationSnapshotsTable,
).insert(
Future<void> insertSnapshot(AnnotationSnapshot snapshot) =>
into(annotationSnapshotsTable).insert(
AnnotationSnapshotsTableCompanion.insert(
id: snapshot.id,
scriptId: snapshot.scriptId,
@ -172,12 +166,12 @@ class AnnotationDao extends DatabaseAccessor<AppDatabase>
required List<TextMark> marks,
required List<LineNote> notes,
}) => transaction(() async {
await (delete(textMarksTable)
..where((t) => t.scriptId.equals(scriptId)))
.go();
await (delete(lineNotesTable)
..where((t) => t.scriptId.equals(scriptId)))
.go();
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);
}

View File

@ -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<AppDatabase>
with _$RecordingDaoMixin {
/// Creates a [RecordingDao].
RecordingDao(super.db);
/// Watches all recordings for a script, ordered by lineIndex.
Stream<List<LineRecording>> 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<void> 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<void> deleteRecording(String id) =>
(delete(lineRecordingsTable)..where((t) => t.id.equals(id))).go();
/// Updates or clears the grade of a recording.
Future<void> 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,
);
}

View File

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

View File

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

View File

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

View File

@ -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<AnnotationDao>();
final annotationDao = context.read<AnnotationDao>();
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<RecordingDao>(),
recordingService: context.read<RecordingService>(),
playbackService: context.read<AudioPlaybackService>(),
recordingsDir: context.read<String>(),
disposeServicesOnClose: false,
)..loadRecordings(script.id),
),
],
child: _AnnotationEditorBody(script: script),
@ -52,6 +74,17 @@ class _AnnotationEditorBody extends StatelessWidget {
appBar: AppBar(
title: Text('Annotate: ${script.title}'),
actions: [
IconButton(
icon: const Icon(Icons.text_fields),
tooltip: 'Text Size',
onPressed: () => showModalBottomSheet<void>(
context: context,
builder: (_) => BlocProvider.value(
value: context.read<TextScaleCubit>(),
child: const TextScaleSettingsSheet(),
),
),
),
IconButton(
icon: const Icon(Icons.history),
tooltip: 'History',
@ -60,8 +93,7 @@ class _AnnotationEditorBody extends StatelessWidget {
),
],
),
floatingActionButton:
BlocBuilder<AnnotationCubit, AnnotationState>(
floatingActionButton: BlocBuilder<AnnotationCubit, AnnotationState>(
builder: (context, state) {
if (state is! AnnotationLoaded) {
return const SizedBox.shrink();
@ -74,10 +106,49 @@ class _AnnotationEditorBody extends StatelessWidget {
},
),
body: BlocBuilder<AnnotationCubit, AnnotationState>(
builder: (context, state) => switch (state) {
AnnotationInitial() =>
const Center(child: CircularProgressIndicator()),
AnnotationLoaded() => _buildLineList(context, state),
builder: (context, annotationState) => switch (annotationState) {
AnnotationInitial() => const Center(child: CircularProgressIndicator()),
AnnotationLoaded() => Column(
children: [
Expanded(child: _buildLineList(context, annotationState)),
if (annotationState.selectedLineIndex != null)
BlocBuilder<RecordingCubit, RecordingState>(
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<RecordingCubit>()
.startRecording(script.id, lineIndex),
onStop: () =>
context.read<RecordingCubit>().stopRecording(),
onPlay: () {
if (latestRecording != null) {
context.read<RecordingCubit>().playRecording(
latestRecording,
);
}
},
);
},
),
],
),
},
),
);
@ -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,
@ -112,7 +185,7 @@ class _AnnotationEditorBody extends StatelessWidget {
}
}
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<LineNote> notes;
final bool isSelected;
@override
State<_LineTile> createState() => _LineTileState();
}
class _LineTileState extends State<_LineTile> {
final LayerLink _layerLink = LayerLink();
OverlayEntry? _toolbarOverlay;
TextSelection? _selection;
final List<TapGestureRecognizer> _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<AnnotationCubit>().addMark(
lineIndex: widget.lineIndex,
startOffset: sel.start,
endOffset: sel.end,
type: type,
);
_removeToolbar();
}
void _showRemoveMarkDialog(String markId) {
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Remove mark?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('No'),
),
TextButton(
onPressed: () {
context.read<AnnotationCubit>().removeMark(markId);
Navigator.pop(dialogContext);
},
child: const Text('Yes'),
),
],
),
);
}
List<TextSpan> _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 = <TextSpan>[];
var cursor = 0;
final activeTypes = <MarkType>[];
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,
)
color: widget.isSelected
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3)
: null,
child: InkWell(
onTap: () =>
context.read<AnnotationCubit>().selectLine(lineIndex),
onLongPress: () => _showMarkPicker(context),
onTap: () => context.read<AnnotationCubit>().selectLine(widget.lineIndex),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
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<RecordingCubit, RecordingState>(
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<AnnotationCubit>()
.removeNote(note.id),
),
)
.toList(),
),
),
],
),
),
),
);
void _showMarkPicker(BuildContext context) {
final cubit = context.read<AnnotationCubit>();
showDialog<void>(
void _showRecordingList(
BuildContext context,
List<LineRecording> recordings,
) {
showModalBottomSheet<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Add Mark'),
content: MarkTypePicker(
onSelected: (type) {
cubit.addMark(
lineIndex: lineIndex,
startOffset: 0,
endOffset: line.text.length,
type: type,
);
builder: (_) => RecordingListSheet(
recordings: recordings,
onPlay: (recording) {
Navigator.pop(context);
context.read<RecordingCubit>().playRecording(recording);
},
onGrade: (id, grade) {
context.read<RecordingCubit>().gradeRecording(id, grade);
},
onDelete: (id) {
context.read<RecordingCubit>().deleteRecording(id);
},
onCancelled: () => Navigator.pop(context),
),
),
);
}
@ -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<AnnotationCubit>();
showModalBottomSheet<void>(
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),
),
),
);
}
}

View File

@ -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<HomeScreen> {
@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<void>(
context: context,
builder: (_) => BlocProvider.value(
value: context.read<TextScaleCubit>(),
child: const TextScaleSettingsSheet(),
),
),
),
],
),
body: DropTarget(
onDragDone: _handleDrop,
onDragEntered: (_) => setState(() => _isDragging = true),

View File

@ -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<void> play(String filePath) =>
_activePlayer.play(DeviceFileSource(filePath));
/// Stops playback.
Future<void> stop() => _activePlayer.stop();
/// Stream of playback status changes.
Stream<PlaybackStatus> get status =>
_activePlayer.onPlayerStateChanged.map((state) => switch (state) {
PlayerState.playing => PlaybackStatus.playing,
PlayerState.completed => PlaybackStatus.completed,
_ => PlaybackStatus.idle,
});
/// Stream of playback position.
Stream<Duration> get position => _activePlayer.onPositionChanged;
/// Releases the player resources.
Future<void> dispose() async {
final player = _player;
if (player != null) {
await player.dispose();
}
}
}

View File

@ -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<bool> hasPermission() => _activeRecorder.hasPermission();
/// Starts recording to the given file path.
///
/// Creates the parent directory if it doesn't exist.
Future<void> 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<String?> stopRecording() => _activeRecorder.stop();
/// Releases the recorder resources.
Future<void> dispose() async {
final recorder = _recorder;
if (recorder != null) {
await recorder.dispose();
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:horatio_core/horatio_core.dart';
/// Category icons for note chips.
const Map<NoteCategory, IconData> 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,
),
);
}

View File

@ -9,7 +9,7 @@ String noteCategoryLabel(NoteCategory category) => switch (category) {
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<NoteEditorSheet> createState() => _NoteEditorSheetState();
}
@ -90,8 +95,9 @@ class _NoteEditorSheetState extends State<NoteEditorSheet> {
hintText: 'Enter your note...',
),
maxLines: 3,
validator: (value) =>
value == null || value.trim().isEmpty ? 'Note cannot be empty' : null,
validator: (value) => value == null || value.trim().isEmpty
? 'Note cannot be empty'
: null,
),
const SizedBox(height: 16),
Row(
@ -102,10 +108,7 @@ class _NoteEditorSheetState extends State<NoteEditorSheet> {
child: const Text('Cancel'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _submit,
child: const Text('Save'),
),
ElevatedButton(onPressed: _submit, child: const Text('Save')),
],
),
],
@ -115,7 +118,11 @@ class _NoteEditorSheetState extends State<NoteEditorSheet> {
void _submit() {
if (_formKey.currentState!.validate()) {
widget.onSave(_category, _textController.text.trim());
widget.onSave(
_category,
_textController.text.trim(),
noteId: widget.noteId,
);
}
}
}

View File

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

View File

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

View File

@ -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<LineRecording> recordings;
/// Called when play is tapped for a recording.
final ValueChanged<LineRecording> 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<String> 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),
),
),
),
],
),
);
}

View File

@ -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<TextScaleCubit, TextScaleState>(
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<TextScaleCubit>().setScale(value),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () =>
context.read<TextScaleCubit>().resetToAuto(),
child: const Text('Reset to auto'),
),
),
],
),
),
);
}

View File

@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <desktop_drop/desktop_drop_plugin.h>
#include <record_linux/record_linux_plugin.h>
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);

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
desktop_drop
record_linux
)

View File

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

View File

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

View File

@ -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>[
SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans'),
]));
unawaited(
appRouter.push(
RoutePaths.srsReview,
extra: <SrsCard>[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),
],
),
],

View File

@ -38,10 +38,12 @@ void main() {
marksController = StreamController<List<TextMark>>.broadcast();
notesController = StreamController<List<LineNote>>.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<AnnotationInitial>());
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<AnnotationInitial>());
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<AnnotationInitial>());
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,7 +275,8 @@ void main() {
await cubit.close();
});
test('loadAnnotations with new scriptId cancels previous streams',
test(
'loadAnnotations with new scriptId cancels previous streams',
() async {
final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId);
marksController.add([testMark]);
@ -244,10 +284,12 @@ void main() {
final marks2 = StreamController<List<TextMark>>.broadcast();
final notes2 = StreamController<List<LineNote>>.broadcast();
when(() => dao.watchMarksForScript('script-2'))
.thenAnswer((_) => marks2.stream);
when(() => dao.watchNotesForScript('script-2'))
.thenAnswer((_) => notes2.stream);
when(
() => dao.watchMarksForScript('script-2'),
).thenAnswer((_) => marks2.stream);
when(
() => dao.watchNotesForScript('script-2'),
).thenAnswer((_) => notes2.stream);
cubit.loadAnnotations('script-2');
marks2.add([]);
@ -262,7 +304,8 @@ void main() {
await cubit.close();
await marks2.close();
await notes2.close();
});
},
);
test('close cancels stream subscriptions', () async {
final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId);

View File

@ -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<List<LineRecording>> recordingsController;
late StreamController<PlaybackStatus> statusController;
late StreamController<Duration> 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<List<LineRecording>>.broadcast();
statusController = StreamController<PlaybackStatus>.broadcast();
positionController = StreamController<Duration>.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<RecordingInitial>());
cubit.close();
});
test('loadRecordings emits RecordingIdle on stream data', () async {
final cubit = createCubit()..loadRecordings(scriptId);
recordingsController.add([testRecording]);
await Future<void>.delayed(Duration.zero);
expect(cubit.state, isA<RecordingIdle>());
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<void>.delayed(Duration.zero);
await cubit.startRecording(scriptId, 0);
expect(cubit.state, isA<RecordingInProgress>());
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<void>.delayed(Duration.zero);
await cubit.startRecording(scriptId, 0);
await Future<void>.delayed(const Duration(milliseconds: 120));
final state = cubit.state;
expect(state, isA<RecordingInProgress>());
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<void>.delayed(Duration.zero);
await cubit.startRecording(scriptId, 0);
expect(cubit.state, isA<RecordingError>());
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<void>.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<void>.delayed(Duration.zero);
await cubit.startRecording(scriptId, 0);
await cubit.stopRecording();
verify(() => dao.insertRecording(scriptId, any())).called(1);
expect(cubit.state, isA<RecordingIdle>());
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<void>.delayed(Duration.zero);
await cubit.startRecording(scriptId, 0);
await cubit.stopRecording();
verifyNever(() => dao.insertRecording(any(), any()));
expect(cubit.state, isA<RecordingIdle>());
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<void>.delayed(Duration.zero);
await cubit.playRecording(testRecording);
expect(cubit.state, isA<RecordingPlayback>());
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<void>.delayed(Duration.zero);
await cubit.playRecording(testRecording);
statusController.add(PlaybackStatus.completed);
await Future<void>.delayed(Duration.zero);
expect(cubit.state, isA<RecordingGrading>());
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<void>.delayed(Duration.zero);
await cubit.playRecording(testRecording);
await cubit.stopPlayback();
expect(cubit.state, isA<RecordingIdle>());
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<void>.delayed(Duration.zero);
await cubit.gradeRecording('r1', 4);
verify(() => dao.updateRecordingGrade('r1', 4)).called(1);
expect(cubit.state, isA<RecordingIdle>());
await cubit.close();
});
test('deleteRecording calls dao', () async {
when(() => dao.deleteRecording('r1')).thenAnswer((_) async {});
final cubit = createCubit()..loadRecordings(scriptId);
recordingsController.add([testRecording]);
await Future<void>.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<void>.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<RecordingInitial>());
await cubit.close();
});
test('stopRecording is no-op when not recording', () async {
final cubit = createCubit()..loadRecordings(scriptId);
recordingsController.add([]);
await Future<void>.delayed(Duration.zero);
await cubit.stopRecording();
expect(cubit.state, isA<RecordingIdle>());
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<void>.delayed(Duration.zero);
await cubit.playRecording(testRecording);
positionController.add(const Duration(seconds: 2));
await Future<void>.delayed(Duration.zero);
final state = cubit.state;
expect(state, isA<RecordingPlayback>());
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<void>.delayed(Duration.zero);
await cubit.startRecording(scriptId, 0);
recordingsController.add([testRecording]);
await Future<void>.delayed(Duration.zero);
final state = cubit.state;
expect(state, isA<RecordingError>());
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<void>.delayed(Duration.zero);
await cubit.startRecording(scriptId, 0);
recordingsController.add([testRecording]);
await Future<void>.delayed(Duration.zero);
final state = cubit.state;
expect(state, isA<RecordingInProgress>());
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<void>.delayed(Duration.zero);
await cubit.playRecording(testRecording);
recordingsController.add([testRecording]);
await Future<void>.delayed(Duration.zero);
expect(cubit.state, isA<RecordingPlayback>());
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<void>.delayed(Duration.zero);
await cubit.playRecording(testRecording);
statusController.add(PlaybackStatus.completed);
await Future<void>.delayed(Duration.zero);
recordingsController.add([testRecording]);
await Future<void>.delayed(Duration.zero);
expect(cubit.state, isA<RecordingGrading>());
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]),
);
});
});
}

View File

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

View File

@ -23,8 +23,7 @@ void main() {
int startOffset = 0,
int endOffset = 5,
MarkType type = MarkType.stress,
}) =>
TextMark(
}) => TextMark(
id: id,
lineIndex: lineIndex,
startOffset: startOffset,
@ -38,8 +37,7 @@ void main() {
int lineIndex = 0,
NoteCategory category = NoteCategory.intention,
String text = 'test note',
}) =>
LineNote(
}) => LineNote(
id: id,
lineIndex: lineIndex,
category: category,
@ -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<void>.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<void>.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<void>.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');

View File

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

View File

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

View File

@ -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<ScriptRepository>(create: (_) => repository),
RepositoryProvider<AnnotationDao>.value(value: mockDao),
RepositoryProvider<RecordingDao>.value(value: mockRecordingDao),
RepositoryProvider<RecordingService>.value(value: mockRecordingService),
RepositoryProvider<AudioPlaybackService>.value(
value: mockPlaybackService,
),
RepositoryProvider<String>.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(
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(
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();

View File

@ -4,13 +4,31 @@ 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');
@ -36,42 +54,94 @@ Script _testScript() => const Script(
],
),
],
);
);
late MockAnnotationDao _dao;
late MockRecordingDao _recordingDao;
late MockRecordingService _recordingService;
late MockAudioPlaybackService _playbackService;
late StreamController<List<TextMark>> _marksCtrl;
late StreamController<List<LineNote>> _notesCtrl;
late StreamController<List<LineRecording>> _recordingsCtrl;
late StreamController<List<AnnotationSnapshot>> _snapshotsCtrl;
late TextScaleCubit _textScaleCubit;
void _setUpDao() {
_dao = MockAnnotationDao();
_marksCtrl = StreamController<List<TextMark>>.broadcast();
_notesCtrl = StreamController<List<LineNote>>.broadcast();
_recordingsCtrl = StreamController<List<LineRecording>>.broadcast();
_snapshotsCtrl = StreamController<List<AnnotationSnapshot>>.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<void> _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<AnnotationDao>.value(
value: _dao,
child: MaterialApp(
home: AnnotationEditorScreen(script: script),
Widget _buildScreen(Script script) => BlocProvider<TextScaleCubit>.value(
value: _textScaleCubit,
child: MultiRepositoryProvider(
providers: [
RepositoryProvider<AnnotationDao>.value(value: _dao),
RepositoryProvider<RecordingDao>.value(value: _recordingDao),
RepositoryProvider<RecordingService>.value(value: _recordingService),
RepositoryProvider<AudioPlaybackService>.value(value: _playbackService),
RepositoryProvider<String>.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<AnnotationDao>.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<TextScaleCubit>.value(
value: _textScaleCubit,
child: MultiRepositoryProvider(
providers: [
RepositoryProvider<AnnotationDao>.value(value: _dao),
RepositoryProvider<RecordingDao>.value(value: _recordingDao),
RepositoryProvider<RecordingService>.value(value: _recordingService),
RepositoryProvider<AudioPlaybackService>.value(value: _playbackService),
RepositoryProvider<String>.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<RecordingBadge>(
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<SelectableText>(
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<SelectableText>(
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<SelectableText>(
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);
});
});
}

View File

@ -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<ScriptImportState>
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: [
BlocProvider<ScriptImportCubit>.value(value: cubit),
BlocProvider<TextScaleCubit>.value(value: _textScaleCubit),
],
child: MultiRepositoryProvider(
providers: [
RepositoryProvider<ScriptRepository>(
create: (_) => ScriptRepository(),
),
],
child: BlocProvider<ScriptImportCubit>.value(
value: cubit,
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);
});
});
}

View File

@ -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<DeviceFileSource>())))
.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<PlayerState>();
when(() => mockPlayer.onPlayerStateChanged)
.thenAnswer((_) => controller.stream);
final service2 = AudioPlaybackService(player: mockPlayer);
final statuses = <PlaybackStatus>[];
final sub = service2.status.listen(statuses.add);
controller
..add(PlayerState.playing)
..add(PlayerState.completed)
..add(PlayerState.stopped)
..add(PlayerState.paused);
await Future<void>.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<Duration>();
when(() => mockPlayer.onPositionChanged)
.thenAnswer((_) => controller.stream);
final service2 = AudioPlaybackService(player: mockPlayer);
final positions = <Duration>[];
final sub = service2.position.listen(positions.add);
controller
..add(const Duration(seconds: 1))
..add(const Duration(seconds: 2));
await Future<void>.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);
});
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="horatio_app">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>horatio_app</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

View File

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

View File

@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'id': id,
'scriptId': scriptId,
'lineIndex': lineIndex,
'filePath': filePath,
'durationMs': durationMs,
'createdAt': createdAt.toUtc().toIso8601String(),
'grade': grade,
};
}

View File

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

View File

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

View File

@ -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<MemorizationPlanner>());
});
const planner = MemorizationPlanner();
Script makeTestScript() {

View File

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