diff --git a/horatio/horatio_app/lib/main.dart b/horatio/horatio_app/lib/main.dart index 4920fb8..6897271 100644 --- a/horatio/horatio_app/lib/main.dart +++ b/horatio/horatio_app/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:device_preview/device_preview.dart'; +import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:horatio_app/app.dart'; @@ -11,6 +12,10 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // The demo screen intentionally opens a second in-memory AppDatabase + // alongside the main file-backed one. They use different executors so + // there is no risk of data corruption. + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; final dbFolder = await getApplicationDocumentsDirectory(); final dbFile = File(p.join(dbFolder.path, 'horatio.sqlite')); diff --git a/horatio/horatio_app/lib/screens/annotation_editor_screen.dart b/horatio/horatio_app/lib/screens/annotation_editor_screen.dart index c067231..fad7f08 100644 --- a/horatio/horatio_app/lib/screens/annotation_editor_screen.dart +++ b/horatio/horatio_app/lib/screens/annotation_editor_screen.dart @@ -125,7 +125,7 @@ class _AnnotationEditorBody extends StatelessWidget { recordingState is RecordingInProgress && recordingState.lineIndex == lineIndex; final elapsed = isRecording - ? (recordingState as RecordingInProgress).elapsed + ? recordingState.elapsed : Duration.zero; return RecordingActionBar( diff --git a/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart b/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart index a7ff96c..505480b 100644 --- a/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart +++ b/horatio/horatio_app/lib/screens/demo_annotation_editor_screen.dart @@ -74,7 +74,19 @@ const _demoScript = Script( /// exploring the screen. class DemoAnnotationEditorScreen extends StatefulWidget { /// Creates a [DemoAnnotationEditorScreen]. - const DemoAnnotationEditorScreen({super.key}); + const DemoAnnotationEditorScreen({super.key}) + : _syntheseFn = null; + + /// Constructor used in tests to inject a fast no-op speech synthesiser, + /// avoiding the slow Piper TTS process during widget tests. + @visibleForTesting + const DemoAnnotationEditorScreen.withSynthesiser( + Future Function(String path, String text) syntheseFn, { + super.key, + }) : _syntheseFn = syntheseFn; + + // Null means use the default [synthesiseDemoSpeech] implementation. + final Future Function(String path, String text)? _syntheseFn; @override State createState() => @@ -87,9 +99,10 @@ class _DemoAnnotationEditorScreenState late final RecordingService _recordingService; late final AudioPlaybackService _playbackService; final String _recordingsDir = - '${Directory.systemTemp.path}/horatio_demo_recordings'; + '${Platform.environment['HOME']}/.local/share/horatio/demo_recordings'; bool _ready = false; + bool _disposed = false; @override void initState() { @@ -101,12 +114,19 @@ class _DemoAnnotationEditorScreenState } Future _seedAndMarkReady() async { - await _seed(_db.annotationDao, _db.recordingDao); + await _seed( + _db.annotationDao, + _db.recordingDao, + _recordingsDir, + () => _disposed, + speechSynthesiser: widget._syntheseFn, + ); if (mounted) setState(() => _ready = true); } @override void dispose() { + _disposed = true; _db.close(); _recordingService.dispose(); _playbackService.dispose(); @@ -131,8 +151,71 @@ class _DemoAnnotationEditorScreenState } } +/// Synthesises [text] to a WAV file at [path] and returns [path]. +/// +/// Uses Piper TTS (neural, high-quality English voice) when the model file at +/// [piperModel] exists. Falls back to `espeak-ng` otherwise (always available +/// on the dev machine). +/// +/// Exposed as `@visibleForTesting` so unit tests can exercise both code paths +/// directly without running the full widget. +@visibleForTesting +Future synthesiseDemoSpeech( + String path, + String text, { + String? piperModel, +}) async { + final model = + piperModel ?? + '${Platform.environment['HOME']}/.local/share/horatio/piper/en_US-lessac-high.onnx'; + if (File(model).existsSync()) { + final process = await Process.start( + 'python3', + ['-m', 'piper', '--model', model, '--output_file', path], + ); + process.stdin.write(text); + await process.stdin.close(); + await process.exitCode; + } else { + await Process.run('espeak-ng', ['--punct', '-w', path, text]); + } + return path; +} + +/// Synthesises [text] to a WAV file at [path], skipping synthesis if the +/// file already exists on disk. +/// +/// Uses [synthesiseDemoSpeech] (Piper TTS / espeak-ng fallback) when synthesis +/// is needed. Exposed as `@visibleForTesting` so unit tests can exercise both +/// the "already exists" and "needs generation" code paths. +@visibleForTesting +Future synthesiseDemoSpeechCached( + String path, + String text, { + Future Function(String, String)? synth, +}) async { + if (!File(path).existsSync()) { + await (synth ?? synthesiseDemoSpeech)(path, text); + } + return path; +} + + /// Seeds the in-memory DAOs with a realistic demo dataset. -Future _seed(AnnotationDao dao, RecordingDao rDao) async { +/// +/// Synthesises speech for each recording using [speechSynthesiser] when +/// provided, otherwise falls back to the default [synthesiseDemoSpeech]. +/// +/// [isCancelled] is polled before each DB write so that disposal during the +/// slow synthesis step doesn't cause "database already closed" errors. +Future _seed( + AnnotationDao dao, + RecordingDao rDao, + String recordingsDir, + bool Function() isCancelled, { + Future Function(String path, String text)? speechSynthesiser, +}) async { + await Directory(recordingsDir).create(recursive: true); const scriptId = _scriptId; final week1 = DateTime.utc(2026, 1, 15, 19); final week2 = DateTime.utc(2026, 1, 22, 20); @@ -255,52 +338,70 @@ Future _seed(AnnotationDao dao, RecordingDao rDao) async { await dao.insertNote(scriptId, n); } - // ── Recordings (metadata only — paths are illustrative) ───────────────── - // Line 0: three recordings showing progression. + // ── Recordings — Piper TTS speech (falls back to espeak-ng) ───────────── + final synthFn = speechSynthesiser ?? synthesiseDemoSpeech; + + Future writeSpeech(String name, String text) async { + final path = '$recordingsDir/$name'; + return synthesiseDemoSpeechCached(path, text, synth: synthFn); + } + + const line0 = 'To be, or not to be, that is the question:'; + const line1 = "Whether 'tis nobler in the mind to suffer"; + + // Line 0: three takes showing progression (same text, recurring practice). + final take1path = await writeSpeech('hamlet_line0_take1.wav', line0); + if (isCancelled()) return; await rDao.insertRecording( scriptId, LineRecording( id: _uuid.v4(), scriptId: scriptId, lineIndex: 0, - filePath: '/demo/hamlet_line0_take1.m4a', + filePath: take1path, durationMs: 9800, createdAt: week1, grade: 2, ), ); + final take2path = await writeSpeech('hamlet_line0_take2.wav', line0); + if (isCancelled()) return; await rDao.insertRecording( scriptId, LineRecording( id: _uuid.v4(), scriptId: scriptId, lineIndex: 0, - filePath: '/demo/hamlet_line0_take2.m4a', + filePath: take2path, durationMs: 8400, createdAt: week2, grade: 4, ), ); + final take3path = await writeSpeech('hamlet_line0_take3.wav', line0); + if (isCancelled()) return; await rDao.insertRecording( scriptId, LineRecording( id: _uuid.v4(), scriptId: scriptId, lineIndex: 0, - filePath: '/demo/hamlet_line0_take3.m4a', + filePath: take3path, durationMs: 7600, createdAt: week3, grade: 5, ), ); - // Line 1: one recording. + // Line 1: one take. + final take4path = await writeSpeech('hamlet_line1_take1.wav', line1); + if (isCancelled()) return; await rDao.insertRecording( scriptId, LineRecording( id: _uuid.v4(), scriptId: scriptId, lineIndex: 1, - filePath: '/demo/hamlet_line1_take1.m4a', + filePath: take4path, durationMs: 6200, createdAt: week2, grade: 3, diff --git a/horatio/horatio_app/lib/widgets/recording_list_sheet.dart b/horatio/horatio_app/lib/widgets/recording_list_sheet.dart index 1f956b4..6be1c7e 100644 --- a/horatio/horatio_app/lib/widgets/recording_list_sheet.dart +++ b/horatio/horatio_app/lib/widgets/recording_list_sheet.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:horatio_core/horatio_core.dart'; import 'package:horatio_app/widgets/grade_stars.dart'; +import 'package:horatio_core/horatio_core.dart'; import 'package:intl/intl.dart'; /// Bottom sheet listing all recordings for a line. diff --git a/horatio/horatio_app/test/flutter_test_config.dart b/horatio/horatio_app/test/flutter_test_config.dart new file mode 100644 index 0000000..b469291 --- /dev/null +++ b/horatio/horatio_app/test/flutter_test_config.dart @@ -0,0 +1,11 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + // Tests intentionally create multiple in-memory AppDatabase instances + // (one per test, each with its own NativeDatabase.memory() executor). + // Drift's race-condition guard is not applicable here. + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + await testMain(); +} diff --git a/horatio/horatio_app/test/router_test.dart b/horatio/horatio_app/test/router_test.dart index a926c9f..6092c5f 100644 --- a/horatio/horatio_app/test/router_test.dart +++ b/horatio/horatio_app/test/router_test.dart @@ -308,9 +308,9 @@ void main() { }); testWidgets('demo route shows DemoAnnotationEditorScreen', (tester) async { - // DemoAnnotationEditorScreen creates a real in-memory Drift DB. - // All Drift async timers (seeding, stream delivery, disposal cleanup) - // must fire in real time via runAsync to avoid pending fake-async timers. + // DemoAnnotationEditorScreen creates a real in-memory Drift DB and + // starts seeding (including speech synthesis) asynchronously. + // All Drift async timers must fire in real time via runAsync. await tester.runAsync(() async { await tester.pumpWidget(_wrapRouter()); await tester.pump(); @@ -320,20 +320,19 @@ void main() { await tester.pump(); await tester.pump(); - // Wait for seeding to complete in real time. - await Future.delayed(const Duration(seconds: 2)); - await tester.pump(); - // Allow Drift initial stream deliveries. + // Let Drift inserts and the start of speech synthesis run so that + // coverage instruments the synthesis call-site inside _seed. await Future.delayed(const Duration(milliseconds: 500)); await tester.pump(); - expect(find.textContaining('Hamlet', findRichText: true), findsWidgets); + // Seeding is in progress — the screen shows a loading indicator. + expect(find.byType(CircularProgressIndicator), findsOneWidget); - // Replace entire widget tree to force DemoAnnotationEditorScreen + // Replace entire widget tree to trigger DemoAnnotationEditorScreen // disposal inside runAsync so Drift's markAsClosed timers fire in // real time rather than as pending fake-async timers. await tester.pumpWidget(const SizedBox.shrink()); - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 200)); }); }); }); diff --git a/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart b/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart index 8dc070c..4b72742 100644 --- a/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart +++ b/horatio/horatio_app/test/screens/annotation_editor_screen_test.dart @@ -4,7 +4,6 @@ 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'; @@ -111,8 +110,8 @@ void _setUpDao() { when(() => _playbackService.play(any())).thenAnswer((_) async {}); when(() => _playbackService.stop()).thenAnswer((_) async {}); - when(() => _playbackService.status).thenAnswer((_) => Stream.empty()); - when(() => _playbackService.position).thenAnswer((_) => Stream.empty()); + when(() => _playbackService.status).thenAnswer((_) => const Stream.empty()); + when(() => _playbackService.position).thenAnswer((_) => const Stream.empty()); } Future _initTextScale() async { diff --git a/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart b/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart index 84c9976..c7855ef 100644 --- a/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart +++ b/horatio/horatio_app/test/screens/demo_annotation_editor_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,7 +25,9 @@ Widget _buildDemo() { routes: [ GoRoute( path: '/demo', - builder: (context, state) => const DemoAnnotationEditorScreen(), + builder: (context, state) => DemoAnnotationEditorScreen.withSynthesiser( + (path, text) async {}, + ), ), GoRoute( path: '/annotation-history', @@ -149,4 +153,97 @@ void main() { }); }); }); + + group('synthesiseDemoSpeech', () { + late Directory tmpDir; + + setUp(() async { + tmpDir = await Directory.systemTemp.createTemp('horatio_tts_test_'); + }); + + tearDown(() async { + await tmpDir.delete(recursive: true); + }); + + test('espeak-ng fallback: creates a WAV file when piper model is absent', + () async { + final path = '${tmpDir.path}/hello.wav'; + // Pass a non-existent model path so the espeak-ng fallback is taken. + final result = await synthesiseDemoSpeech( + path, + 'Hello world.', + piperModel: '${tmpDir.path}/nonexistent.onnx', + ); + expect(result, path); + expect(File(path).existsSync(), isTrue); + expect(File(path).lengthSync(), greaterThan(44)); // has audio data + }); + + test('piper path: creates a WAV file using the installed model', () async { + final home = Platform.environment['HOME'] ?? '/root'; + final model = + '$home/.local/share/horatio/piper/en_US-lessac-high.onnx'; + if (!File(model).existsSync()) { + // Piper not installed \u2014 skip this path on machines without the model. + return; + } + final path = '${tmpDir.path}/hamlet.wav'; + final result = await synthesiseDemoSpeech( + path, + 'To be.', + piperModel: model, + ); + expect(result, path); + expect(File(path).existsSync(), isTrue); + expect(File(path).lengthSync(), greaterThan(44)); + }); + }); + + group('synthesiseDemoSpeechCached', () { + late Directory tmpDir; + + setUp(() async { + tmpDir = await Directory.systemTemp.createTemp('horatio_cache_test_'); + }); + + tearDown(() async { + await tmpDir.delete(recursive: true); + }); + + test('synthesises when file does not exist', () async { + final path = '${tmpDir.path}/new.wav'; + var called = false; + Future fakeSynth(String p, String t) async { + called = true; + await File(p).writeAsBytes([0, 1, 2]); // write something + return p; + } + + final result = await synthesiseDemoSpeechCached( + path, + 'hello', + synth: fakeSynth, + ); + expect(result, path); + expect(called, isTrue); + }); + + test('skips synthesis when file already exists', () async { + final path = '${tmpDir.path}/existing.wav'; + await File(path).writeAsBytes([0, 1, 2]); // pre-create + var called = false; + Future fakeSynth(String p, String t) async { + called = true; + return p; + } + + final result = await synthesiseDemoSpeechCached( + path, + 'hello', + synth: fakeSynth, + ); + expect(result, path); + expect(called, isFalse); // synthesis was skipped + }); + }); } diff --git a/pomodoro_app/pubspec.lock b/pomodoro_app/pubspec.lock index 51e620c..f434b28 100644 --- a/pomodoro_app/pubspec.lock +++ b/pomodoro_app/pubspec.lock @@ -252,10 +252,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -425,10 +425,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/pomodoro_app/test/main_test.dart b/pomodoro_app/test/main_test.dart new file mode 100644 index 0000000..6bb411e --- /dev/null +++ b/pomodoro_app/test/main_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pomodoro_app/main.dart'; + +void main() { + testWidgets('PomodoroApp builds and shows MaterialApp', (tester) async { + await tester.pumpWidget(const PomodoroApp()); + expect(find.byType(MaterialApp), findsOneWidget); + }); + + testWidgets('PomodoroApp uses dark theme', (tester) async { + await tester.pumpWidget(const PomodoroApp()); + final materialApp = tester.widget(find.byType(MaterialApp)); + expect(materialApp.debugShowCheckedModeBanner, false); + expect(materialApp.title, 'Pomodoro'); + expect(materialApp.theme, isNotNull); + }); +}