mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 15:23:06 +02:00
feat(home): add 'See a demo' button with seeded Hamlet annotation editor
- Add DemoAnnotationEditorScreen: wraps the real AnnotationEditorScreen with an in-memory Drift DB seeded with 6 lines of Hamlet's soliloquy, 4 TextMarks, 4 LineNotes, 4 LineRecordings (3 on line 0 with grades), and 1 AnnotationSnapshot — all ephemeral, zero writes to disk - Add /demo route to go_router - Show 'See a demo' OutlinedButton.icon on the empty library screen only - Tests: 6 widget tests for DemoAnnotationEditorScreen (including runAsync pattern for Drift real-time timer handling), 2 new home screen tests, and a router test for the /demo route All 366 tests pass, 100% branch coverage, flutter analyze --fatal-infos clean
This commit is contained in:
parent
c52969d8bb
commit
8b5d3e75f2
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:horatio_app/screens/annotation_editor_screen.dart';
|
import 'package:horatio_app/screens/annotation_editor_screen.dart';
|
||||||
import 'package:horatio_app/screens/annotation_history_screen.dart';
|
import 'package:horatio_app/screens/annotation_history_screen.dart';
|
||||||
|
import 'package:horatio_app/screens/demo_annotation_editor_screen.dart';
|
||||||
import 'package:horatio_app/screens/home_screen.dart';
|
import 'package:horatio_app/screens/home_screen.dart';
|
||||||
import 'package:horatio_app/screens/import_screen.dart';
|
import 'package:horatio_app/screens/import_screen.dart';
|
||||||
import 'package:horatio_app/screens/rehearsal_screen.dart';
|
import 'package:horatio_app/screens/rehearsal_screen.dart';
|
||||||
@ -35,6 +36,9 @@ abstract final class RoutePaths {
|
|||||||
|
|
||||||
/// Annotation history.
|
/// Annotation history.
|
||||||
static const String annotationHistory = '/annotation-history';
|
static const String annotationHistory = '/annotation-history';
|
||||||
|
|
||||||
|
/// Interactive demo — ephemeral in-memory Hamlet excerpt.
|
||||||
|
static const String demo = '/demo';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Application router configuration.
|
/// Application router configuration.
|
||||||
@ -120,11 +124,13 @@ final GoRouter appRouter = GoRouter(
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: RoutePaths.demo,
|
||||||
|
builder: (context, state) => const DemoAnnotationEditorScreen(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
errorBuilder: (context, state) => Scaffold(
|
errorBuilder: (context, state) => Scaffold(
|
||||||
appBar: AppBar(title: const Text('Not Found')),
|
appBar: AppBar(title: const Text('Not Found')),
|
||||||
body: Center(
|
body: Center(child: Text('Page not found: ${state.uri}')),
|
||||||
child: Text('Page not found: ${state.uri}'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,321 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.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/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_core/horatio_core.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
const _uuid = Uuid();
|
||||||
|
const _scriptId = 'demo-hamlet-soliloquy';
|
||||||
|
const _hamlet = Role(name: 'Hamlet');
|
||||||
|
|
||||||
|
/// Demo script — Hamlet's soliloquy (6 lines).
|
||||||
|
const _demoScript = Script(
|
||||||
|
id: _scriptId,
|
||||||
|
title: 'Hamlet — To be, or not to be (demo)',
|
||||||
|
roles: [_hamlet],
|
||||||
|
scenes: [
|
||||||
|
Scene(
|
||||||
|
title: 'Act III, Scene I',
|
||||||
|
lines: [
|
||||||
|
ScriptLine(
|
||||||
|
text: 'To be, or not to be, that is the question:',
|
||||||
|
role: _hamlet,
|
||||||
|
sceneIndex: 0,
|
||||||
|
lineIndex: 0,
|
||||||
|
),
|
||||||
|
ScriptLine(
|
||||||
|
text: "Whether 'tis nobler in the mind to suffer",
|
||||||
|
role: _hamlet,
|
||||||
|
sceneIndex: 0,
|
||||||
|
lineIndex: 1,
|
||||||
|
),
|
||||||
|
ScriptLine(
|
||||||
|
text: 'The slings and arrows of outrageous fortune,',
|
||||||
|
role: _hamlet,
|
||||||
|
sceneIndex: 0,
|
||||||
|
lineIndex: 2,
|
||||||
|
),
|
||||||
|
ScriptLine(
|
||||||
|
text: 'Or to take arms against a sea of troubles',
|
||||||
|
role: _hamlet,
|
||||||
|
sceneIndex: 0,
|
||||||
|
lineIndex: 3,
|
||||||
|
),
|
||||||
|
ScriptLine(
|
||||||
|
text: 'And by opposing end them.',
|
||||||
|
role: _hamlet,
|
||||||
|
sceneIndex: 0,
|
||||||
|
lineIndex: 4,
|
||||||
|
),
|
||||||
|
ScriptLine(
|
||||||
|
text: 'To die: to sleep; no more;',
|
||||||
|
role: _hamlet,
|
||||||
|
sceneIndex: 0,
|
||||||
|
lineIndex: 5,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Wraps [AnnotationEditorScreen] with a fully in-memory database seeded with
|
||||||
|
/// realistic demo annotations, notes, and recordings.
|
||||||
|
///
|
||||||
|
/// All data lives only in RAM — nothing is written to disk or the real DB.
|
||||||
|
/// The demo is fully interactive: users can add/edit marks and notes while
|
||||||
|
/// exploring the screen.
|
||||||
|
class DemoAnnotationEditorScreen extends StatefulWidget {
|
||||||
|
/// Creates a [DemoAnnotationEditorScreen].
|
||||||
|
const DemoAnnotationEditorScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DemoAnnotationEditorScreen> createState() =>
|
||||||
|
_DemoAnnotationEditorScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DemoAnnotationEditorScreenState
|
||||||
|
extends State<DemoAnnotationEditorScreen> {
|
||||||
|
late final AppDatabase _db;
|
||||||
|
late final RecordingService _recordingService;
|
||||||
|
late final AudioPlaybackService _playbackService;
|
||||||
|
final String _recordingsDir =
|
||||||
|
'${Directory.systemTemp.path}/horatio_demo_recordings';
|
||||||
|
|
||||||
|
bool _ready = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_db = AppDatabase(NativeDatabase.memory());
|
||||||
|
_recordingService = RecordingService();
|
||||||
|
_playbackService = AudioPlaybackService();
|
||||||
|
_seedAndMarkReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _seedAndMarkReady() async {
|
||||||
|
await _seed(_db.annotationDao, _db.recordingDao);
|
||||||
|
if (mounted) setState(() => _ready = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_db.close();
|
||||||
|
_recordingService.dispose();
|
||||||
|
_playbackService.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_ready) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
return MultiRepositoryProvider(
|
||||||
|
providers: [
|
||||||
|
RepositoryProvider<AnnotationDao>.value(value: _db.annotationDao),
|
||||||
|
RepositoryProvider<RecordingDao>.value(value: _db.recordingDao),
|
||||||
|
RepositoryProvider<RecordingService>.value(value: _recordingService),
|
||||||
|
RepositoryProvider<AudioPlaybackService>.value(value: _playbackService),
|
||||||
|
RepositoryProvider<String>.value(value: _recordingsDir),
|
||||||
|
],
|
||||||
|
child: const AnnotationEditorScreen(script: _demoScript),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seeds the in-memory DAOs with a realistic demo dataset.
|
||||||
|
Future<void> _seed(AnnotationDao dao, RecordingDao rDao) async {
|
||||||
|
const scriptId = _scriptId;
|
||||||
|
final week1 = DateTime.utc(2026, 1, 15, 19);
|
||||||
|
final week2 = DateTime.utc(2026, 1, 22, 20);
|
||||||
|
final week3 = DateTime.utc(2026, 2, 1, 18);
|
||||||
|
final now = DateTime.utc(2026, 3, 15, 21);
|
||||||
|
|
||||||
|
// ── Text marks ──────────────────────────────────────────────────────────
|
||||||
|
// Collect marks as we insert them so the snapshot doesn't need stream reads.
|
||||||
|
final marks = <TextMark>[];
|
||||||
|
|
||||||
|
TextMark newMark({
|
||||||
|
required int lineIndex,
|
||||||
|
required int startOffset,
|
||||||
|
required int endOffset,
|
||||||
|
required MarkType type,
|
||||||
|
required DateTime createdAt,
|
||||||
|
}) => TextMark(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
lineIndex: lineIndex,
|
||||||
|
startOffset: startOffset,
|
||||||
|
endOffset: endOffset,
|
||||||
|
type: type,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Line 0: stress "To be" + slow down the question;
|
||||||
|
// Line 2: emphasis on "slings and arrows";
|
||||||
|
// Line 4: breath before ending.
|
||||||
|
marks
|
||||||
|
..add(
|
||||||
|
newMark(
|
||||||
|
lineIndex: 0,
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: 5,
|
||||||
|
type: MarkType.stress,
|
||||||
|
createdAt: week1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
newMark(
|
||||||
|
lineIndex: 0,
|
||||||
|
startOffset: 16,
|
||||||
|
endOffset: 43,
|
||||||
|
type: MarkType.slowDown,
|
||||||
|
createdAt: week2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
newMark(
|
||||||
|
lineIndex: 2,
|
||||||
|
startOffset: 4,
|
||||||
|
endOffset: 20,
|
||||||
|
type: MarkType.emphasis,
|
||||||
|
createdAt: week1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
newMark(
|
||||||
|
lineIndex: 4,
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: 13,
|
||||||
|
type: MarkType.breath,
|
||||||
|
createdAt: week3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (final m in marks) {
|
||||||
|
await dao.insertMark(scriptId, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Line notes ──────────────────────────────────────────────────────────
|
||||||
|
final notes = <LineNote>[];
|
||||||
|
|
||||||
|
LineNote newNote({
|
||||||
|
required int lineIndex,
|
||||||
|
required NoteCategory category,
|
||||||
|
required String text,
|
||||||
|
required DateTime createdAt,
|
||||||
|
}) => LineNote(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
lineIndex: lineIndex,
|
||||||
|
category: category,
|
||||||
|
text: text,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
notes
|
||||||
|
..add(
|
||||||
|
newNote(
|
||||||
|
lineIndex: 1,
|
||||||
|
category: NoteCategory.delivery,
|
||||||
|
text: 'Breathe slowly before this line — let the weight land',
|
||||||
|
createdAt: week1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
newNote(
|
||||||
|
lineIndex: 3,
|
||||||
|
category: NoteCategory.blocking,
|
||||||
|
text: 'Step downstage, half-turn to audience on "Or to take arms"',
|
||||||
|
createdAt: week2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
newNote(
|
||||||
|
lineIndex: 3,
|
||||||
|
category: NoteCategory.intention,
|
||||||
|
text: 'He has already decided — resignation, not a genuine question',
|
||||||
|
createdAt: week3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
newNote(
|
||||||
|
lineIndex: 0,
|
||||||
|
category: NoteCategory.subtext,
|
||||||
|
text: "Staring at nothing. The audience exists; he doesn't see them",
|
||||||
|
createdAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (final n in notes) {
|
||||||
|
await dao.insertNote(scriptId, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recordings (metadata only — paths are illustrative) ─────────────────
|
||||||
|
// Line 0: three recordings showing progression.
|
||||||
|
await rDao.insertRecording(
|
||||||
|
scriptId,
|
||||||
|
LineRecording(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
scriptId: scriptId,
|
||||||
|
lineIndex: 0,
|
||||||
|
filePath: '/demo/hamlet_line0_take1.m4a',
|
||||||
|
durationMs: 9800,
|
||||||
|
createdAt: week1,
|
||||||
|
grade: 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await rDao.insertRecording(
|
||||||
|
scriptId,
|
||||||
|
LineRecording(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
scriptId: scriptId,
|
||||||
|
lineIndex: 0,
|
||||||
|
filePath: '/demo/hamlet_line0_take2.m4a',
|
||||||
|
durationMs: 8400,
|
||||||
|
createdAt: week2,
|
||||||
|
grade: 4,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await rDao.insertRecording(
|
||||||
|
scriptId,
|
||||||
|
LineRecording(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
scriptId: scriptId,
|
||||||
|
lineIndex: 0,
|
||||||
|
filePath: '/demo/hamlet_line0_take3.m4a',
|
||||||
|
durationMs: 7600,
|
||||||
|
createdAt: week3,
|
||||||
|
grade: 5,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Line 1: one recording.
|
||||||
|
await rDao.insertRecording(
|
||||||
|
scriptId,
|
||||||
|
LineRecording(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
scriptId: scriptId,
|
||||||
|
lineIndex: 1,
|
||||||
|
filePath: '/demo/hamlet_line1_take1.m4a',
|
||||||
|
durationMs: 6200,
|
||||||
|
createdAt: week2,
|
||||||
|
grade: 3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Annotation snapshot (history entry from week 1) ──────────────────────
|
||||||
|
// Use the already-collected marks/notes — no stream reads needed.
|
||||||
|
await dao.insertSnapshot(
|
||||||
|
AnnotationSnapshot(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
scriptId: scriptId,
|
||||||
|
timestamp: week1,
|
||||||
|
marks: marks,
|
||||||
|
notes: notes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -52,135 +52,116 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Horatio'),
|
title: const Text('Horatio'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.text_fields),
|
icon: const Icon(Icons.text_fields),
|
||||||
tooltip: 'Text Size',
|
tooltip: 'Text Size',
|
||||||
onPressed: () => showModalBottomSheet<void>(
|
onPressed: () => showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => BlocProvider.value(
|
builder: (_) => BlocProvider.value(
|
||||||
value: context.read<TextScaleCubit>(),
|
value: context.read<TextScaleCubit>(),
|
||||||
child: const TextScaleSettingsSheet(),
|
child: const TextScaleSettingsSheet(),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
body: DropTarget(
|
|
||||||
onDragDone: _handleDrop,
|
|
||||||
onDragEntered: (_) => setState(() => _isDragging = true),
|
|
||||||
onDragExited: (_) => setState(() => _isDragging = false),
|
|
||||||
child: BlocBuilder<ScriptImportCubit, ScriptImportState>(
|
|
||||||
builder: (context, state) => switch (state) {
|
|
||||||
ScriptImportLoading() => const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
ScriptImportError(:final message) => Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.error_outline,
|
|
||||||
size: 48,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(message, textAlign: TextAlign.center),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => context
|
|
||||||
.read<ScriptImportCubit>()
|
|
||||||
.loadScripts(),
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ScriptImportLoaded(:final scripts)
|
|
||||||
when scripts.isEmpty =>
|
|
||||||
_EmptyLibrary(isDragging: _isDragging),
|
|
||||||
ScriptImportLoaded(:final scripts) => Stack(
|
|
||||||
children: [
|
|
||||||
ListView.builder(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
itemCount: scripts.length,
|
|
||||||
itemBuilder: (context, index) => ScriptCardWidget(
|
|
||||||
script: scripts[index],
|
|
||||||
onTap: () => context.push(
|
|
||||||
RoutePaths.roleSelection,
|
|
||||||
extra: scripts[index],
|
|
||||||
),
|
|
||||||
onDelete: () => context
|
|
||||||
.read<ScriptImportCubit>()
|
|
||||||
.removeScript(index),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_isDragging)
|
|
||||||
Positioned.fill(
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.primary
|
|
||||||
.withValues(alpha: 0.1),
|
|
||||||
border: Border.all(
|
|
||||||
color:
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
width: 3,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.file_download,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Drop script file here',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.headlineSmall
|
|
||||||
?.copyWith(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'.txt .docx .pdf',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.bodyMedium
|
|
||||||
?.copyWith(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.primary
|
|
||||||
.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
ScriptImportInitial() =>
|
|
||||||
_EmptyLibrary(isDragging: _isDragging),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
|
),
|
||||||
|
body: DropTarget(
|
||||||
|
onDragDone: _handleDrop,
|
||||||
|
onDragEntered: (_) => setState(() => _isDragging = true),
|
||||||
|
onDragExited: (_) => setState(() => _isDragging = false),
|
||||||
|
child: BlocBuilder<ScriptImportCubit, ScriptImportState>(
|
||||||
|
builder: (context, state) => switch (state) {
|
||||||
|
ScriptImportLoading() => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
ScriptImportError(:final message) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(message, textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () =>
|
||||||
|
context.read<ScriptImportCubit>().loadScripts(),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ScriptImportLoaded(:final scripts) when scripts.isEmpty =>
|
||||||
|
_EmptyLibrary(isDragging: _isDragging),
|
||||||
|
ScriptImportLoaded(:final scripts) => Stack(
|
||||||
|
children: [
|
||||||
|
ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: scripts.length,
|
||||||
|
itemBuilder: (context, index) => ScriptCardWidget(
|
||||||
|
script: scripts[index],
|
||||||
|
onTap: () => context.push(
|
||||||
|
RoutePaths.roleSelection,
|
||||||
|
extra: scripts[index],
|
||||||
|
),
|
||||||
|
onDelete: () =>
|
||||||
|
context.read<ScriptImportCubit>().removeScript(index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isDragging)
|
||||||
|
Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withValues(alpha: 0.1),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
width: 3,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.file_download,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Drop script file here',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'.txt .docx .pdf',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ScriptImportInitial() => _EmptyLibrary(isDragging: _isDragging),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bundled public domain script metadata for the suggestion cards.
|
/// Bundled public domain script metadata for the suggestion cards.
|
||||||
@ -245,8 +226,7 @@ class _EmptyLibrary extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () =>
|
onTap: () => context.read<ScriptImportCubit>().importFromFile(),
|
||||||
context.read<ScriptImportCubit>().importFromFile(),
|
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
@ -272,23 +252,18 @@ class _EmptyLibrary extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isDragging
|
isDragging ? Icons.file_download : Icons.upload_file,
|
||||||
? Icons.file_download
|
|
||||||
: Icons.upload_file,
|
|
||||||
size: 72,
|
size: 72,
|
||||||
color: isDragging
|
color: isDragging
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
: colorScheme.primary
|
: colorScheme.primary.withValues(alpha: 0.6),
|
||||||
.withValues(alpha: 0.6),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
isDragging
|
isDragging
|
||||||
? 'Drop to import'
|
? 'Drop to import'
|
||||||
: 'Drop or click to import file',
|
: 'Drop or click to import file',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context).textTheme.headlineSmall
|
||||||
.textTheme
|
|
||||||
.headlineSmall
|
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: isDragging
|
color: isDragging
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
@ -299,12 +274,9 @@ class _EmptyLibrary extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Supports .txt .docx .pdf',
|
'Supports .txt .docx .pdf',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
.textTheme
|
color: colorScheme.onSurfaceVariant,
|
||||||
.bodyMedium
|
),
|
||||||
?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -313,11 +285,20 @@ class _EmptyLibrary extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.play_circle_outline),
|
||||||
|
label: const Text('See a demo'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size(double.infinity, 48),
|
||||||
|
),
|
||||||
|
onPressed: () => context.push(RoutePaths.demo),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'or try a classic',
|
'or try a classic',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@ -332,9 +313,9 @@ class _EmptyLibrary extends StatelessWidget {
|
|||||||
title: Text(entry.title),
|
title: Text(entry.title),
|
||||||
subtitle: Text(entry.author),
|
subtitle: Text(entry.author),
|
||||||
trailing: const Icon(Icons.download),
|
trailing: const Icon(Icons.download),
|
||||||
onTap: () => context
|
onTap: () => context.read<ScriptImportCubit>().importFromAsset(
|
||||||
.read<ScriptImportCubit>()
|
entry.assetPath,
|
||||||
.importFromAsset(entry.assetPath),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -379,10 +360,7 @@ class _DashedBorderPainter extends CustomPainter {
|
|||||||
var distance = 0.0;
|
var distance = 0.0;
|
||||||
while (distance < metric.length) {
|
while (distance < metric.length) {
|
||||||
final end = (distance + dashWidth).clamp(0.0, metric.length);
|
final end = (distance + dashWidth).clamp(0.0, metric.length);
|
||||||
dashedPath.addPath(
|
dashedPath.addPath(metric.extractPath(distance, end), Offset.zero);
|
||||||
metric.extractPath(distance, end),
|
|
||||||
Offset.zero,
|
|
||||||
);
|
|
||||||
distance += dashWidth + dashSpace;
|
distance += dashWidth + dashSpace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,20 +41,20 @@ Widget _wrapRouter() {
|
|||||||
() => mockRecordingDao.watchRecordingsForScript(any()),
|
() => mockRecordingDao.watchRecordingsForScript(any()),
|
||||||
).thenAnswer((_) => Stream.value([]));
|
).thenAnswer((_) => Stream.value([]));
|
||||||
|
|
||||||
when(
|
when(mockRecordingService.hasPermission).thenAnswer((_) async => true);
|
||||||
() => mockRecordingService.hasPermission(),
|
|
||||||
).thenAnswer((_) async => true);
|
|
||||||
when(
|
when(
|
||||||
() => mockRecordingService.startRecording(any()),
|
() => mockRecordingService.startRecording(any()),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async {});
|
||||||
when(
|
when(mockRecordingService.stopRecording).thenAnswer((_) async => null);
|
||||||
() => mockRecordingService.stopRecording(),
|
|
||||||
).thenAnswer((_) async => null);
|
|
||||||
|
|
||||||
when(() => mockPlaybackService.play(any())).thenAnswer((_) async {});
|
when(() => mockPlaybackService.play(any())).thenAnswer((_) async {});
|
||||||
when(() => mockPlaybackService.stop()).thenAnswer((_) async {});
|
when(mockPlaybackService.stop).thenAnswer((_) async {});
|
||||||
when(() => mockPlaybackService.status).thenAnswer((_) => Stream.empty());
|
when(
|
||||||
when(() => mockPlaybackService.position).thenAnswer((_) => Stream.empty());
|
() => mockPlaybackService.status,
|
||||||
|
).thenAnswer((_) => const Stream.empty());
|
||||||
|
when(
|
||||||
|
() => mockPlaybackService.position,
|
||||||
|
).thenAnswer((_) => const Stream.empty());
|
||||||
|
|
||||||
return MultiRepositoryProvider(
|
return MultiRepositoryProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@ -306,5 +306,35 @@ void main() {
|
|||||||
// Redirected to home.
|
// Redirected to home.
|
||||||
expect(find.text('Horatio'), findsOneWidget);
|
expect(find.text('Horatio'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.pumpWidget(_wrapRouter());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
appRouter.go(RoutePaths.demo);
|
||||||
|
// Process the navigation frame.
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Wait for seeding to complete in real time.
|
||||||
|
await Future<void>.delayed(const Duration(seconds: 2));
|
||||||
|
await tester.pump();
|
||||||
|
// Allow Drift initial stream deliveries.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.textContaining('Hamlet', findRichText: true), findsWidgets);
|
||||||
|
|
||||||
|
// Replace entire widget tree to force 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<void>.delayed(const Duration(milliseconds: 500));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,152 @@
|
|||||||
|
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/text_scale/text_scale_cubit.dart';
|
||||||
|
import 'package:horatio_app/screens/annotation_editor_screen.dart';
|
||||||
|
import 'package:horatio_app/screens/demo_annotation_editor_screen.dart';
|
||||||
|
import 'package:horatio_app/widgets/note_chip.dart';
|
||||||
|
import 'package:horatio_app/widgets/recording_action_bar.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
late TextScaleCubit _textScaleCubit;
|
||||||
|
|
||||||
|
Future<void> _initTextScale() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_textScaleCubit = TextScaleCubit(prefs: prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDemo() {
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/demo',
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/demo',
|
||||||
|
builder: (context, state) => const DemoAnnotationEditorScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/annotation-history',
|
||||||
|
builder: (context, state) =>
|
||||||
|
const Scaffold(body: Text('History Screen')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return BlocProvider<TextScaleCubit>.value(
|
||||||
|
value: _textScaleCubit,
|
||||||
|
child: MaterialApp.router(routerConfig: router),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a demo screen widget test entirely inside [tester.runAsync].
|
||||||
|
///
|
||||||
|
/// [DemoAnnotationEditorScreen] creates a real Drift in-memory database.
|
||||||
|
/// Drift schedules timers for initial stream data delivery and for cleanup on
|
||||||
|
/// disposal (via [StreamQueryStore.markAsClosed] when cubits close and cancel
|
||||||
|
/// stream subscriptions). Running the full lifecycle — pump, seed wait, cubit
|
||||||
|
/// init, assertions, and explicit teardown — inside [tester.runAsync] ensures
|
||||||
|
/// every timer fires in real time and is never left pending as a fake-async
|
||||||
|
/// timer when the test ends.
|
||||||
|
Future<void> _runDemoTest(
|
||||||
|
WidgetTester tester,
|
||||||
|
Future<void> Function() assertions,
|
||||||
|
) async {
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.pumpWidget(_buildDemo());
|
||||||
|
// Seeding completes in real time.
|
||||||
|
await Future<void>.delayed(const Duration(seconds: 2));
|
||||||
|
// Rebuild with _ready = true; creates AnnotationCubit + RecordingCubit
|
||||||
|
// which subscribe to Drift streams.
|
||||||
|
await tester.pump();
|
||||||
|
// Allow Drift's initial stream deliveries to fire in real time.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
|
// Settle cubit-driven rebuilds.
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await assertions();
|
||||||
|
|
||||||
|
// Teardown 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<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() async {
|
||||||
|
await _initTextScale();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() => _textScaleCubit.close());
|
||||||
|
|
||||||
|
group('DemoAnnotationEditorScreen', () {
|
||||||
|
testWidgets('shows loading indicator while seeding', (tester) async {
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.pumpWidget(_buildDemo());
|
||||||
|
await tester.pump();
|
||||||
|
// Immediately after the first frame, seeding is still in progress.
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
|
// Let seeding finish before disposing so the in-flight DB inserts
|
||||||
|
// don't hit a closed database.
|
||||||
|
await Future<void>.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders AnnotationEditorScreen after seeding', (tester) async {
|
||||||
|
await _runDemoTest(tester, () async {
|
||||||
|
expect(find.byType(AnnotationEditorScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows Hamlet title in app bar', (tester) async {
|
||||||
|
await _runDemoTest(tester, () async {
|
||||||
|
expect(find.textContaining('Hamlet'), findsWidgets);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows demo script lines', (tester) async {
|
||||||
|
await _runDemoTest(tester, () async {
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'To be, or not to be, that is the question:',
|
||||||
|
findRichText: true,
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping a line shows RecordingActionBar', (tester) async {
|
||||||
|
await _runDemoTest(tester, () async {
|
||||||
|
await tester.tap(
|
||||||
|
find.text(
|
||||||
|
'To be, or not to be, that is the question:',
|
||||||
|
findRichText: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(RecordingActionBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('demo data shows note chips on seeded line', (tester) async {
|
||||||
|
await _runDemoTest(tester, () async {
|
||||||
|
// Line 3 has two seeded notes (blocking + intention).
|
||||||
|
await tester.tap(
|
||||||
|
find.text(
|
||||||
|
'Or to take arms against a sea of troubles',
|
||||||
|
findRichText: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(NoteChip), findsWidgets);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -25,14 +25,15 @@ Widget _wrap(ScriptImportCubit cubit) {
|
|||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/',
|
initialLocation: '/',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
|
||||||
path: '/',
|
|
||||||
builder: (context, state) => const HomeScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/role-selection',
|
path: '/role-selection',
|
||||||
builder: (context, state) => const Scaffold(),
|
builder: (context, state) => const Scaffold(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/demo',
|
||||||
|
builder: (context, state) => const Scaffold(body: Text('Demo Screen')),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return MultiBlocProvider(
|
return MultiBlocProvider(
|
||||||
@ -42,9 +43,7 @@ Widget _wrap(ScriptImportCubit cubit) {
|
|||||||
],
|
],
|
||||||
child: MultiRepositoryProvider(
|
child: MultiRepositoryProvider(
|
||||||
providers: [
|
providers: [
|
||||||
RepositoryProvider<ScriptRepository>(
|
RepositoryProvider<ScriptRepository>(create: (_) => ScriptRepository()),
|
||||||
create: (_) => ScriptRepository(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(routerConfig: router),
|
child: MaterialApp.router(routerConfig: router),
|
||||||
),
|
),
|
||||||
@ -62,10 +61,12 @@ void main() {
|
|||||||
when(() => cubit.loadScripts()).thenReturn(null);
|
when(() => cubit.loadScripts()).thenReturn(null);
|
||||||
when(() => cubit.importFromFile()).thenAnswer((_) async {});
|
when(() => cubit.importFromFile()).thenAnswer((_) async {});
|
||||||
when(() => cubit.importFromAsset(any())).thenAnswer((_) async {});
|
when(() => cubit.importFromAsset(any())).thenAnswer((_) async {});
|
||||||
when(() => cubit.importFromBytes(
|
when(
|
||||||
bytes: any(named: 'bytes'),
|
() => cubit.importFromBytes(
|
||||||
fileName: any(named: 'fileName'),
|
bytes: any(named: 'bytes'),
|
||||||
)).thenAnswer((_) async {});
|
fileName: any(named: 'fileName'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() => _textScaleCubit.close());
|
tearDown(() => _textScaleCubit.close());
|
||||||
@ -75,8 +76,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('HomeScreen states', () {
|
group('HomeScreen states', () {
|
||||||
testWidgets('shows loading indicator for ScriptImportLoading',
|
testWidgets('shows loading indicator for ScriptImportLoading', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
when(() => cubit.state).thenReturn(const ScriptImportLoading());
|
when(() => cubit.state).thenReturn(const ScriptImportLoading());
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(cubit));
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
@ -86,8 +88,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows error view for ScriptImportError', (tester) async {
|
testWidgets('shows error view for ScriptImportError', (tester) async {
|
||||||
when(() => cubit.state)
|
when(
|
||||||
.thenReturn(const ScriptImportError(message: 'Disk full'));
|
() => cubit.state,
|
||||||
|
).thenReturn(const ScriptImportError(message: 'Disk full'));
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(cubit));
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@ -98,8 +101,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Retry button calls loadScripts', (tester) async {
|
testWidgets('Retry button calls loadScripts', (tester) async {
|
||||||
when(() => cubit.state)
|
when(
|
||||||
.thenReturn(const ScriptImportError(message: 'Fail'));
|
() => cubit.state,
|
||||||
|
).thenReturn(const ScriptImportError(message: 'Fail'));
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(cubit));
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@ -113,8 +117,9 @@ void main() {
|
|||||||
verify(() => cubit.loadScripts()).called(1);
|
verify(() => cubit.loadScripts()).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows script list for loaded state with scripts',
|
testWidgets('shows script list for loaded state with scripts', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
const role = Role(name: 'Hero');
|
const role = Role(name: 'Hero');
|
||||||
const script = Script(
|
const script = Script(
|
||||||
id: 'home-my-play-id',
|
id: 'home-my-play-id',
|
||||||
@ -133,8 +138,9 @@ void main() {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
when(() => cubit.state)
|
when(
|
||||||
.thenReturn(const ScriptImportLoaded(scripts: [script]));
|
() => cubit.state,
|
||||||
|
).thenReturn(const ScriptImportLoaded(scripts: [script]));
|
||||||
when(() => cubit.removeScript(any())).thenReturn(null);
|
when(() => cubit.removeScript(any())).thenReturn(null);
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(cubit));
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
@ -144,8 +150,9 @@ void main() {
|
|||||||
expect(find.text('Horatio'), findsOneWidget); // App bar.
|
expect(find.text('Horatio'), findsOneWidget); // App bar.
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('delete button on script card calls removeScript',
|
testWidgets('delete button on script card calls removeScript', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
const role = Role(name: 'Hero');
|
const role = Role(name: 'Hero');
|
||||||
const script = Script(
|
const script = Script(
|
||||||
id: 'home-play-id',
|
id: 'home-play-id',
|
||||||
@ -154,18 +161,14 @@ void main() {
|
|||||||
scenes: [
|
scenes: [
|
||||||
Scene(
|
Scene(
|
||||||
lines: [
|
lines: [
|
||||||
ScriptLine(
|
ScriptLine(text: 'Hi.', role: role, sceneIndex: 0, lineIndex: 0),
|
||||||
text: 'Hi.',
|
|
||||||
role: role,
|
|
||||||
sceneIndex: 0,
|
|
||||||
lineIndex: 0,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
when(() => cubit.state)
|
when(
|
||||||
.thenReturn(const ScriptImportLoaded(scripts: [script]));
|
() => cubit.state,
|
||||||
|
).thenReturn(const ScriptImportLoaded(scripts: [script]));
|
||||||
when(() => cubit.removeScript(any())).thenReturn(null);
|
when(() => cubit.removeScript(any())).thenReturn(null);
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(cubit));
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
@ -177,6 +180,30 @@ void main() {
|
|||||||
verify(() => cubit.removeScript(0)).called(1);
|
verify(() => cubit.removeScript(0)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('shows See a demo button in empty library state', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
||||||
|
|
||||||
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('See a demo'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.play_circle_outline), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping See a demo navigates to /demo', (tester) async {
|
||||||
|
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
||||||
|
|
||||||
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('See a demo'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Demo Screen'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('tapping import zone calls importFromFile', (tester) async {
|
testWidgets('tapping import zone calls importFromFile', (tester) async {
|
||||||
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
||||||
|
|
||||||
@ -193,8 +220,9 @@ void main() {
|
|||||||
verify(() => cubit.importFromFile()).called(1);
|
verify(() => cubit.importFromFile()).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping public domain script calls importFromAsset',
|
testWidgets('tapping public domain script calls importFromAsset', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(cubit));
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
@ -214,8 +242,9 @@ void main() {
|
|||||||
DropTarget findDropTarget(WidgetTester tester) =>
|
DropTarget findDropTarget(WidgetTester tester) =>
|
||||||
tester.widget<DropTarget>(find.byType(DropTarget));
|
tester.widget<DropTarget>(find.byType(DropTarget));
|
||||||
|
|
||||||
testWidgets('onDragEntered sets isDragging true in empty library',
|
testWidgets('onDragEntered sets isDragging true in empty library', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(cubit));
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
@ -287,14 +316,17 @@ void main() {
|
|||||||
});
|
});
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
verify(() => cubit.importFromBytes(
|
verify(
|
||||||
bytes: any(named: 'bytes'),
|
() => cubit.importFromBytes(
|
||||||
fileName: 'script.txt',
|
bytes: any(named: 'bytes'),
|
||||||
)).called(1);
|
fileName: 'script.txt',
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('onDragDone shows snackbar for unsupported file type',
|
testWidgets('onDragDone shows snackbar for unsupported file type', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
when(() => cubit.state).thenReturn(const ScriptImportInitial());
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(cubit));
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
@ -319,8 +351,9 @@ void main() {
|
|||||||
expect(find.text('Unsupported file type: .xyz'), findsOneWidget);
|
expect(find.text('Unsupported file type: .xyz'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('drag overlay appears in loaded-with-scripts state',
|
testWidgets('drag overlay appears in loaded-with-scripts state', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
const role = Role(name: 'Hero');
|
const role = Role(name: 'Hero');
|
||||||
const script = Script(
|
const script = Script(
|
||||||
id: 'home-drag-id',
|
id: 'home-drag-id',
|
||||||
@ -329,18 +362,14 @@ void main() {
|
|||||||
scenes: [
|
scenes: [
|
||||||
Scene(
|
Scene(
|
||||||
lines: [
|
lines: [
|
||||||
ScriptLine(
|
ScriptLine(text: 'Hi.', role: role, sceneIndex: 0, lineIndex: 0),
|
||||||
text: 'Hi.',
|
|
||||||
role: role,
|
|
||||||
sceneIndex: 0,
|
|
||||||
lineIndex: 0,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
when(() => cubit.state)
|
when(
|
||||||
.thenReturn(const ScriptImportLoaded(scripts: [script]));
|
() => cubit.state,
|
||||||
|
).thenReturn(const ScriptImportLoaded(scripts: [script]));
|
||||||
when(() => cubit.removeScript(any())).thenReturn(null);
|
when(() => cubit.removeScript(any())).thenReturn(null);
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(cubit));
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
@ -359,8 +388,9 @@ void main() {
|
|||||||
expect(find.text('.txt .docx .pdf'), findsOneWidget);
|
expect(find.text('.txt .docx .pdf'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping script card navigates to role-selection',
|
testWidgets('tapping script card navigates to role-selection', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
const role = Role(name: 'Hero');
|
const role = Role(name: 'Hero');
|
||||||
const script = Script(
|
const script = Script(
|
||||||
id: 'home-nav-id',
|
id: 'home-nav-id',
|
||||||
@ -379,8 +409,9 @@ void main() {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
when(() => cubit.state)
|
when(
|
||||||
.thenReturn(const ScriptImportLoaded(scripts: [script]));
|
() => cubit.state,
|
||||||
|
).thenReturn(const ScriptImportLoaded(scripts: [script]));
|
||||||
when(() => cubit.removeScript(any())).thenReturn(null);
|
when(() => cubit.removeScript(any())).thenReturn(null);
|
||||||
|
|
||||||
await tester.pumpWidget(_wrap(cubit));
|
await tester.pumpWidget(_wrap(cubit));
|
||||||
@ -401,10 +432,10 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Find the CustomPaint that uses _DashedBorderPainter.
|
// Find the CustomPaint that uses _DashedBorderPainter.
|
||||||
final customPaints =
|
final customPaints = tester.widgetList<CustomPaint>(
|
||||||
tester.widgetList<CustomPaint>(find.byType(CustomPaint));
|
find.byType(CustomPaint),
|
||||||
final dashedWidget =
|
);
|
||||||
customPaints.firstWhere((cp) => cp.painter != null);
|
final dashedWidget = customPaints.firstWhere((cp) => cp.painter != null);
|
||||||
final painter = dashedWidget.painter!;
|
final painter = dashedWidget.painter!;
|
||||||
|
|
||||||
// Same instance → all comparisons evaluate to false → every branch
|
// Same instance → all comparisons evaluate to false → every branch
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user