From da9727a21df9fcc969486bc5ad574eb08bc0d554 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 29 Mar 2026 14:44:57 +0200 Subject: [PATCH] feat(horatio): add Horatio actor script memorization app Two-package monorepo: - horatio_core: pure Dart package (parser, SRS, planner) - horatio_app: Flutter UI (Bloc/Cubit, GoRouter, TTS) Features: - Script import (txt, docx, pdf) with drag-and-drop - Four script format parsers (colon, bracketed, parenthetical, screenplay) - SM-2 spaced repetition for line memorization - Rehearsal mode with TTS and line comparison - 5 bundled public domain scripts Quality: - 83 core tests + 160 app tests, both 100% branch coverage - Strict analysis (130+ lint rules, fatal-infos) - Dead code detection script (dead_code.sh) - run.sh pipeline: analyze, test, dead-code, run, web - Pre-commit hook for horatio test coverage --- .pre-commit-config.yaml | 18 +- horatio/analysis_options.yaml | 181 +++ horatio/dead_code.sh | 97 ++ .../specs/2026-03-28-horatio-design.md | 99 ++ horatio/horatio_app/.gitignore | 45 + horatio/horatio_app/.metadata | 30 + horatio/horatio_app/README.md | 17 + horatio/horatio_app/analysis_options.yaml | 1 + .../public_domain/cherry_orchard_act1.json | 5 + .../public_domain/dolls_house_act3.json | 5 + .../public_domain/hamlet_act3_scene1.json | 5 + .../importance_of_being_earnest_act1.json | 5 + .../romeo_juliet_act2_scene2.json | 5 + horatio/horatio_app/lib/app.dart | 43 + .../lib/bloc/rehearsal/rehearsal_cubit.dart | 157 +++ .../lib/bloc/rehearsal/rehearsal_state.dart | 125 ++ .../script_import/script_import_cubit.dart | 105 ++ .../script_import/script_import_state.dart | 45 + .../lib/bloc/srs_review/srs_review_cubit.dart | 73 ++ .../lib/bloc/srs_review/srs_review_state.dart | 57 + horatio/horatio_app/lib/main.dart | 12 + horatio/horatio_app/lib/router.dart | 100 ++ .../horatio_app/lib/screens/home_screen.dart | 383 +++++++ .../lib/screens/import_screen.dart | 187 +++ .../lib/screens/rehearsal_screen.dart | 438 +++++++ .../lib/screens/role_selection_screen.dart | 108 ++ .../lib/screens/schedule_screen.dart | 128 +++ .../lib/screens/srs_review_screen.dart | 210 ++++ .../lib/services/file_import_service.dart | 115 ++ .../lib/services/script_repository.dart | 26 + .../lib/services/speech_service.dart | 168 +++ .../horatio_app/lib/services/tts_service.dart | 37 + horatio/horatio_app/lib/theme/app_theme.dart | 98 ++ .../horatio_app/lib/widgets/grade_badge.dart | 28 + .../lib/widgets/line_diff_widget.dart | 38 + .../lib/widgets/script_card_widget.dart | 45 + horatio/horatio_app/linux/.gitignore | 1 + horatio/horatio_app/linux/CMakeLists.txt | 128 +++ .../horatio_app/linux/flutter/CMakeLists.txt | 88 ++ .../flutter/generated_plugin_registrant.cc | 19 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 25 + .../horatio_app/linux/runner/CMakeLists.txt | 26 + horatio/horatio_app/linux/runner/main.cc | 6 + .../linux/runner/my_application.cc | 148 +++ .../horatio_app/linux/runner/my_application.h | 18 + horatio/horatio_app/pubspec.lock | 1006 +++++++++++++++++ horatio/horatio_app/pubspec.yaml | 44 + horatio/horatio_app/test/app_test.dart | 30 + .../test/bloc/rehearsal_cubit_test.dart | 229 ++++ .../test/bloc/script_import_cubit_test.dart | 306 +++++ .../test/bloc/srs_review_cubit_test.dart | 133 +++ horatio/horatio_app/test/router_test.dart | 170 +++ .../test/screens/home_screen_test.dart | 399 +++++++ .../test/screens/import_screen_test.dart | 209 ++++ .../test/screens/rehearsal_screen_test.dart | 773 +++++++++++++ .../screens/role_selection_screen_test.dart | 157 +++ .../test/screens/schedule_screen_test.dart | 133 +++ .../test/screens/srs_review_screen_test.dart | 181 +++ .../services/file_import_service_test.dart | 205 ++++ .../test/services/script_repository_test.dart | 67 ++ .../test/services/speech_service_test.dart | 356 ++++++ .../test/services/tts_service_test.dart | 60 + .../test/theme/app_theme_test.dart | 23 + horatio/horatio_app/test/widget_test.dart | 88 ++ .../test/widgets/grade_badge_test.dart | 23 + .../test/widgets/line_diff_widget_test.dart | 44 + .../test/widgets/script_card_widget_test.dart | 74 ++ horatio/horatio_core/.gitignore | 10 + horatio/horatio_core/CHANGELOG.md | 3 + horatio/horatio_core/README.md | 39 + horatio/horatio_core/analysis_options.yaml | 1 + horatio/horatio_core/lib/horatio_core.dart | 9 + .../horatio_core/lib/src/models/models.dart | 6 + horatio/horatio_core/lib/src/models/role.dart | 25 + .../horatio_core/lib/src/models/scene.dart | 20 + .../horatio_core/lib/src/models/script.dart | 35 + .../lib/src/models/script_line.dart | 47 + .../horatio_core/lib/src/models/srs_card.dart | 48 + .../lib/src/models/stage_direction.dart | 11 + .../horatio_core/lib/src/parser/parser.dart | 3 + .../lib/src/parser/role_detector.dart | 153 +++ .../lib/src/parser/script_parser.dart | 13 + .../lib/src/parser/text_parser.dart | 188 +++ .../lib/src/planner/daily_session.dart | 26 + .../lib/src/planner/line_comparator.dart | 153 +++ .../lib/src/planner/memorization_planner.dart | 117 ++ .../horatio_core/lib/src/planner/planner.dart | 3 + .../lib/src/srs/card_scheduler.dart | 52 + .../lib/src/srs/review_result.dart | 47 + .../lib/src/srs/sm2_algorithm.dart | 48 + horatio/horatio_core/lib/src/srs/srs.dart | 3 + horatio/horatio_core/pubspec.yaml | 19 + .../horatio_core/test/models/model_test.dart | 236 ++++ .../test/parser/role_detector_test.dart | 123 ++ .../test/parser/text_parser_test.dart | 116 ++ .../test/planner/planner_test.dart | 238 ++++ horatio/horatio_core/test/srs/srs_test.dart | 207 ++++ horatio/melos.yaml | 44 + horatio/run.sh | 271 +++++ 100 files changed, 10734 insertions(+), 3 deletions(-) create mode 100644 horatio/analysis_options.yaml create mode 100755 horatio/dead_code.sh create mode 100644 horatio/docs/superpowers/specs/2026-03-28-horatio-design.md create mode 100644 horatio/horatio_app/.gitignore create mode 100644 horatio/horatio_app/.metadata create mode 100644 horatio/horatio_app/README.md create mode 100644 horatio/horatio_app/analysis_options.yaml create mode 100644 horatio/horatio_app/assets/public_domain/cherry_orchard_act1.json create mode 100644 horatio/horatio_app/assets/public_domain/dolls_house_act3.json create mode 100644 horatio/horatio_app/assets/public_domain/hamlet_act3_scene1.json create mode 100644 horatio/horatio_app/assets/public_domain/importance_of_being_earnest_act1.json create mode 100644 horatio/horatio_app/assets/public_domain/romeo_juliet_act2_scene2.json create mode 100644 horatio/horatio_app/lib/app.dart create mode 100644 horatio/horatio_app/lib/bloc/rehearsal/rehearsal_cubit.dart create mode 100644 horatio/horatio_app/lib/bloc/rehearsal/rehearsal_state.dart create mode 100644 horatio/horatio_app/lib/bloc/script_import/script_import_cubit.dart create mode 100644 horatio/horatio_app/lib/bloc/script_import/script_import_state.dart create mode 100644 horatio/horatio_app/lib/bloc/srs_review/srs_review_cubit.dart create mode 100644 horatio/horatio_app/lib/bloc/srs_review/srs_review_state.dart create mode 100644 horatio/horatio_app/lib/main.dart create mode 100644 horatio/horatio_app/lib/router.dart create mode 100644 horatio/horatio_app/lib/screens/home_screen.dart create mode 100644 horatio/horatio_app/lib/screens/import_screen.dart create mode 100644 horatio/horatio_app/lib/screens/rehearsal_screen.dart create mode 100644 horatio/horatio_app/lib/screens/role_selection_screen.dart create mode 100644 horatio/horatio_app/lib/screens/schedule_screen.dart create mode 100644 horatio/horatio_app/lib/screens/srs_review_screen.dart create mode 100644 horatio/horatio_app/lib/services/file_import_service.dart create mode 100644 horatio/horatio_app/lib/services/script_repository.dart create mode 100644 horatio/horatio_app/lib/services/speech_service.dart create mode 100644 horatio/horatio_app/lib/services/tts_service.dart create mode 100644 horatio/horatio_app/lib/theme/app_theme.dart create mode 100644 horatio/horatio_app/lib/widgets/grade_badge.dart create mode 100644 horatio/horatio_app/lib/widgets/line_diff_widget.dart create mode 100644 horatio/horatio_app/lib/widgets/script_card_widget.dart create mode 100644 horatio/horatio_app/linux/.gitignore create mode 100644 horatio/horatio_app/linux/CMakeLists.txt create mode 100644 horatio/horatio_app/linux/flutter/CMakeLists.txt create mode 100644 horatio/horatio_app/linux/flutter/generated_plugin_registrant.cc create mode 100644 horatio/horatio_app/linux/flutter/generated_plugin_registrant.h create mode 100644 horatio/horatio_app/linux/flutter/generated_plugins.cmake create mode 100644 horatio/horatio_app/linux/runner/CMakeLists.txt create mode 100644 horatio/horatio_app/linux/runner/main.cc create mode 100644 horatio/horatio_app/linux/runner/my_application.cc create mode 100644 horatio/horatio_app/linux/runner/my_application.h create mode 100644 horatio/horatio_app/pubspec.lock create mode 100644 horatio/horatio_app/pubspec.yaml create mode 100644 horatio/horatio_app/test/app_test.dart create mode 100644 horatio/horatio_app/test/bloc/rehearsal_cubit_test.dart create mode 100644 horatio/horatio_app/test/bloc/script_import_cubit_test.dart create mode 100644 horatio/horatio_app/test/bloc/srs_review_cubit_test.dart create mode 100644 horatio/horatio_app/test/router_test.dart create mode 100644 horatio/horatio_app/test/screens/home_screen_test.dart create mode 100644 horatio/horatio_app/test/screens/import_screen_test.dart create mode 100644 horatio/horatio_app/test/screens/rehearsal_screen_test.dart create mode 100644 horatio/horatio_app/test/screens/role_selection_screen_test.dart create mode 100644 horatio/horatio_app/test/screens/schedule_screen_test.dart create mode 100644 horatio/horatio_app/test/screens/srs_review_screen_test.dart create mode 100644 horatio/horatio_app/test/services/file_import_service_test.dart create mode 100644 horatio/horatio_app/test/services/script_repository_test.dart create mode 100644 horatio/horatio_app/test/services/speech_service_test.dart create mode 100644 horatio/horatio_app/test/services/tts_service_test.dart create mode 100644 horatio/horatio_app/test/theme/app_theme_test.dart create mode 100644 horatio/horatio_app/test/widget_test.dart create mode 100644 horatio/horatio_app/test/widgets/grade_badge_test.dart create mode 100644 horatio/horatio_app/test/widgets/line_diff_widget_test.dart create mode 100644 horatio/horatio_app/test/widgets/script_card_widget_test.dart create mode 100644 horatio/horatio_core/.gitignore create mode 100644 horatio/horatio_core/CHANGELOG.md create mode 100644 horatio/horatio_core/README.md create mode 100644 horatio/horatio_core/analysis_options.yaml create mode 100644 horatio/horatio_core/lib/horatio_core.dart create mode 100644 horatio/horatio_core/lib/src/models/models.dart create mode 100644 horatio/horatio_core/lib/src/models/role.dart create mode 100644 horatio/horatio_core/lib/src/models/scene.dart create mode 100644 horatio/horatio_core/lib/src/models/script.dart create mode 100644 horatio/horatio_core/lib/src/models/script_line.dart create mode 100644 horatio/horatio_core/lib/src/models/srs_card.dart create mode 100644 horatio/horatio_core/lib/src/models/stage_direction.dart create mode 100644 horatio/horatio_core/lib/src/parser/parser.dart create mode 100644 horatio/horatio_core/lib/src/parser/role_detector.dart create mode 100644 horatio/horatio_core/lib/src/parser/script_parser.dart create mode 100644 horatio/horatio_core/lib/src/parser/text_parser.dart create mode 100644 horatio/horatio_core/lib/src/planner/daily_session.dart create mode 100644 horatio/horatio_core/lib/src/planner/line_comparator.dart create mode 100644 horatio/horatio_core/lib/src/planner/memorization_planner.dart create mode 100644 horatio/horatio_core/lib/src/planner/planner.dart create mode 100644 horatio/horatio_core/lib/src/srs/card_scheduler.dart create mode 100644 horatio/horatio_core/lib/src/srs/review_result.dart create mode 100644 horatio/horatio_core/lib/src/srs/sm2_algorithm.dart create mode 100644 horatio/horatio_core/lib/src/srs/srs.dart create mode 100644 horatio/horatio_core/pubspec.yaml create mode 100644 horatio/horatio_core/test/models/model_test.dart create mode 100644 horatio/horatio_core/test/parser/role_detector_test.dart create mode 100644 horatio/horatio_core/test/parser/text_parser_test.dart create mode 100644 horatio/horatio_core/test/planner/planner_test.dart create mode 100644 horatio/horatio_core/test/srs/srs_test.dart create mode 100644 horatio/melos.yaml create mode 100755 horatio/run.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da390d8..952460a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -220,7 +220,7 @@ repos: - id: codespell args: - --skip=*.json,*.lock,*.min.js,*.min.css,.git,__pycache__,.venv,*.txt - - --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph + - --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,bloc,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph exclude: ^(Bash/ffmpeg-build/|LaTeX/|CPP/|.*\.geojson$) # =========================================================================== @@ -328,7 +328,7 @@ repos: entry: cppcheck language: system types_or: [c, c++] - exclude: ^pomodoro_app/ + exclude: ^(pomodoro_app/|horatio/) args: - --enable=warning,portability - --force @@ -379,7 +379,7 @@ repos: entry: scripts/check_c_cpp_build_files.sh language: script types_or: [c, c++] - exclude: ^CPP/mini_browser/ + exclude: ^(CPP/mini_browser/|horatio/) # =========================================================================== # CHECK PYTHON LOCATION - All Python files must be under python_pkg/ @@ -424,3 +424,15 @@ repos: # - id: commitizen # - id: commitizen-branch # stages: [push] + + # =========================================================================== + # HORATIO - Dart/Flutter tests with coverage enforcement + # =========================================================================== + - repo: local + hooks: + - id: horatio-tests + name: horatio test coverage + entry: bash -c 'cd horatio && bash run.sh test' + language: system + files: ^horatio/ + pass_filenames: false diff --git a/horatio/analysis_options.yaml b/horatio/analysis_options.yaml new file mode 100644 index 0000000..31e0d50 --- /dev/null +++ b/horatio/analysis_options.yaml @@ -0,0 +1,181 @@ +analyzer: + errors: + missing_return: error + missing_required_param: error + todo: warning + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + # Error rules + always_use_package_imports: true + avoid_dynamic_calls: true + avoid_returning_null_for_future: true + avoid_slow_async_io: true + avoid_type_to_string: true + avoid_types_as_parameter_names: true + cancel_subscriptions: true + close_sinks: true + literal_only_boolean_expressions: true + no_adjacent_strings_in_list: true + prefer_void_to_null: true + test_types_in_equals: true + throw_in_finally: true + unnecessary_statements: true + # Style rules + always_declare_return_types: true + always_put_required_named_parameters_first: true + annotate_overrides: true + avoid_annotating_with_dynamic: true + avoid_bool_literals_in_conditional_expressions: true + avoid_catching_errors: true + avoid_double_and_int_checks: true + avoid_empty_else: true + avoid_equals_and_hash_code_on_mutable_classes: true + avoid_escaping_inner_quotes: true + avoid_field_initializers_in_const_classes: true + avoid_final_parameters: true + avoid_function_literals_in_foreach_calls: true + avoid_implementing_value_types: true + avoid_init_to_null: true + avoid_multiple_declarations_per_line: true + avoid_null_checks_in_equality_operators: true + avoid_positional_boolean_parameters: true + avoid_print: true + avoid_private_typedef_functions: true + avoid_redundant_argument_values: true + avoid_relative_lib_imports: true + avoid_renaming_method_parameters: true + avoid_return_types_on_setters: true + avoid_returning_null_for_void: true + avoid_returning_this: true + avoid_setters_without_getters: true + avoid_shadowing_type_parameters: true + avoid_single_cascade_in_expression_statements: true + avoid_unnecessary_containers: true + avoid_unused_constructor_parameters: true + avoid_void_async: true + cascade_invocations: true + cast_nullable_to_non_nullable: true + combinators_ordering: true + conditional_uri_does_not_exist: true + curly_braces_in_flow_control_structures: true + dangling_library_doc_comments: true + deprecated_consistency: true + directives_ordering: true + empty_catches: true + empty_constructor_bodies: true + eol_at_end_of_file: true + exhaustive_cases: true + file_names: true + hash_and_equals: true + implementation_imports: true + implicit_call_tearoffs: true + join_return_with_assignment: true + leading_newlines_in_multiline_strings: true + library_annotations: true + library_names: true + library_prefixes: true + missing_whitespace_between_adjacent_strings: true + no_default_cases: true + no_leading_underscores_for_library_prefixes: true + no_leading_underscores_for_local_identifiers: true + no_literal_bool_comparisons: true + no_runtimeType_toString: true + non_constant_identifier_names: true + noop_primitive_operations: true + null_check_on_nullable_type_parameter: true + null_closures: true + omit_local_variable_types: true + one_member_abstracts: true + only_throw_errors: true + overridden_fields: true + package_api_docs: true + package_prefixed_library_names: true + parameter_assignments: true + prefer_adjacent_string_concatenation: true + prefer_asserts_in_initializer_lists: true + prefer_collection_literals: true + prefer_conditional_assignment: true + prefer_const_constructors: true + prefer_const_constructors_in_immutables: true + prefer_const_declarations: true + prefer_const_literals_to_create_immutables: true + prefer_constructors_over_static_methods: true + prefer_contains: true + prefer_expression_function_bodies: true + prefer_final_fields: true + prefer_final_in_for_each: true + prefer_final_locals: true + prefer_for_elements_to_map_fromIterable: true + prefer_function_declarations_over_variables: true + prefer_generic_function_type_aliases: true + prefer_if_elements_to_conditional_expressions: true + prefer_if_null_operators: true + prefer_initializing_formals: true + prefer_inlined_adds: true + prefer_int_literals: true + prefer_interpolation_to_compose_strings: true + prefer_is_empty: true + prefer_is_not_empty: true + prefer_is_not_operator: true + prefer_iterable_whereType: true + prefer_null_aware_method_calls: true + prefer_null_aware_operators: true + prefer_single_quotes: true + prefer_spread_collections: true + prefer_typing_uninitialized_variables: true + provide_deprecation_message: true + recursive_getters: true + require_trailing_commas: true + sized_box_for_whitespace: true + slash_for_doc_comments: true + sort_child_properties_last: true + sort_constructors_first: true + sort_unnamed_constructors_first: true + type_annotate_public_apis: true + type_init_formals: true + unawaited_futures: true + unnecessary_await_in_return: true + unnecessary_brace_in_string_interps: true + unnecessary_breaks: true + unnecessary_const: true + unnecessary_constructor_name: true + unnecessary_getters_setters: true + unnecessary_lambdas: true + unnecessary_late: true + unnecessary_library_directive: true + unnecessary_new: true + unnecessary_null_aware_assignments: true + unnecessary_null_aware_operator_on_extension_on_nullable: true + unnecessary_null_checks: true + unnecessary_null_in_if_null_operators: true + unnecessary_nullable_for_final_variable_declarations: true + unnecessary_overrides: true + unnecessary_parenthesis: true + unnecessary_raw_strings: true + unnecessary_string_escapes: true + unnecessary_string_interpolations: true + unnecessary_this: true + unnecessary_to_list_in_spreads: true + unreachable_from_main: true + use_colored_box: true + use_decorated_box: true + use_enums: true + use_full_hex_values_for_flutter_colors: true + use_function_type_syntax_for_parameters: true + use_if_null_to_convert_nulls_to_bools: true + use_is_even_rather_than_modulo: true + use_named_constants: true + use_raw_strings: true + use_rethrow_when_possible: true + use_setters_to_change_properties: true + use_string_buffers: true + use_string_in_part_of_directives: true + use_super_parameters: true + use_test_throws_matchers: true + use_to_and_as_if_applicable: true + void_checks: true diff --git a/horatio/dead_code.sh b/horatio/dead_code.sh new file mode 100755 index 0000000..fc2ed40 --- /dev/null +++ b/horatio/dead_code.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# ============================================================================ +# Dead code detection and auto-removal for Horatio Dart/Flutter packages. +# +# Phase 1: dart fix --apply (auto-remove what's fixable) +# Phase 2: dart/flutter analyze (detect remaining dead code diagnostics) +# +# Exit code 0 = clean, 1 = dead code remains after auto-fix. +# +# Usage: +# ./dead_code.sh # Auto-fix + report (both packages) +# ./dead_code.sh --dry-run # Report only, no modifications +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly CORE_DIR="$SCRIPT_DIR/horatio_core" +readonly APP_DIR="$SCRIPT_DIR/horatio_app" + +# Diagnostic codes that indicate dead/unreachable code. +readonly DEAD_CODE_PATTERN='unused_element|unused_field|unused_local_variable|unreachable_from_main|dead_code|unused_import' + +DRY_RUN=false +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=true +fi + +heading() { + echo "" + echo "── $1 ──" +} + +# Phase 1: auto-fix. +auto_fix() { + local dir="$1" + local name="$2" + + if $DRY_RUN; then + heading "$name: dart fix --dry-run" + cd "$dir" + dart fix --dry-run || true + return + fi + + heading "$name: dart fix --apply" + cd "$dir" + dart fix --apply || true +} + +# Phase 2: analyze and grep for dead-code diagnostics. +# Returns the number of dead-code findings (0 = clean). +check_dead_code() { + local dir="$1" + local name="$2" + local analyze_cmd="$3" + + heading "$name: checking for dead code" + cd "$dir" + + local output + output=$($analyze_cmd 2>&1) || true + + local findings + findings=$(echo "$output" | grep -cE "$DEAD_CODE_PATTERN" || true) + + if [[ "$findings" -gt 0 ]]; then + echo "$output" | grep -E "$DEAD_CODE_PATTERN" + echo "" + echo " $name: $findings dead-code diagnostic(s) remaining." + return 1 + fi + + echo " $name: no dead code found." + return 0 +} + +main() { + local failed=0 + + auto_fix "$CORE_DIR" "horatio_core" + auto_fix "$APP_DIR" "horatio_app" + + check_dead_code "$CORE_DIR" "horatio_core" "dart analyze" || failed=1 + check_dead_code "$APP_DIR" "horatio_app" "flutter analyze" || failed=1 + + echo "" + if [[ "$failed" -ne 0 ]]; then + echo "DEAD CODE DETECTED — review the diagnostics above and remove the unused declarations." + exit 1 + fi + + echo "All clean — no dead code found." +} + +main diff --git a/horatio/docs/superpowers/specs/2026-03-28-horatio-design.md b/horatio/docs/superpowers/specs/2026-03-28-horatio-design.md new file mode 100644 index 0000000..e081299 --- /dev/null +++ b/horatio/docs/superpowers/specs/2026-03-28-horatio-design.md @@ -0,0 +1,99 @@ +# Horatio — Script Memorization App Design Spec + +## Overview + +**Horatio** is a multiplatform app (iOS, Android, Windows, Linux, macOS) for actors +to learn their scripts through structured rehearsal and spaced repetition. + +Named after Hamlet's loyal friend — the faithful companion who helps you remember. + +## Architecture + +**Two-package monorepo managed by Melos:** + +- `horatio_core` — Pure Dart package: script parsing, models, SM-2 SRS, memorization planner +- `horatio_app` — Flutter app: UI, TTS, audio, Bloc/Cubit state management, drift database + +## Tech Stack + +- **Framework:** Flutter 3.x (Dart 3.x) +- **State management:** Bloc/Cubit +- **Database:** SQLite via drift (type-safe, reactive, migrations) +- **TTS:** System TTS via flutter_tts (offline) +- **Monorepo:** Melos +- **Lint:** Strictest possible dart analysis + DCM + pre-commit hooks + +## Core Models + +- `Script` — Full parsed document (title, scenes, roles, lines) +- `Role` — Character name + all their lines +- `ScriptLine` — Text content, role, scene index, position, optional stage direction +- `Scene` — Ordered list of lines with scene title +- `SrsCard` — Line/cue pair with SM-2 data (interval, ease factor, next review date) +- `RehearsalSession` — Progress through a dialogue sequence + +## Screen Flow + +1. **Home** — Imported scripts list + public domain library + import button +2. **Import** — File picker for PDF/DOCX/TXT/ODS, parsing progress +3. **Role Selection** — Detected roles with line counts, deadline picker +4. **Schedule Overview** — Calendar of daily memorization sessions +5. **Dialogue Rehearsal** — TTS reads others' lines, actor types their response +6. **SRS Review** — Flashcard interface with SM-2 scheduling + +## Script Parsing + +**Supported formats:** TXT, PDF, DOCX, ODS + +**Role detection heuristics (priority order):** + +1. Screenplay format: `CHARACTER NAME` in ALL CAPS on its own line +2. Colon format: `CHARACTER: dialogue text` +3. Parenthetical: `CHARACTER (stage direction) dialogue` +4. Bracketed: `[CHARACTER] dialogue` + +**Edge cases:** Stage directions preserved but not treated as dialogue. Cross-page +line merging. Narration tagged as STAGE_DIRECTION. + +## SM-2 Spaced Repetition + +Standard SM-2 algorithm: + +- Each line/cue pair becomes an SRS card +- New cards introduced per deadline schedule +- Review intervals adjusted by ease factor (1.3 minimum) +- Long monologues split into sentence-pair cards + +## Dialogue Rehearsal Mode + +- Sequential scene playback +- Other characters' lines read by TTS +- Actor types their line at each cue +- Levenshtein distance for fuzzy matching +- Diff highlighting for feedback +- Session progress feeds into SRS scheduling + +## Public Domain Library + +Pre-parsed scripts bundled as JSON assets: + +- Shakespeare: Hamlet, Romeo and Juliet, Macbeth, A Midsummer Night's Dream, + Othello, The Tempest +- Chekhov: The Cherry Orchard, Three Sisters, The Seagull, Uncle Vanya +- Molière: Tartuffe, The Misanthrope +- Oscar Wilde: The Importance of Being Earnest +- Ibsen: A Doll's House, Hedda Gabler + +## Code Quality + +- Strictest dart analysis (strict-casts, strict-inference, strict-raw-types) +- 50+ lint rules enabled +- dart_code_metrics for complexity limits +- Pre-commit hooks: analyze, format, test +- Coverage: 100% on core, 90%+ on app +- Melos for consistent cross-package commands + +## MVP Phases + +**Phase 1 (MVP):** Import, role detection, rehearsal mode, SRS cards, schedule +**Phase 2:** Audio recording, performance notes, stress annotations, public domain browser diff --git a/horatio/horatio_app/.gitignore b/horatio/horatio_app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/horatio/horatio_app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/horatio/horatio_app/.metadata b/horatio/horatio_app/.metadata new file mode 100644 index 0000000..02c33e9 --- /dev/null +++ b/horatio/horatio_app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + - platform: linux + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/horatio/horatio_app/README.md b/horatio/horatio_app/README.md new file mode 100644 index 0000000..8413e6a --- /dev/null +++ b/horatio/horatio_app/README.md @@ -0,0 +1,17 @@ +# horatio_app + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/horatio/horatio_app/analysis_options.yaml b/horatio/horatio_app/analysis_options.yaml new file mode 100644 index 0000000..5e2133e --- /dev/null +++ b/horatio/horatio_app/analysis_options.yaml @@ -0,0 +1 @@ +include: ../analysis_options.yaml diff --git a/horatio/horatio_app/assets/public_domain/cherry_orchard_act1.json b/horatio/horatio_app/assets/public_domain/cherry_orchard_act1.json new file mode 100644 index 0000000..8aabd25 --- /dev/null +++ b/horatio/horatio_app/assets/public_domain/cherry_orchard_act1.json @@ -0,0 +1,5 @@ +{ + "title": "The Cherry Orchard — Act 1 (Excerpt)", + "source": "Anton Chekhov (translated by Constance Garnett), public domain", + "text": "LYUBOV: Is it really you, Varya? How like your mother you are!\n\n(To Anya)\nAnd Anya is like her too.\n\nDUNYASHA: It's so long since I've seen you.\n\nLYUBOV: The nursery, my dear delightful nursery! I used to sleep here when I was a child.\n\n(Weeps)\nAnd here I am like a child still.\n\nGAYEV: The train was two hours late. What do you think of that? What sort of system is that?\n\nVARYA: I must go and get ready for the master.\n\nLOPAKHIN: Your brother, Leonid Andreyevitch, says I'm a snob, a money-grubber, but I don't mind that a bit. Let him say what he likes. I only want you to trust me as you used to, and to look at me with those wonderful, touching eyes, as you used to.\n\nLYUBOV: I can't sit still, I simply can't.\n\n(Jumps up and walks about in great agitation)\nThis happiness is too much for me. You can laugh at me. I'm silly. My own dear bookcase.\n\n(Kisses bookcase)\nMy little table!\n\nGAYEV: Nurse died while you were away.\n\nLYUBOV: Yes, God rest her soul. They wrote and told me." +} diff --git a/horatio/horatio_app/assets/public_domain/dolls_house_act3.json b/horatio/horatio_app/assets/public_domain/dolls_house_act3.json new file mode 100644 index 0000000..794720f --- /dev/null +++ b/horatio/horatio_app/assets/public_domain/dolls_house_act3.json @@ -0,0 +1,5 @@ +{ + "title": "A Doll's House — Act 3 (Excerpt)", + "source": "Henrik Ibsen (translated by William Archer), public domain", + "text": "NORA: Sit down here, Torvald. You and I have much to say to one another.\n\nHELMER: Nora — what is this? — this cold, set face?\n\nNORA: Sit down. It will take some time; I have a lot to talk over with you.\n\nHELMER: You alarm me, Nora! — and I don't understand you.\n\nNORA: No, that is just it. You don't understand me, and I have never understood you either — before tonight. No, you mustn't interrupt me. You must simply listen to what I say. Torvald, this is a settling of accounts.\n\nHELMER: What do you mean by that?\n\nNORA: Is there not one thing that strikes you as strange in our sitting here like this?\n\nHELMER: What is that?\n\nNORA: We have been married now eight years. Does it not occur to you that this is the first time we two, you and I, husband and wife, have had a serious conversation?\n\nHELMER: What do you mean by serious?\n\nNORA: In all these eight years — longer than that — from the very beginning of our acquaintance, we have never exchanged a word on any serious subject." +} diff --git a/horatio/horatio_app/assets/public_domain/hamlet_act3_scene1.json b/horatio/horatio_app/assets/public_domain/hamlet_act3_scene1.json new file mode 100644 index 0000000..9268bdf --- /dev/null +++ b/horatio/horatio_app/assets/public_domain/hamlet_act3_scene1.json @@ -0,0 +1,5 @@ +{ + "title": "Hamlet — Act 3, Scene 1 (Excerpt)", + "source": "William Shakespeare, public domain", + "text": "HAMLET: To be, or not to be, that is the question:\nWhether 'tis nobler in the mind to suffer\nThe slings and arrows of outrageous fortune,\nOr to take arms against a sea of troubles\nAnd by opposing end them. To die — to sleep,\nNo more; and by a sleep to say we end\nThe heart-ache and the thousand natural shocks\nThat flesh is heir to: 'tis a consummation\nDevoutly to be wish'd. To die, to sleep;\nTo sleep, perchance to dream — ay, there's the rub.\n\n(Enter Ophelia)\n\nOPHELIA: Good my lord,\nHow does your honour for this many a day?\n\nHAMLET: I humbly thank you; well, well, well.\n\nOPHELIA: My lord, I have remembrances of yours,\nThat I have longed long to re-deliver;\nI pray you, now receive them.\n\nHAMLET: No, not I;\nI never gave you aught.\n\nOPHELIA: My honour'd lord, you know right well you did;\nAnd, with them, words of so sweet breath composed\nAs made the things more rich: their perfume lost,\nTake these again; for to the noble mind\nRich gifts wax poor when givers prove unkind.\nThere, my lord.\n\nHAMLET: Ha, ha! are you honest?\n\nOPHELIA: My lord?\n\nHAMLET: Are you fair?\n\nOPHELIA: What means your lordship?\n\nHAMLET: That if you be honest and fair, your honesty should\nadmit no discourse to your beauty." +} diff --git a/horatio/horatio_app/assets/public_domain/importance_of_being_earnest_act1.json b/horatio/horatio_app/assets/public_domain/importance_of_being_earnest_act1.json new file mode 100644 index 0000000..8dfe32d --- /dev/null +++ b/horatio/horatio_app/assets/public_domain/importance_of_being_earnest_act1.json @@ -0,0 +1,5 @@ +{ + "title": "The Importance of Being Earnest — Act 1 (Excerpt)", + "source": "Oscar Wilde, public domain", + "text": "ALGERNON: Did you hear what I was playing, Lane?\n\nLANE: I didn't think it polite to listen, sir.\n\nALGERNON: I'm sorry for that, for your sake. I don't play accurately — any one can play accurately — but I play with wonderful expression.\n\nLANE: Yes, sir.\n\nALGERNON: And, speaking of the science of Life, have you got the cucumber sandwiches cut for Lady Bracknell?\n\nLANE: Yes, sir.\n\n(Hands them on a salver.)\n\nALGERNON: Oh! by the way, Lane, I see from your book that on Thursday night, when Lord Shoreman and Mr. Worthing were dining with me, that eight bottles of champagne are entered as having been consumed.\n\nLANE: Yes, sir; eight bottles and a pint.\n\nALGERNON: Why is it that at a bachelor's establishment the servants invariably drink the champagne? I ask merely for information.\n\nLANE: I attribute it to the superior quality of the wine, sir. I have often observed that in married households the champagne is rarely of a first-rate brand.\n\nALGERNON: Good heavens! Is marriage so demoralising as that?\n\nLANE: I believe it is a very pleasant state, sir. I have had very little experience of it myself up to the present. I have only been married once. That was in consequence of a misunderstanding between myself and a young person.\n\nALGERNON: I don't know that I am much interested in your family life, Lane.\n\nLANE: No, sir; it is not a very interesting subject. I never think of it myself.\n\nALGERNON: Very natural, I am sure. That will do, Lane, thank you.\n\nLANE: Thank you, sir." +} diff --git a/horatio/horatio_app/assets/public_domain/romeo_juliet_act2_scene2.json b/horatio/horatio_app/assets/public_domain/romeo_juliet_act2_scene2.json new file mode 100644 index 0000000..2bb23e7 --- /dev/null +++ b/horatio/horatio_app/assets/public_domain/romeo_juliet_act2_scene2.json @@ -0,0 +1,5 @@ +{ + "title": "Romeo and Juliet — Act 2, Scene 2 (Balcony Scene Excerpt)", + "source": "William Shakespeare, public domain", + "text": "ROMEO: But, soft! what light through yonder window breaks?\nIt is the east, and Juliet is the sun.\nArise, fair sun, and kill the envious moon,\nWho is already sick and pale with grief.\n\nJULIET: O Romeo, Romeo! wherefore art thou Romeo?\nDeny thy father and refuse thy name;\nOr, if thou wilt not, be but sworn my love,\nAnd I'll no longer be a Capulet.\n\nROMEO: Shall I hear more, or shall I speak at this?\n\nJULIET: 'Tis but thy name that is my enemy;\nThou art thyself, though not a Montague.\nWhat's Montague? it is nor hand, nor foot,\nNor arm, nor face, nor any other part\nBelonging to a man. O, be some other name!\nWhat's in a name? that which we call a rose\nBy any other name would smell as sweet.\n\nROMEO: I take thee at thy word:\nCall me but love, and I'll be new baptized;\nHenceforth I never will be Romeo.\n\nJULIET: What man art thou that thus bescreen'd in night\nSo stumblest on my counsel?\n\nROMEO: By a name\nI know not how to tell thee who I am:\nMy name, dear saint, is hateful to myself,\nBecause it is an enemy to thee." +} diff --git a/horatio/horatio_app/lib/app.dart b/horatio/horatio_app/lib/app.dart new file mode 100644 index 0000000..8e9ac3f --- /dev/null +++ b/horatio/horatio_app/lib/app.dart @@ -0,0 +1,43 @@ +import 'package:device_preview/device_preview.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/router.dart'; +import 'package:horatio_app/services/script_repository.dart'; +import 'package:horatio_app/theme/app_theme.dart'; + +/// Root widget for the Horatio app. +class HoratioApp extends StatelessWidget { + /// Creates the [HoratioApp]. + const HoratioApp({super.key}); + + @override + Widget build(BuildContext context) => MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (_) => ScriptRepository(), + ), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ScriptImportCubit( + repository: context.read(), + ), + ), + BlocProvider( + create: (_) => SrsReviewCubit(), + ), + ], + child: MaterialApp.router( + title: 'Horatio', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + locale: DevicePreview.locale(context), + builder: DevicePreview.appBuilder, + routerConfig: appRouter, + ), + ), + ); +} diff --git a/horatio/horatio_app/lib/bloc/rehearsal/rehearsal_cubit.dart b/horatio/horatio_app/lib/bloc/rehearsal/rehearsal_cubit.dart new file mode 100644 index 0000000..de44d9c --- /dev/null +++ b/horatio/horatio_app/lib/bloc/rehearsal/rehearsal_cubit.dart @@ -0,0 +1,157 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/rehearsal/rehearsal_state.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// Manages dialogue rehearsal flow. +class RehearsalCubit extends Cubit { + /// Creates a [RehearsalCubit] for running a dialogue session. + RehearsalCubit({ + required Script script, + required Role selectedRole, + }) : _comparator = const LineComparator(), + _grades = [], + super(const RehearsalInitial()) { + _buildDialogueSequence(script, selectedRole); + } + + final LineComparator _comparator; + + /// Pairs of (cueLine, actorLine) for the session. + final List<_DialoguePair> _pairs = []; + int _currentIndex = 0; + final List _grades; + + void _buildDialogueSequence(Script script, Role selectedRole) { + for (final scene in script.scenes) { + for (var i = 0; i < scene.lines.length; i++) { + final line = scene.lines[i]; + if (line.role == selectedRole && i > 0) { + // Find the previous non-stage-direction line as cue. + final cue = _findCue(scene.lines, i); + if (cue != null) { + _pairs.add( + _DialoguePair( + cueText: cue.text, + cueSpeaker: cue.role?.name ?? 'Stage Direction', + expectedLine: line.text, + ), + ); + } + } + } + } + } + + ScriptLine? _findCue(List lines, int beforeIndex) { + for (var i = beforeIndex - 1; i >= 0; i--) { + if (!lines[i].isStageDirection) return lines[i]; + } + return null; + } + + /// Starts the rehearsal session at the first line. + void start() { + if (_pairs.isEmpty) { + emit( + const RehearsalComplete( + totalLines: 0, + exactCount: 0, + minorCount: 0, + majorCount: 0, + missedCount: 0, + ), + ); + return; + } + _currentIndex = 0; + _grades.clear(); + _emitAwaiting(); + } + + /// Submits the actor's [response] for the current line. + void submitLine(String response) { + if (_currentIndex >= _pairs.length) return; + + final pair = _pairs[_currentIndex]; + final grade = _comparator.grade(pair.expectedLine, response); + final segments = _comparator.wordDiff(pair.expectedLine, response); + + _grades.add(grade); + emit( + RehearsalFeedback( + expectedLine: pair.expectedLine, + actualLine: response, + grade: grade, + diffSegments: segments, + lineIndex: _currentIndex, + totalLines: _pairs.length, + ), + ); + } + + /// Advances to the next line after viewing feedback. + void nextLine() { + _currentIndex++; + if (_currentIndex >= _pairs.length) { + _emitComplete(); + } else { + _emitAwaiting(); + } + } + + void _emitAwaiting() { + final pair = _pairs[_currentIndex]; + emit( + RehearsalAwaitingLine( + cueText: pair.cueText, + cueSpeaker: pair.cueSpeaker, + expectedLine: pair.expectedLine, + lineIndex: _currentIndex, + totalLines: _pairs.length, + ), + ); + } + + void _emitComplete() { + var exact = 0; + var minor = 0; + var major = 0; + var missed = 0; + + for (final g in _grades) { + switch (g) { + case LineMatchGrade.exact: + exact++; + case LineMatchGrade.minor: + minor++; + case LineMatchGrade.major: + major++; + case LineMatchGrade.missed: + missed++; + } + } + + emit( + RehearsalComplete( + totalLines: _pairs.length, + exactCount: exact, + minorCount: minor, + majorCount: major, + missedCount: missed, + ), + ); + } +} + +/// Internal pair of cue + expected actor line. +final class _DialoguePair { + const _DialoguePair({ + required this.cueText, + required this.cueSpeaker, + required this.expectedLine, + }); + + final String cueText; + final String cueSpeaker; + final String expectedLine; +} diff --git a/horatio/horatio_app/lib/bloc/rehearsal/rehearsal_state.dart b/horatio/horatio_app/lib/bloc/rehearsal/rehearsal_state.dart new file mode 100644 index 0000000..8c58ec1 --- /dev/null +++ b/horatio/horatio_app/lib/bloc/rehearsal/rehearsal_state.dart @@ -0,0 +1,125 @@ +import 'package:equatable/equatable.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// State for [RehearsalCubit]. +sealed class RehearsalState extends Equatable { + const RehearsalState(); +} + +/// Rehearsal not started yet. +final class RehearsalInitial extends RehearsalState { + const RehearsalInitial(); + + @override + List get props => []; +} + +/// Waiting for the actor to say their line. +final class RehearsalAwaitingLine extends RehearsalState { + const RehearsalAwaitingLine({ + required this.cueText, + required this.cueSpeaker, + required this.expectedLine, + required this.lineIndex, + required this.totalLines, + }); + + /// The cue line text shown/spoken to the actor. + final String cueText; + + /// Who speaks the cue. + final String cueSpeaker; + + /// The line the actor should recite. + final String expectedLine; + + /// Current line index in the session. + final int lineIndex; + + /// Total lines in this rehearsal session. + final int totalLines; + + @override + List get props => [ + cueText, + cueSpeaker, + expectedLine, + lineIndex, + totalLines, + ]; +} + +/// The actor has submitted their line and can see feedback. +final class RehearsalFeedback extends RehearsalState { + const RehearsalFeedback({ + required this.expectedLine, + required this.actualLine, + required this.grade, + required this.diffSegments, + required this.lineIndex, + required this.totalLines, + }); + + /// The expected line text. + final String expectedLine; + + /// What the actor typed/said. + final String actualLine; + + /// Match quality grade. + final LineMatchGrade grade; + + /// Word-level diff for highlighting. + final List diffSegments; + + /// Current line index. + final int lineIndex; + + /// Total lines. + final int totalLines; + + @override + List get props => [ + expectedLine, + actualLine, + grade, + diffSegments, + lineIndex, + totalLines, + ]; +} + +/// Rehearsal session completed. +final class RehearsalComplete extends RehearsalState { + const RehearsalComplete({ + required this.totalLines, + required this.exactCount, + required this.minorCount, + required this.majorCount, + required this.missedCount, + }); + + /// Total lines rehearsed. + final int totalLines; + + /// Lines graded as exact. + final int exactCount; + + /// Lines graded as minor deviations. + final int minorCount; + + /// Lines graded as major deviations. + final int majorCount; + + /// Lines missed. + final int missedCount; + + @override + List get props => [ + totalLines, + exactCount, + minorCount, + majorCount, + missedCount, + ]; +} diff --git a/horatio/horatio_app/lib/bloc/script_import/script_import_cubit.dart b/horatio/horatio_app/lib/bloc/script_import/script_import_cubit.dart new file mode 100644 index 0000000..f18a4a3 --- /dev/null +++ b/horatio/horatio_app/lib/bloc/script_import/script_import_cubit.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:horatio_app/bloc/script_import/script_import_state.dart'; +import 'package:horatio_app/services/file_import_service.dart'; +import 'package:horatio_app/services/script_repository.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// Manages script import and library state. +class ScriptImportCubit extends Cubit { + /// Creates a [ScriptImportCubit]. + ScriptImportCubit({ + required ScriptRepository repository, + FileImportService? importService, + AssetBundle? assetBundle, + }) : _repository = repository, + _importService = importService ?? const FileImportService(), + _assetBundle = assetBundle ?? rootBundle, + super(const ScriptImportInitial()); + + final ScriptRepository _repository; + final FileImportService _importService; + final AssetBundle _assetBundle; + + /// Loads the current script library. + void loadScripts() { + emit(ScriptImportLoaded(scripts: _repository.scripts)); + } + + /// Imports a script from a user-selected file. + Future importFromFile() async { + emit(const ScriptImportLoading()); + try { + final script = await _importService.pickAndParse(); + if (script == null) { + // User cancelled. + emit(ScriptImportLoaded(scripts: _repository.scripts)); + return; + } + _repository.add(script); + emit(ScriptImportLoaded(scripts: _repository.scripts)); + } on FormatException catch (e) { + emit(ScriptImportError(message: e.message)); + } + } + + /// Imports a script from raw text content. + void importFromText({ + required String text, + required String title, + }) { + emit(const ScriptImportLoading()); + try { + final parser = TextParser(); + final script = parser.parse(content: text, title: title); + _repository.add(script); + emit(ScriptImportLoaded(scripts: _repository.scripts)); + } on FormatException catch (e) { + emit(ScriptImportError(message: e.message)); + } + } + + /// Imports a script from dropped file bytes. + Future importFromBytes({ + required Uint8List bytes, + required String fileName, + }) async { + emit(const ScriptImportLoading()); + try { + final script = await _importService.parseBytes( + bytes: bytes, + fileName: fileName, + ); + if (script == null) return; + _repository.add(script); + emit(ScriptImportLoaded(scripts: _repository.scripts)); + } on FormatException catch (e) { + emit(ScriptImportError(message: e.message)); + } + } + + /// Imports a bundled public domain script from assets. + Future importFromAsset(String assetPath) async { + emit(const ScriptImportLoading()); + try { + final jsonString = await _assetBundle.loadString(assetPath); + final data = json.decode(jsonString) as Map; + final title = data['title'] as String; + final text = data['text'] as String; + final parser = TextParser(); + final script = parser.parse(content: text, title: title); + _repository.add(script); + emit(ScriptImportLoaded(scripts: _repository.scripts)); + } on FormatException catch (e) { + emit(ScriptImportError(message: e.message)); + } + } + + /// Removes a script at [index]. + void removeScript(int index) { + _repository.removeAt(index); + emit(ScriptImportLoaded(scripts: _repository.scripts)); + } +} diff --git a/horatio/horatio_app/lib/bloc/script_import/script_import_state.dart b/horatio/horatio_app/lib/bloc/script_import/script_import_state.dart new file mode 100644 index 0000000..0cdeae6 --- /dev/null +++ b/horatio/horatio_app/lib/bloc/script_import/script_import_state.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; +import 'package:horatio_core/horatio_core.dart'; + +/// State for [ScriptImportCubit]. +sealed class ScriptImportState extends Equatable { + const ScriptImportState(); +} + +/// Initial state — no scripts loaded yet. +final class ScriptImportInitial extends ScriptImportState { + const ScriptImportInitial(); + + @override + List get props => []; +} + +/// Scripts are loaded and available. +final class ScriptImportLoaded extends ScriptImportState { + const ScriptImportLoaded({required this.scripts}); + + /// The list of imported scripts. + final List