mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 13:23:01 +02:00
feat(screen): integrate recording UI, note chips, and recording badges
This commit is contained in:
parent
85edd6ba02
commit
c52969d8bb
@ -0,0 +1,72 @@
|
||||
nbbbfv# Font Scaling, Word-Level Marks, Voice Recording & Note UX — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the Horatio app usable on 4K displays with auto-responsive font scaling + manual control, replace whole-line marks with word-level text selection, add per-line voice recording with playback and grading, and improve note UX with inline chips and edit/delete.
|
||||
|
||||
**Architecture:** Incremental feature layering on the existing Drift + flutter_bloc stack. Each chunk builds on the previous: font scaling is independent, word-level marks replace the existing long-press flow, recording adds a new data layer (model → table → DAO → service → cubit → UI), note UX enhances existing cubit/DAO/widgets.
|
||||
|
||||
**Tech Stack:** Flutter 3.10+, Dart 3.11+, flutter_bloc, Drift (SQLite), shared_preferences, audioplayers ^6.1.0, record ^6.2.0 (already in pubspec), mocktail, bloc_test
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-29-font-annotations-recording-design.md`
|
||||
|
||||
**Pipeline:** `./run.sh test` runs analyze + codegen + test with 100% branch coverage. `.g.dart` and `tables/` files are filtered from coverage.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Responsibility |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `horatio_app/lib/bloc/text_scale/text_scale_cubit.dart` | Text scale factor management + SharedPreferences persistence |
|
||||
| `horatio_app/lib/bloc/text_scale/text_scale_state.dart` | Equatable state for TextScaleCubit |
|
||||
| `horatio_app/lib/widgets/text_scale_settings_sheet.dart` | Bottom sheet with slider 0.5–3.0x + reset button |
|
||||
| `horatio_app/test/bloc/text_scale_cubit_test.dart` | Unit tests for TextScaleCubit |
|
||||
| `horatio_app/test/widgets/text_scale_settings_sheet_test.dart` | Widget tests for settings sheet |
|
||||
| `horatio_app/lib/widgets/mark_selection_toolbar.dart` | Floating toolbar with 6 colored chips for mark type selection |
|
||||
| `horatio_app/test/widgets/mark_selection_toolbar_test.dart` | Widget tests for toolbar |
|
||||
| `horatio_core/lib/src/models/line_recording.dart` | Immutable model for voice recordings |
|
||||
| `horatio_core/test/models/line_recording_test.dart` | JSON round-trip + equality tests |
|
||||
| `horatio_app/lib/database/tables/line_recordings_table.dart` | Drift table definition |
|
||||
| `horatio_app/lib/database/daos/recording_dao.dart` | CRUD DAO for recordings |
|
||||
| `horatio_app/test/database/recording_dao_test.dart` | Integration tests for RecordingDao |
|
||||
| `horatio_app/lib/services/recording_service.dart` | Wraps `record` package for mic capture |
|
||||
| `horatio_app/lib/services/audio_playback_service.dart` | Wraps `audioplayers` for playback |
|
||||
| `horatio_app/test/services/recording_service_test.dart` | Mock-based unit tests |
|
||||
| `horatio_app/test/services/audio_playback_service_test.dart` | Mock-based unit tests |
|
||||
| `horatio_app/lib/bloc/recording/recording_cubit.dart` | State machine for record/play/grade lifecycle |
|
||||
| `horatio_app/lib/bloc/recording/recording_state.dart` | Recording state hierarchy |
|
||||
| `horatio_app/test/bloc/recording_cubit_test.dart` | Full branch coverage cubit tests |
|
||||
| `horatio_app/lib/widgets/grade_stars.dart` | 0–5 star grading widget |
|
||||
| `horatio_app/lib/widgets/recording_action_bar.dart` | Record/Play/Grade bottom bar |
|
||||
| `horatio_app/lib/widgets/recording_badge.dart` | Mic icon + count badge per line |
|
||||
| `horatio_app/lib/widgets/recording_list_sheet.dart` | Bottom sheet listing all recordings for a line |
|
||||
| `horatio_app/lib/widgets/note_chip.dart` | Tappable inline note chip |
|
||||
| `horatio_app/test/widgets/grade_stars_test.dart` | Widget tests |
|
||||
| `horatio_app/test/widgets/recording_action_bar_test.dart` | Widget tests |
|
||||
| `horatio_app/test/widgets/recording_badge_test.dart` | Widget tests |
|
||||
| `horatio_app/test/widgets/recording_list_sheet_test.dart` | Widget tests |
|
||||
| `horatio_app/test/widgets/note_chip_test.dart` | Widget tests |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `horatio_app/pubspec.yaml` | Add `shared_preferences ^2.3.0`, `audioplayers ^6.1.0` |
|
||||
| `horatio_core/lib/src/models/models.dart` | Export `line_recording.dart` |
|
||||
| `horatio_app/lib/database/app_database.dart` | Add LineRecordingsTable, bump schema v1→v2, add MigrationStrategy |
|
||||
| `horatio_app/lib/database/daos/annotation_dao.dart` | Add `updateNoteCategory` method |
|
||||
| `horatio_app/lib/bloc/annotation/annotation_cubit.dart` | Change `updateNote` to accept optional category |
|
||||
| `horatio_app/lib/app.dart` | Add TextScaleCubit, RecordingDao, services to providers; wrap with MediaQuery |
|
||||
| `horatio_app/lib/main.dart` | Init SharedPreferences, pass to TextScaleCubit |
|
||||
| `horatio_app/lib/screens/annotation_editor_screen.dart` | Word selection, recording UI, note chips, settings icon |
|
||||
| `horatio_app/lib/screens/home_screen.dart` | Add settings icon to AppBar |
|
||||
| `horatio_app/lib/widgets/note_editor_sheet.dart` | Add `noteId` parameter for edit mode |
|
||||
| `horatio_app/test/bloc/annotation_cubit_test.dart` | Tests for updated updateNote |
|
||||
| `horatio_app/test/screens/annotation_editor_screen_test.dart` | Tests for new interactions |
|
||||
| `horatio_app/test/app_test.dart` | Update for new providers |
|
||||
| `horatio_app/test/helpers/test_database.dart` | No changes needed (in-memory DB auto-migrates) |
|
||||
|
||||
---
|
||||
@ -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.
|
||||
|
||||
---
|
||||
@ -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**
|
||||
|
||||
---
|
||||
@ -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.
|
||||
|
||||
---
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
@ -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
|
||||
```
|
||||
@ -15,7 +15,7 @@ migration:
|
||||
- platform: root
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: linux
|
||||
- platform: web
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
|
||||
|
||||
@ -1,51 +1,117 @@
|
||||
import 'package:device_preview/device_preview.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:horatio_app/bloc/script_import/script_import_cubit.dart';
|
||||
import 'package:horatio_app/bloc/srs_review/srs_review_cubit.dart';
|
||||
import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart';
|
||||
import 'package:horatio_app/bloc/text_scale/text_scale_state.dart';
|
||||
import 'package:horatio_app/database/app_database.dart';
|
||||
import 'package:horatio_app/database/daos/annotation_dao.dart';
|
||||
import 'package:horatio_app/database/daos/recording_dao.dart';
|
||||
import 'package:horatio_app/router.dart';
|
||||
import 'package:horatio_app/services/audio_playback_service.dart';
|
||||
import 'package:horatio_app/services/recording_service.dart';
|
||||
import 'package:horatio_app/services/script_repository.dart';
|
||||
import 'package:horatio_app/theme/app_theme.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Root widget for the Horatio app.
|
||||
class HoratioApp extends StatelessWidget {
|
||||
/// Creates the [HoratioApp].
|
||||
const HoratioApp({required this.database, super.key});
|
||||
const HoratioApp({
|
||||
required this.database,
|
||||
required this.recordingsDir,
|
||||
required this.prefs,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The drift database instance.
|
||||
final AppDatabase database;
|
||||
|
||||
/// SharedPreferences for text scale persistence.
|
||||
final SharedPreferences prefs;
|
||||
|
||||
/// Directory where line recordings are stored.
|
||||
final String recordingsDir;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MultiRepositoryProvider(
|
||||
providers: [
|
||||
RepositoryProvider<ScriptRepository>(
|
||||
create: (_) => ScriptRepository(),
|
||||
),
|
||||
RepositoryProvider<AnnotationDao>(
|
||||
create: (_) => database.annotationDao,
|
||||
),
|
||||
],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<ScriptImportCubit>(
|
||||
create: (context) => ScriptImportCubit(
|
||||
repository: context.read<ScriptRepository>(),
|
||||
),
|
||||
),
|
||||
BlocProvider<SrsReviewCubit>(
|
||||
create: (_) => SrsReviewCubit(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
title: 'Horatio',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
locale: DevicePreview.locale(context),
|
||||
builder: DevicePreview.appBuilder,
|
||||
routerConfig: appRouter,
|
||||
),
|
||||
providers: [
|
||||
RepositoryProvider<ScriptRepository>(create: (_) => ScriptRepository()),
|
||||
RepositoryProvider<AnnotationDao>(create: (_) => database.annotationDao),
|
||||
RepositoryProvider<RecordingDao>(create: (_) => database.recordingDao),
|
||||
RepositoryProvider<RecordingService>(
|
||||
create: (_) => RecordingService(),
|
||||
dispose: (service) => service.dispose(),
|
||||
),
|
||||
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>()),
|
||||
),
|
||||
);
|
||||
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,
|
||||
darkTheme: AppTheme.dark,
|
||||
locale: DevicePreview.locale(context),
|
||||
builder: DevicePreview.appBuilder,
|
||||
routerConfig: appRouter,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,8 +10,8 @@ import 'package:uuid/uuid.dart';
|
||||
class AnnotationCubit extends Cubit<AnnotationState> {
|
||||
/// Creates an [AnnotationCubit].
|
||||
AnnotationCubit({required AnnotationDao dao})
|
||||
: _dao = dao,
|
||||
super(const AnnotationInitial());
|
||||
: _dao = dao,
|
||||
super(const AnnotationInitial());
|
||||
|
||||
final AnnotationDao _dao;
|
||||
StreamSubscription<List<TextMark>>? _marksSub;
|
||||
@ -46,14 +46,17 @@ class AnnotationCubit extends Cubit<AnnotationState> {
|
||||
List<LineNote> notes,
|
||||
) {
|
||||
final current = state;
|
||||
emit(AnnotationLoaded(
|
||||
scriptId: scriptId,
|
||||
marks: marks,
|
||||
notes: notes,
|
||||
selectedLineIndex:
|
||||
current is AnnotationLoaded ? current.selectedLineIndex : null,
|
||||
editing: current is AnnotationLoaded ? current.editing : null,
|
||||
));
|
||||
emit(
|
||||
AnnotationLoaded(
|
||||
scriptId: scriptId,
|
||||
marks: marks,
|
||||
notes: notes,
|
||||
selectedLineIndex: current is AnnotationLoaded
|
||||
? current.selectedLineIndex
|
||||
: null,
|
||||
editing: current is AnnotationLoaded ? current.editing : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Focuses a line for annotation.
|
||||
@ -68,13 +71,13 @@ class AnnotationCubit extends Cubit<AnnotationState> {
|
||||
void startEditing({required int lineIndex, required bool isAddingMark}) {
|
||||
final current = state;
|
||||
if (current is AnnotationLoaded) {
|
||||
emit(current.copyWith(
|
||||
selectedLineIndex: () => lineIndex,
|
||||
editing: () => EditingContext(
|
||||
lineIndex: lineIndex,
|
||||
isAddingMark: isAddingMark,
|
||||
emit(
|
||||
current.copyWith(
|
||||
selectedLineIndex: () => lineIndex,
|
||||
editing: () =>
|
||||
EditingContext(lineIndex: lineIndex, isAddingMark: isAddingMark),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,9 +130,19 @@ class AnnotationCubit extends Cubit<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);
|
||||
|
||||
233
horatio/horatio_app/lib/bloc/recording/recording_cubit.dart
Normal file
233
horatio/horatio_app/lib/bloc/recording/recording_cubit.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
116
horatio/horatio_app/lib/bloc/recording/recording_state.dart
Normal file
116
horatio/horatio_app/lib/bloc/recording/recording_state.dart
Normal 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];
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
(t) =>
|
||||
t.scriptId.equals(scriptId) &
|
||||
t.lineIndex.equals(lineIndex),
|
||||
))
|
||||
.get();
|
||||
Future<List<TextMark>> getMarksForLine(String scriptId, int lineIndex) async {
|
||||
final rows =
|
||||
await (select(textMarksTable)..where(
|
||||
(t) =>
|
||||
t.scriptId.equals(scriptId) & t.lineIndex.equals(lineIndex),
|
||||
))
|
||||
.get();
|
||||
return rows.map(_rowToMark).toList();
|
||||
}
|
||||
|
||||
/// Inserts a text mark.
|
||||
Future<void> insertMark(String scriptId, TextMark mark) => into(
|
||||
textMarksTable,
|
||||
).insert(
|
||||
Future<void> insertMark(String scriptId, TextMark mark) =>
|
||||
into(textMarksTable).insert(
|
||||
TextMarksTableCompanion.insert(
|
||||
id: mark.id,
|
||||
scriptId: scriptId,
|
||||
@ -66,13 +61,13 @@ class AnnotationDao extends DatabaseAccessor<AppDatabase>
|
||||
(delete(textMarksTable)..where((t) => t.id.equals(id))).go();
|
||||
|
||||
TextMark _rowToMark(TextMarksTableData row) => TextMark(
|
||||
id: row.id,
|
||||
lineIndex: row.lineIndex,
|
||||
startOffset: row.startOffset,
|
||||
endOffset: row.endOffset,
|
||||
type: MarkType.values.byName(row.markType),
|
||||
createdAt: row.createdAt,
|
||||
);
|
||||
id: row.id,
|
||||
lineIndex: row.lineIndex,
|
||||
startOffset: row.startOffset,
|
||||
endOffset: row.endOffset,
|
||||
type: MarkType.values.byName(row.markType),
|
||||
createdAt: row.createdAt,
|
||||
);
|
||||
|
||||
// -- LineNote CRUD --------------------------------------------------------
|
||||
|
||||
@ -85,24 +80,19 @@ class AnnotationDao extends DatabaseAccessor<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(
|
||||
(t) =>
|
||||
t.scriptId.equals(scriptId) &
|
||||
t.lineIndex.equals(lineIndex),
|
||||
))
|
||||
.get();
|
||||
Future<List<LineNote>> getNotesForLine(String scriptId, int lineIndex) async {
|
||||
final rows =
|
||||
await (select(lineNotesTable)..where(
|
||||
(t) =>
|
||||
t.scriptId.equals(scriptId) & t.lineIndex.equals(lineIndex),
|
||||
))
|
||||
.get();
|
||||
return rows.map(_rowToNote).toList();
|
||||
}
|
||||
|
||||
/// Inserts a line note.
|
||||
Future<void> insertNote(String scriptId, LineNote note) => into(
|
||||
lineNotesTable,
|
||||
).insert(
|
||||
Future<void> insertNote(String scriptId, LineNote note) =>
|
||||
into(lineNotesTable).insert(
|
||||
LineNotesTableCompanion.insert(
|
||||
id: note.id,
|
||||
scriptId: scriptId,
|
||||
@ -115,27 +105,32 @@ 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) =>
|
||||
(delete(lineNotesTable)..where((t) => t.id.equals(id))).go();
|
||||
|
||||
LineNote _rowToNote(LineNotesTableData row) => LineNote(
|
||||
id: row.id,
|
||||
lineIndex: row.lineIndex,
|
||||
category: NoteCategory.values.byName(row.category),
|
||||
text: row.noteText,
|
||||
createdAt: row.createdAt,
|
||||
);
|
||||
id: row.id,
|
||||
lineIndex: row.lineIndex,
|
||||
category: NoteCategory.values.byName(row.category),
|
||||
text: row.noteText,
|
||||
createdAt: row.createdAt,
|
||||
);
|
||||
|
||||
// -- Snapshot management --------------------------------------------------
|
||||
|
||||
/// Watches all snapshots for a script, newest first.
|
||||
Stream<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,
|
||||
@ -158,10 +152,10 @@ class AnnotationDao extends DatabaseAccessor<AppDatabase>
|
||||
AnnotationSnapshot.fromJson(
|
||||
json.decode(row.snapshotJson) as Map<String, dynamic>,
|
||||
);
|
||||
// Note: scriptId and timestamp exist in both the table columns (for
|
||||
// efficient WHERE/ORDER BY filtering) AND in the JSON blob (for complete
|
||||
// deserialization). The columns are the source of truth for queries;
|
||||
// the JSON is the source of truth for the full snapshot data.
|
||||
// Note: scriptId and timestamp exist in both the table columns (for
|
||||
// efficient WHERE/ORDER BY filtering) AND in the JSON blob (for complete
|
||||
// deserialization). The columns are the source of truth for queries;
|
||||
// the JSON is the source of truth for the full snapshot data.
|
||||
|
||||
// -- Bulk operations (for snapshot restore) -------------------------------
|
||||
|
||||
@ -172,12 +166,12 @@ class AnnotationDao extends DatabaseAccessor<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);
|
||||
}
|
||||
|
||||
56
horatio/horatio_app/lib/database/daos/recording_dao.dart
Normal file
56
horatio/horatio_app/lib/database/daos/recording_dao.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
20
horatio/horatio_app/lib/database/daos/recording_dao.g.dart
Normal file
20
horatio/horatio_app/lib/database/daos/recording_dao.g.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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};
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
@ -49,38 +71,87 @@ class _AnnotationEditorBody extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Annotate: ${script.title}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.history),
|
||||
tooltip: 'History',
|
||||
onPressed: () =>
|
||||
context.push(RoutePaths.annotationHistory, extra: script),
|
||||
appBar: AppBar(
|
||||
title: Text('Annotate: ${script.title}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.text_fields),
|
||||
tooltip: 'Text Size',
|
||||
onPressed: () => showModalBottomSheet<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),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: BlocBuilder<AnnotationCubit, AnnotationState>(
|
||||
builder: (context, state) {
|
||||
if (state is! AnnotationLoaded) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return FloatingActionButton(
|
||||
onPressed: () => _saveSnapshot(context, state),
|
||||
tooltip: 'Save Snapshot',
|
||||
child: const Icon(Icons.save),
|
||||
);
|
||||
},
|
||||
),
|
||||
body: BlocBuilder<AnnotationCubit, AnnotationState>(
|
||||
builder: (context, 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,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton:
|
||||
BlocBuilder<AnnotationCubit, AnnotationState>(
|
||||
builder: (context, state) {
|
||||
if (state is! AnnotationLoaded) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return FloatingActionButton(
|
||||
onPressed: () => _saveSnapshot(context, state),
|
||||
tooltip: 'Save Snapshot',
|
||||
child: const Icon(Icons.save),
|
||||
);
|
||||
},
|
||||
),
|
||||
body: BlocBuilder<AnnotationCubit, AnnotationState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
AnnotationInitial() =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
AnnotationLoaded() => _buildLineList(context, state),
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildLineList(BuildContext context, AnnotationLoaded state) {
|
||||
final lines = _allLines;
|
||||
@ -88,10 +159,12 @@ class _AnnotationEditorBody extends StatelessWidget {
|
||||
itemCount: lines.length,
|
||||
itemBuilder: (context, index) {
|
||||
final line = lines[index];
|
||||
final lineMarks =
|
||||
state.marks.where((m) => m.lineIndex == index).toList();
|
||||
final lineNotes =
|
||||
state.notes.where((n) => n.lineIndex == index).toList();
|
||||
final lineMarks = state.marks
|
||||
.where((m) => m.lineIndex == index)
|
||||
.toList();
|
||||
final lineNotes = state.notes
|
||||
.where((n) => n.lineIndex == index)
|
||||
.toList();
|
||||
final isSelected = state.selectedLineIndex == index;
|
||||
return _LineTile(
|
||||
line: line,
|
||||
@ -106,13 +179,13 @@ class _AnnotationEditorBody extends StatelessWidget {
|
||||
|
||||
void _saveSnapshot(BuildContext context, AnnotationLoaded state) {
|
||||
context.read<AnnotationHistoryCubit>().saveSnapshot(
|
||||
marks: state.marks,
|
||||
notes: state.notes,
|
||||
);
|
||||
marks: state.marks,
|
||||
notes: state.notes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LineTile extends StatelessWidget {
|
||||
class _LineTile extends StatefulWidget {
|
||||
const _LineTile({
|
||||
required this.line,
|
||||
required this.lineIndex,
|
||||
@ -127,53 +200,267 @@ class _LineTile extends StatelessWidget {
|
||||
final List<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,
|
||||
)
|
||||
: null,
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
context.read<AnnotationCubit>().selectLine(lineIndex),
|
||||
onLongPress: () => _showMarkPicker(context),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
color: widget.isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||
: null,
|
||||
child: InkWell(
|
||||
onTap: () => context.read<AnnotationCubit>().selectLine(widget.lineIndex),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MarkOverlay(text: line.text, marks: marks),
|
||||
child: widget.isSelected
|
||||
? CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: SelectableText.rich(
|
||||
TextSpan(
|
||||
style: DefaultTextStyle.of(context).style,
|
||||
children: _buildSpans(),
|
||||
),
|
||||
onSelectionChanged: _onSelectionChanged,
|
||||
),
|
||||
)
|
||||
: MarkOverlay(
|
||||
text: widget.line.text,
|
||||
marks: widget.marks,
|
||||
),
|
||||
),
|
||||
BlocBuilder<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,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onCancelled: () => Navigator.pop(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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
50
horatio/horatio_app/lib/services/audio_playback_service.dart
Normal file
50
horatio/horatio_app/lib/services/audio_playback_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
39
horatio/horatio_app/lib/services/recording_service.dart
Normal file
39
horatio/horatio_app/lib/services/recording_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
39
horatio/horatio_app/lib/widgets/grade_stars.dart
Normal file
39
horatio/horatio_app/lib/widgets/grade_stars.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
50
horatio/horatio_app/lib/widgets/mark_selection_toolbar.dart
Normal file
50
horatio/horatio_app/lib/widgets/mark_selection_toolbar.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
45
horatio/horatio_app/lib/widgets/note_chip.dart
Normal file
45
horatio/horatio_app/lib/widgets/note_chip.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -3,13 +3,13 @@ import 'package:horatio_core/horatio_core.dart';
|
||||
|
||||
/// User-facing label for each [NoteCategory].
|
||||
String noteCategoryLabel(NoteCategory category) => switch (category) {
|
||||
NoteCategory.intention => 'Intention',
|
||||
NoteCategory.subtext => 'Subtext',
|
||||
NoteCategory.blocking => 'Blocking',
|
||||
NoteCategory.emotion => 'Emotion',
|
||||
NoteCategory.delivery => 'Delivery',
|
||||
NoteCategory.general => 'General',
|
||||
};
|
||||
NoteCategory.intention => 'Intention',
|
||||
NoteCategory.subtext => 'Subtext',
|
||||
NoteCategory.blocking => 'Blocking',
|
||||
NoteCategory.emotion => 'Emotion',
|
||||
NoteCategory.delivery => 'Delivery',
|
||||
NoteCategory.general => 'General',
|
||||
};
|
||||
|
||||
/// A bottom-sheet widget for creating or editing a [LineNote].
|
||||
class NoteEditorSheet extends StatefulWidget {
|
||||
@ -19,11 +19,13 @@ class NoteEditorSheet extends StatefulWidget {
|
||||
required this.onCancel,
|
||||
this.initialCategory,
|
||||
this.initialText,
|
||||
this.noteId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Called with the chosen category and text on save.
|
||||
final void Function(NoteCategory category, String text) onSave;
|
||||
/// Called with the chosen category, text, and optional noteId on save.
|
||||
final void Function(NoteCategory category, String text, {String? noteId})
|
||||
onSave;
|
||||
|
||||
/// Called when the user cancels editing.
|
||||
final VoidCallback onCancel;
|
||||
@ -34,6 +36,9 @@ class NoteEditorSheet extends StatefulWidget {
|
||||
/// Pre-filled text when editing an existing note.
|
||||
final String? initialText;
|
||||
|
||||
/// Non-null when editing an existing note.
|
||||
final String? noteId;
|
||||
|
||||
@override
|
||||
State<NoteEditorSheet> createState() => _NoteEditorSheetState();
|
||||
}
|
||||
@ -58,64 +63,66 @@ class _NoteEditorSheetState extends State<NoteEditorSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<NoteCategory>(
|
||||
initialValue: _category,
|
||||
decoration: const InputDecoration(labelText: 'Category'),
|
||||
items: NoteCategory.values
|
||||
.map(
|
||||
(c) => DropdownMenuItem(
|
||||
value: c,
|
||||
child: Text(noteCategoryLabel(c)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _category = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _textController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Note',
|
||||
hintText: 'Enter your note...',
|
||||
),
|
||||
maxLines: 3,
|
||||
validator: (value) => value == null || value.trim().isEmpty
|
||||
? 'Note cannot be empty'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
DropdownButtonFormField<NoteCategory>(
|
||||
initialValue: _category,
|
||||
decoration: const InputDecoration(labelText: 'Category'),
|
||||
items: NoteCategory.values
|
||||
.map(
|
||||
(c) => DropdownMenuItem(
|
||||
value: c,
|
||||
child: Text(noteCategoryLabel(c)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _category = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _textController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Note',
|
||||
hintText: 'Enter your note...',
|
||||
),
|
||||
maxLines: 3,
|
||||
validator: (value) =>
|
||||
value == null || value.trim().isEmpty ? 'Note cannot be empty' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(onPressed: _submit, child: const Text('Save')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void _submit() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
widget.onSave(_category, _textController.text.trim());
|
||||
widget.onSave(
|
||||
_category,
|
||||
_textController.text.trim(),
|
||||
noteId: widget.noteId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
78
horatio/horatio_app/lib/widgets/recording_action_bar.dart
Normal file
78
horatio/horatio_app/lib/widgets/recording_action_bar.dart
Normal 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
39
horatio/horatio_app/lib/widgets/recording_badge.dart
Normal file
39
horatio/horatio_app/lib/widgets/recording_badge.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
70
horatio/horatio_app/lib/widgets/recording_list_sheet.dart
Normal file
70
horatio/horatio_app/lib/widgets/recording_list_sheet.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
desktop_drop
|
||||
record_linux
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@ -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,33 +275,37 @@ void main() {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('loadAnnotations with new scriptId cancels previous streams',
|
||||
() async {
|
||||
final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId);
|
||||
marksController.add([testMark]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
test(
|
||||
'loadAnnotations with new scriptId cancels previous streams',
|
||||
() async {
|
||||
final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId);
|
||||
marksController.add([testMark]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final marks2 = StreamController<List<TextMark>>.broadcast();
|
||||
final notes2 = StreamController<List<LineNote>>.broadcast();
|
||||
when(() => dao.watchMarksForScript('script-2'))
|
||||
.thenAnswer((_) => marks2.stream);
|
||||
when(() => dao.watchNotesForScript('script-2'))
|
||||
.thenAnswer((_) => notes2.stream);
|
||||
final marks2 = StreamController<List<TextMark>>.broadcast();
|
||||
final notes2 = StreamController<List<LineNote>>.broadcast();
|
||||
when(
|
||||
() => dao.watchMarksForScript('script-2'),
|
||||
).thenAnswer((_) => marks2.stream);
|
||||
when(
|
||||
() => dao.watchNotesForScript('script-2'),
|
||||
).thenAnswer((_) => notes2.stream);
|
||||
|
||||
cubit.loadAnnotations('script-2');
|
||||
marks2.add([]);
|
||||
notes2.add([]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
cubit.loadAnnotations('script-2');
|
||||
marks2.add([]);
|
||||
notes2.add([]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = cubit.state;
|
||||
expect(state, isA<AnnotationLoaded>());
|
||||
expect((state as AnnotationLoaded).scriptId, 'script-2');
|
||||
expect(state.marks, isEmpty);
|
||||
final state = cubit.state;
|
||||
expect(state, isA<AnnotationLoaded>());
|
||||
expect((state as AnnotationLoaded).scriptId, 'script-2');
|
||||
expect(state.marks, isEmpty);
|
||||
|
||||
await cubit.close();
|
||||
await marks2.close();
|
||||
await notes2.close();
|
||||
});
|
||||
await cubit.close();
|
||||
await marks2.close();
|
||||
await notes2.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('close cancels stream subscriptions', () async {
|
||||
final cubit = AnnotationCubit(dao: dao)..loadAnnotations(scriptId);
|
||||
|
||||
401
horatio/horatio_app/test/bloc/recording_cubit_test.dart
Normal file
401
horatio/horatio_app/test/bloc/recording_cubit_test.dart
Normal 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]),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
100
horatio/horatio_app/test/bloc/text_scale_cubit_test.dart
Normal file
100
horatio/horatio_app/test/bloc/text_scale_cubit_test.dart
Normal 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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -23,29 +23,27 @@ void main() {
|
||||
int startOffset = 0,
|
||||
int endOffset = 5,
|
||||
MarkType type = MarkType.stress,
|
||||
}) =>
|
||||
TextMark(
|
||||
id: id,
|
||||
lineIndex: lineIndex,
|
||||
startOffset: startOffset,
|
||||
endOffset: endOffset,
|
||||
type: type,
|
||||
createdAt: DateTime.utc(2026, 3, 29),
|
||||
);
|
||||
}) => TextMark(
|
||||
id: id,
|
||||
lineIndex: lineIndex,
|
||||
startOffset: startOffset,
|
||||
endOffset: endOffset,
|
||||
type: type,
|
||||
createdAt: DateTime.utc(2026, 3, 29),
|
||||
);
|
||||
|
||||
LineNote makeNote({
|
||||
String id = 'n1',
|
||||
int lineIndex = 0,
|
||||
NoteCategory category = NoteCategory.intention,
|
||||
String text = 'test note',
|
||||
}) =>
|
||||
LineNote(
|
||||
id: id,
|
||||
lineIndex: lineIndex,
|
||||
category: category,
|
||||
text: text,
|
||||
createdAt: DateTime.utc(2026, 3, 29),
|
||||
);
|
||||
}) => LineNote(
|
||||
id: id,
|
||||
lineIndex: lineIndex,
|
||||
category: category,
|
||||
text: text,
|
||||
createdAt: DateTime.utc(2026, 3, 29),
|
||||
);
|
||||
|
||||
group('TextMark CRUD', () {
|
||||
test('insertMark and getMarksForLine', () async {
|
||||
@ -65,13 +63,7 @@ void main() {
|
||||
|
||||
test('watchMarksForScript emits on insert', () async {
|
||||
final stream = dao.watchMarksForScript(scriptId);
|
||||
final future = expectLater(
|
||||
stream,
|
||||
emitsInOrder([
|
||||
isEmpty,
|
||||
hasLength(1),
|
||||
]),
|
||||
);
|
||||
final future = expectLater(stream, emitsInOrder([isEmpty, hasLength(1)]));
|
||||
await Future<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');
|
||||
|
||||
42
horatio/horatio_app/test/database/app_database_test.dart
Normal file
42
horatio/horatio_app/test/database/app_database_test.dart
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
80
horatio/horatio_app/test/database/recording_dao_test.dart
Normal file
80
horatio/horatio_app/test/database/recording_dao_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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(
|
||||
RoutePaths.schedule,
|
||||
extra: {'script': script, 'role': role},
|
||||
));
|
||||
unawaited(
|
||||
appRouter.push(
|
||||
RoutePaths.schedule,
|
||||
extra: {'script': script, 'role': role},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Memorization Schedule'), findsOneWidget);
|
||||
@ -124,21 +161,18 @@ void main() {
|
||||
scenes: [
|
||||
Scene(
|
||||
lines: [
|
||||
ScriptLine(
|
||||
text: 'A.',
|
||||
role: role,
|
||||
sceneIndex: 0,
|
||||
lineIndex: 0,
|
||||
),
|
||||
ScriptLine(text: 'A.', role: role, sceneIndex: 0, lineIndex: 0),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
unawaited(appRouter.push(
|
||||
RoutePaths.rehearsal,
|
||||
extra: {'script': script, 'role': role},
|
||||
));
|
||||
unawaited(
|
||||
appRouter.push(
|
||||
RoutePaths.rehearsal,
|
||||
extra: {'script': script, 'role': role},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Rehearsing: Hero'), findsOneWidget);
|
||||
@ -148,9 +182,7 @@ void main() {
|
||||
await tester.pumpWidget(_wrapRouter());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final cards = [
|
||||
SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans'),
|
||||
];
|
||||
final cards = [SrsCard(id: 'c1', cueText: 'Cue', answerText: 'Ans')];
|
||||
|
||||
unawaited(appRouter.push(RoutePaths.srsReview, extra: cards));
|
||||
await tester.pumpAndSettle();
|
||||
@ -169,8 +201,9 @@ void main() {
|
||||
expect(find.text('Not Found'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('schedule route with wrong extra type falls back',
|
||||
(tester) async {
|
||||
testWidgets('schedule route with wrong extra type falls back', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_wrapRouter());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@ -182,8 +215,9 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('annotations route with Script extra shows editor',
|
||||
(tester) async {
|
||||
testWidgets('annotations route with Script extra shows editor', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_wrapRouter());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@ -216,8 +250,9 @@ void main() {
|
||||
expect(find.text('Annotate: Annotate Play'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('annotations route with null extra redirects home',
|
||||
(tester) async {
|
||||
testWidgets('annotations route with null extra redirects home', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_wrapRouter());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@ -228,8 +263,9 @@ void main() {
|
||||
expect(find.text('Horatio'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('annotation-history route with Script extra shows history',
|
||||
(tester) async {
|
||||
testWidgets('annotation-history route with Script extra shows history', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_wrapRouter());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@ -252,16 +288,15 @@ void main() {
|
||||
],
|
||||
);
|
||||
|
||||
unawaited(
|
||||
appRouter.push(RoutePaths.annotationHistory, extra: script),
|
||||
);
|
||||
unawaited(appRouter.push(RoutePaths.annotationHistory, extra: script));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('History: History Play'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('annotation-history route with null extra redirects home',
|
||||
(tester) async {
|
||||
testWidgets('annotation-history route with null extra redirects home', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_wrapRouter());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
||||
@ -4,74 +4,144 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:horatio_app/bloc/recording/recording_state.dart';
|
||||
import 'package:horatio_app/bloc/text_scale/text_scale_cubit.dart';
|
||||
import 'package:horatio_app/database/daos/annotation_dao.dart';
|
||||
import 'package:horatio_app/database/daos/recording_dao.dart';
|
||||
import 'package:horatio_app/screens/annotation_editor_screen.dart';
|
||||
import 'package:horatio_app/services/audio_playback_service.dart';
|
||||
import 'package:horatio_app/services/recording_service.dart';
|
||||
import 'package:horatio_app/widgets/mark_overlay.dart';
|
||||
import 'package:horatio_app/widgets/mark_selection_toolbar.dart';
|
||||
import 'package:horatio_app/widgets/note_chip.dart';
|
||||
import 'package:horatio_app/widgets/recording_action_bar.dart';
|
||||
import 'package:horatio_app/widgets/recording_badge.dart';
|
||||
import 'package:horatio_app/widgets/text_scale_settings_sheet.dart';
|
||||
import 'package:horatio_core/horatio_core.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class MockAnnotationDao extends Mock implements AnnotationDao {}
|
||||
|
||||
class MockRecordingDao extends Mock implements RecordingDao {}
|
||||
|
||||
class MockRecordingService extends Mock implements RecordingService {}
|
||||
|
||||
class MockAudioPlaybackService extends Mock implements AudioPlaybackService {}
|
||||
|
||||
const _hamlet = Role(name: 'Hamlet');
|
||||
const _horatio = Role(name: 'Horatio');
|
||||
|
||||
Script _testScript() => const Script(
|
||||
id: 'editor-screen-test',
|
||||
title: 'Test Play',
|
||||
roles: [_hamlet, _horatio],
|
||||
scenes: [
|
||||
Scene(
|
||||
lines: [
|
||||
ScriptLine(
|
||||
text: 'To be or not to be.',
|
||||
role: _hamlet,
|
||||
sceneIndex: 0,
|
||||
lineIndex: 0,
|
||||
),
|
||||
ScriptLine(
|
||||
text: 'Indeed, my lord.',
|
||||
role: _horatio,
|
||||
sceneIndex: 0,
|
||||
lineIndex: 1,
|
||||
),
|
||||
],
|
||||
id: 'editor-screen-test',
|
||||
title: 'Test Play',
|
||||
roles: [_hamlet, _horatio],
|
||||
scenes: [
|
||||
Scene(
|
||||
lines: [
|
||||
ScriptLine(
|
||||
text: 'To be or not to be.',
|
||||
role: _hamlet,
|
||||
sceneIndex: 0,
|
||||
lineIndex: 0,
|
||||
),
|
||||
ScriptLine(
|
||||
text: 'Indeed, my lord.',
|
||||
role: _horatio,
|
||||
sceneIndex: 0,
|
||||
lineIndex: 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
late MockAnnotationDao _dao;
|
||||
late MockRecordingDao _recordingDao;
|
||||
late MockRecordingService _recordingService;
|
||||
late MockAudioPlaybackService _playbackService;
|
||||
late StreamController<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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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: [
|
||||
RepositoryProvider<ScriptRepository>(
|
||||
create: (_) => ScriptRepository(),
|
||||
),
|
||||
BlocProvider<ScriptImportCubit>.value(value: cubit),
|
||||
BlocProvider<TextScaleCubit>.value(value: _textScaleCubit),
|
||||
],
|
||||
child: BlocProvider<ScriptImportCubit>.value(
|
||||
value: cubit,
|
||||
child: MultiRepositoryProvider(
|
||||
providers: [
|
||||
RepositoryProvider<ScriptRepository>(
|
||||
create: (_) => ScriptRepository(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp.router(routerConfig: router),
|
||||
),
|
||||
);
|
||||
@ -46,7 +54,10 @@ Widget _wrap(ScriptImportCubit cubit) {
|
||||
void main() {
|
||||
late MockScriptImportCubit cubit;
|
||||
|
||||
setUp(() {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_textScaleCubit = TextScaleCubit(prefs: prefs);
|
||||
cubit = MockScriptImportCubit();
|
||||
when(() => cubit.loadScripts()).thenReturn(null);
|
||||
when(() => cubit.importFromFile()).thenAnswer((_) async {});
|
||||
@ -57,6 +68,8 @@ void main() {
|
||||
)).thenAnswer((_) async {});
|
||||
});
|
||||
|
||||
tearDown(() => _textScaleCubit.close());
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(Uint8List(0));
|
||||
});
|
||||
@ -400,4 +413,18 @@ void main() {
|
||||
expect(painter.shouldRepaint(painter), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('HomeScreen text scale', () {
|
||||
testWidgets('text size button opens settings sheet', (tester) async {
|
||||
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
||||
|
||||
await tester.pumpWidget(_wrap(cubit));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.text_fields));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextScaleSettingsSheet), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
86
horatio/horatio_app/test/widgets/grade_stars_test.dart
Normal file
86
horatio/horatio_app/test/widgets/grade_stars_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
100
horatio/horatio_app/test/widgets/note_chip_test.dart
Normal file
100
horatio/horatio_app/test/widgets/note_chip_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
132
horatio/horatio_app/test/widgets/recording_action_bar_test.dart
Normal file
132
horatio/horatio_app/test/widgets/recording_action_bar_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
47
horatio/horatio_app/test/widgets/recording_badge_test.dart
Normal file
47
horatio/horatio_app/test/widgets/recording_badge_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
138
horatio/horatio_app/test/widgets/recording_list_sheet_test.dart
Normal file
138
horatio/horatio_app/test/widgets/recording_list_sheet_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
46
horatio/horatio_app/web/index.html
Normal file
46
horatio/horatio_app/web/index.html
Normal 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>
|
||||
35
horatio/horatio_app/web/manifest.json
Normal file
35
horatio/horatio_app/web/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
66
horatio/horatio_core/lib/src/models/line_recording.dart
Normal file
66
horatio/horatio_core/lib/src/models/line_recording.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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';
|
||||
|
||||
89
horatio/horatio_core/test/models/line_recording_test.dart
Normal file
89
horatio/horatio_core/test/models/line_recording_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user