From d12810b8068051a0abc08548142c1237538eae1d Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 25 Feb 2026 23:44:22 -0500 Subject: [PATCH 1/7] feat: add superdeck_genui package AI-powered presentation generation package with chat wizard UI, Gemini integration, deck generation pipeline, and tool-based slide editing. Includes prompt templates, example decks, schemas, and comprehensive test coverage. --- .../Flutter/GeneratedPluginRegistrant.swift | 2 - packages/genui/README.md | 45 ++ packages/genui/analysis_options.yaml | 2 + .../genui/assets/examples/coffee_deck.json | 334 +++++++++ .../genui/assets/examples/coffee_prompt.txt | 17 + .../genui/assets/examples/layout_patterns.md | 306 +++++++++ .../assets/examples/polar_bear_deck.json | 326 +++++++++ .../assets/examples/polar_bear_prompt.txt | 24 + .../genui/assets/examples/zebra_deck.json | 343 ++++++++++ .../genui/assets/examples/zebra_prompt.txt | 17 + .../genui/assets/prompts/deck_system.prompt | 176 +++++ .../assets/prompts/image_generation.prompt | 3 + .../assets/prompts/outline_system.prompt | 78 +++ .../prompts/partials/_deck_templates.prompt | 343 ++++++++++ .../genui/assets/prompts/wizard_system.prompt | 254 +++++++ .../lib/src/ai/catalog/ask_user_checkbox.dart | 163 +++++ .../src/ai/catalog/ask_user_checkbox.g.dart | 66 ++ .../src/ai/catalog/ask_user_image_style.dart | 357 ++++++++++ .../ai/catalog/ask_user_image_style.g.dart | 57 ++ .../ai/catalog/ask_user_question_cards.dart | 334 +++++++++ .../lib/src/ai/catalog/ask_user_radio.dart | 151 ++++ .../lib/src/ai/catalog/ask_user_radio.g.dart | 83 +++ .../lib/src/ai/catalog/ask_user_slider.dart | 192 ++++++ .../lib/src/ai/catalog/ask_user_slider.g.dart | 62 ++ .../lib/src/ai/catalog/ask_user_style.dart | 190 ++++++ .../lib/src/ai/catalog/ask_user_style.g.dart | 104 +++ .../lib/src/ai/catalog/ask_user_text.dart | 122 ++++ .../lib/src/ai/catalog/ask_user_text.g.dart | 58 ++ .../genui/lib/src/ai/catalog/catalog.dart | 57 ++ .../src/ai/catalog/catalog_question_step.dart | 37 + .../lib/src/ai/catalog/summary_card.dart | 259 +++++++ .../lib/src/ai/catalog/summary_card.g.dart | 114 ++++ .../lib/src/ai/catalog/summary_card_view.dart | 353 ++++++++++ .../src/ai/catalog/user_action_dispatch.dart | 44 ++ .../lib/src/ai/prompts/examples_loader.dart | 149 ++++ .../genui/lib/src/ai/prompts/font_styles.dart | 143 ++++ .../src/ai/prompts/image_style_prompts.dart | 131 ++++ .../lib/src/ai/prompts/prompt_registry.dart | 133 ++++ .../lib/src/ai/schemas/deck_schemas.dart | 272 ++++++++ .../lib/src/ai/schemas/deck_schemas.g.dart | 377 ++++++++++ .../src/ai/schemas/genui_action_schema.dart | 42 ++ .../src/ai/schemas/genui_action_schema.g.dart | 117 ++++ .../lib/src/ai/schemas/outline_schema.dart | 82 +++ .../genui/lib/src/ai/schemas/schemas.dart | 10 + .../src/ai/schemas/wizard_context_keys.dart | 109 +++ .../ai/services/deck_generator_pipeline.dart | 369 ++++++++++ .../deck_generator_pipeline_helpers.dart | 173 +++++ .../ai/services/deck_generator_service.dart | 256 +++++++ .../ai/services/deck_generator_workflow.dart | 249 +++++++ .../lib/src/ai/services/error_classifier.dart | 106 +++ .../src/ai/services/generation_progress.dart | 54 ++ .../ai/services/image_generator_service.dart | 218 ++++++ .../lib/src/ai/services/prompt_builder.dart | 115 ++++ .../lib/src/ai/services/retry_policy.dart | 88 +++ .../genui/lib/src/ai/services/services.dart | 15 + .../lib/src/ai/services/slide_key_utils.dart | 36 + .../ai/services/style_json_serializer.dart | 19 + packages/genui/lib/src/ai/wizard_context.dart | 165 +++++ packages/genui/lib/src/chat/chat_message.dart | 98 +++ .../genui/lib/src/chat/chat_viewmodel.dart | 387 +++++++++++ .../genui/lib/src/chat/view/chat_screen.dart | 379 +++++++++++ .../src/chat/view/widgets/chat_bubble.dart | 114 ++++ .../chat/view/widgets/chat_genui_panels.dart | 123 ++++ .../lib/src/chat/view/widgets/chat_input.dart | 55 ++ .../src/chat/view/widgets/chat_scaffold.dart | 38 ++ .../src/chat/view/widgets/empty_state.dart | 93 +++ .../src/chat/view/widgets/model_select.dart | 61 ++ .../chat/view/widgets/typing_indicator.dart | 64 ++ .../lib/src/constants/error_messages.dart | 76 +++ .../lib/src/constants/gemini_models.dart | 19 + packages/genui/lib/src/constants/paths.dart | 64 ++ packages/genui/lib/src/debug_logger.dart | 158 +++++ packages/genui/lib/src/env_config.dart | 50 ++ .../lib/src/navigation/app_navigator_key.dart | 3 + packages/genui/lib/src/path_service.dart | 88 +++ .../presentation/presentation_viewmodel.dart | 223 ++++++ .../thumbnail_preview_service.dart | 121 ++++ .../view/creating_presentation_screen.dart | 458 +++++++++++++ .../lib/src/presentation/view/loading.dart | 162 +++++ .../view/presentation_deck_host.dart | 27 + packages/genui/lib/src/routes.dart | 51 ++ .../lib/src/tools/deck_document_store.dart | 101 +++ .../lib/src/tools/deck_mutation_helpers.dart | 90 +++ .../lib/src/tools/deck_tools_locator.dart | 6 + .../lib/src/tools/deck_tools_schemas.dart | 101 +++ .../lib/src/tools/deck_tools_schemas.g.dart | 447 ++++++++++++ .../lib/src/tools/deck_tools_service.dart | 363 ++++++++++ packages/genui/lib/src/tools/errors.dart | 93 +++ .../lib/src/ui/components/sd_buttons.dart | 74 ++ .../lib/src/ui/components/sd_components.dart | 6 + .../lib/src/ui/components/sd_custom.dart | 138 ++++ .../lib/src/ui/components/sd_feedback.dart | 71 ++ .../genui/lib/src/ui/components/sd_form.dart | 337 +++++++++ .../lib/src/ui/components/sd_tokens.dart | 10 + .../lib/src/ui/components/sd_typography.dart | 97 +++ packages/genui/lib/src/ui/ui.dart | 4 + .../src/ui/widgets/catalog_next_button.dart | 23 + packages/genui/lib/src/ui/widgets/header.dart | 38 ++ .../src/ui/widgets/wizard_loading_state.dart | 20 + packages/genui/lib/src/utils/color_utils.dart | 42 ++ .../lib/src/utils/deck_style_service.dart | 99 +++ packages/genui/lib/src/utils/font_utils.dart | 13 + packages/genui/lib/src/utils/hash_utils.dart | 28 + .../genui/lib/src/utils/style_builder.dart | 99 +++ packages/genui/lib/src/viewmodel_scope.dart | 71 ++ packages/genui/lib/superdeck_genui.dart | 35 + packages/genui/pubspec.yaml | 53 ++ .../test/chat/models/chat_message_test.dart | 145 ++++ .../chat/viewmodel/chat_viewmodel_test.dart | 383 +++++++++++ .../ai/catalog/ask_user_checkbox_test.dart | 85 +++ .../ai/catalog/ask_user_image_style_test.dart | 75 ++ .../core/ai/catalog/ask_user_radio_test.dart | 82 +++ .../core/ai/catalog/ask_user_slider_test.dart | 75 ++ .../core/ai/catalog/ask_user_style_test.dart | 97 +++ .../core/ai/catalog/ask_user_text_test.dart | 73 ++ .../ai/catalog/catalog_validation_test.dart | 86 +++ .../catalog_widget_regressions_test.dart | 334 +++++++++ .../ai/catalog/schema_equivalence_test.dart | 195 ++++++ .../ai/catalog/summary_item_kind_test.dart | 93 +++ .../core/ai/prompts/font_styles_test.dart | 269 ++++++++ .../ai/prompts/image_style_prompts_test.dart | 228 +++++++ .../core/ai/prompts/prompt_registry_test.dart | 293 ++++++++ .../schemas/deck_schemas_ack_types_test.dart | 83 +++ .../services/deck_generator_schema_test.dart | 300 ++++++++ .../ai/services/error_classifier_test.dart | 275 ++++++++ .../core/ai/services/prompt_builder_test.dart | 293 ++++++++ .../core/ai/services/retry_policy_test.dart | 92 +++ .../ai/services/slide_key_utils_test.dart | 52 ++ .../services/style_json_serializer_test.dart | 48 ++ .../test/core/ai/wizard_context_test.dart | 34 + .../core/tools/deck_document_store_test.dart | 211 ++++++ .../tools/deck_mutation_helpers_test.dart | 174 +++++ .../core/tools/deck_tools_schemas_test.dart | 109 +++ .../core/tools/deck_tools_service_test.dart | 518 ++++++++++++++ .../test/core/utils/color_utils_test.dart | 209 ++++++ .../core/utils/deck_style_service_test.dart | 309 +++++++++ .../test/core/utils/hash_utils_test.dart | 68 ++ .../test/core/utils/style_builder_test.dart | 234 +++++++ .../thumbnail_preview_service_test.dart | 207 ++++++ .../view/presentation_deck_host_test.dart | 54 ++ .../presentation_viewmodel_test.dart | 642 ++++++++++++++++++ pubspec.lock | 8 +- 142 files changed, 20299 insertions(+), 6 deletions(-) create mode 100644 packages/genui/README.md create mode 100644 packages/genui/analysis_options.yaml create mode 100644 packages/genui/assets/examples/coffee_deck.json create mode 100644 packages/genui/assets/examples/coffee_prompt.txt create mode 100644 packages/genui/assets/examples/layout_patterns.md create mode 100644 packages/genui/assets/examples/polar_bear_deck.json create mode 100644 packages/genui/assets/examples/polar_bear_prompt.txt create mode 100644 packages/genui/assets/examples/zebra_deck.json create mode 100644 packages/genui/assets/examples/zebra_prompt.txt create mode 100644 packages/genui/assets/prompts/deck_system.prompt create mode 100644 packages/genui/assets/prompts/image_generation.prompt create mode 100644 packages/genui/assets/prompts/outline_system.prompt create mode 100644 packages/genui/assets/prompts/partials/_deck_templates.prompt create mode 100644 packages/genui/assets/prompts/wizard_system.prompt create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_checkbox.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_checkbox.g.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_image_style.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_image_style.g.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_question_cards.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_radio.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_radio.g.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_slider.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_slider.g.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_style.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_style.g.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_text.dart create mode 100644 packages/genui/lib/src/ai/catalog/ask_user_text.g.dart create mode 100644 packages/genui/lib/src/ai/catalog/catalog.dart create mode 100644 packages/genui/lib/src/ai/catalog/catalog_question_step.dart create mode 100644 packages/genui/lib/src/ai/catalog/summary_card.dart create mode 100644 packages/genui/lib/src/ai/catalog/summary_card.g.dart create mode 100644 packages/genui/lib/src/ai/catalog/summary_card_view.dart create mode 100644 packages/genui/lib/src/ai/catalog/user_action_dispatch.dart create mode 100644 packages/genui/lib/src/ai/prompts/examples_loader.dart create mode 100644 packages/genui/lib/src/ai/prompts/font_styles.dart create mode 100644 packages/genui/lib/src/ai/prompts/image_style_prompts.dart create mode 100644 packages/genui/lib/src/ai/prompts/prompt_registry.dart create mode 100644 packages/genui/lib/src/ai/schemas/deck_schemas.dart create mode 100644 packages/genui/lib/src/ai/schemas/deck_schemas.g.dart create mode 100644 packages/genui/lib/src/ai/schemas/genui_action_schema.dart create mode 100644 packages/genui/lib/src/ai/schemas/genui_action_schema.g.dart create mode 100644 packages/genui/lib/src/ai/schemas/outline_schema.dart create mode 100644 packages/genui/lib/src/ai/schemas/schemas.dart create mode 100644 packages/genui/lib/src/ai/schemas/wizard_context_keys.dart create mode 100644 packages/genui/lib/src/ai/services/deck_generator_pipeline.dart create mode 100644 packages/genui/lib/src/ai/services/deck_generator_pipeline_helpers.dart create mode 100644 packages/genui/lib/src/ai/services/deck_generator_service.dart create mode 100644 packages/genui/lib/src/ai/services/deck_generator_workflow.dart create mode 100644 packages/genui/lib/src/ai/services/error_classifier.dart create mode 100644 packages/genui/lib/src/ai/services/generation_progress.dart create mode 100644 packages/genui/lib/src/ai/services/image_generator_service.dart create mode 100644 packages/genui/lib/src/ai/services/prompt_builder.dart create mode 100644 packages/genui/lib/src/ai/services/retry_policy.dart create mode 100644 packages/genui/lib/src/ai/services/services.dart create mode 100644 packages/genui/lib/src/ai/services/slide_key_utils.dart create mode 100644 packages/genui/lib/src/ai/services/style_json_serializer.dart create mode 100644 packages/genui/lib/src/ai/wizard_context.dart create mode 100644 packages/genui/lib/src/chat/chat_message.dart create mode 100644 packages/genui/lib/src/chat/chat_viewmodel.dart create mode 100644 packages/genui/lib/src/chat/view/chat_screen.dart create mode 100644 packages/genui/lib/src/chat/view/widgets/chat_bubble.dart create mode 100644 packages/genui/lib/src/chat/view/widgets/chat_genui_panels.dart create mode 100644 packages/genui/lib/src/chat/view/widgets/chat_input.dart create mode 100644 packages/genui/lib/src/chat/view/widgets/chat_scaffold.dart create mode 100644 packages/genui/lib/src/chat/view/widgets/empty_state.dart create mode 100644 packages/genui/lib/src/chat/view/widgets/model_select.dart create mode 100644 packages/genui/lib/src/chat/view/widgets/typing_indicator.dart create mode 100644 packages/genui/lib/src/constants/error_messages.dart create mode 100644 packages/genui/lib/src/constants/gemini_models.dart create mode 100644 packages/genui/lib/src/constants/paths.dart create mode 100644 packages/genui/lib/src/debug_logger.dart create mode 100644 packages/genui/lib/src/env_config.dart create mode 100644 packages/genui/lib/src/navigation/app_navigator_key.dart create mode 100644 packages/genui/lib/src/path_service.dart create mode 100644 packages/genui/lib/src/presentation/presentation_viewmodel.dart create mode 100644 packages/genui/lib/src/presentation/thumbnail_preview_service.dart create mode 100644 packages/genui/lib/src/presentation/view/creating_presentation_screen.dart create mode 100644 packages/genui/lib/src/presentation/view/loading.dart create mode 100644 packages/genui/lib/src/presentation/view/presentation_deck_host.dart create mode 100644 packages/genui/lib/src/routes.dart create mode 100644 packages/genui/lib/src/tools/deck_document_store.dart create mode 100644 packages/genui/lib/src/tools/deck_mutation_helpers.dart create mode 100644 packages/genui/lib/src/tools/deck_tools_locator.dart create mode 100644 packages/genui/lib/src/tools/deck_tools_schemas.dart create mode 100644 packages/genui/lib/src/tools/deck_tools_schemas.g.dart create mode 100644 packages/genui/lib/src/tools/deck_tools_service.dart create mode 100644 packages/genui/lib/src/tools/errors.dart create mode 100644 packages/genui/lib/src/ui/components/sd_buttons.dart create mode 100644 packages/genui/lib/src/ui/components/sd_components.dart create mode 100644 packages/genui/lib/src/ui/components/sd_custom.dart create mode 100644 packages/genui/lib/src/ui/components/sd_feedback.dart create mode 100644 packages/genui/lib/src/ui/components/sd_form.dart create mode 100644 packages/genui/lib/src/ui/components/sd_tokens.dart create mode 100644 packages/genui/lib/src/ui/components/sd_typography.dart create mode 100644 packages/genui/lib/src/ui/ui.dart create mode 100644 packages/genui/lib/src/ui/widgets/catalog_next_button.dart create mode 100644 packages/genui/lib/src/ui/widgets/header.dart create mode 100644 packages/genui/lib/src/ui/widgets/wizard_loading_state.dart create mode 100644 packages/genui/lib/src/utils/color_utils.dart create mode 100644 packages/genui/lib/src/utils/deck_style_service.dart create mode 100644 packages/genui/lib/src/utils/font_utils.dart create mode 100644 packages/genui/lib/src/utils/hash_utils.dart create mode 100644 packages/genui/lib/src/utils/style_builder.dart create mode 100644 packages/genui/lib/src/viewmodel_scope.dart create mode 100644 packages/genui/lib/superdeck_genui.dart create mode 100644 packages/genui/pubspec.yaml create mode 100644 packages/genui/test/chat/models/chat_message_test.dart create mode 100644 packages/genui/test/chat/viewmodel/chat_viewmodel_test.dart create mode 100644 packages/genui/test/core/ai/catalog/ask_user_checkbox_test.dart create mode 100644 packages/genui/test/core/ai/catalog/ask_user_image_style_test.dart create mode 100644 packages/genui/test/core/ai/catalog/ask_user_radio_test.dart create mode 100644 packages/genui/test/core/ai/catalog/ask_user_slider_test.dart create mode 100644 packages/genui/test/core/ai/catalog/ask_user_style_test.dart create mode 100644 packages/genui/test/core/ai/catalog/ask_user_text_test.dart create mode 100644 packages/genui/test/core/ai/catalog/catalog_validation_test.dart create mode 100644 packages/genui/test/core/ai/catalog/catalog_widget_regressions_test.dart create mode 100644 packages/genui/test/core/ai/catalog/schema_equivalence_test.dart create mode 100644 packages/genui/test/core/ai/catalog/summary_item_kind_test.dart create mode 100644 packages/genui/test/core/ai/prompts/font_styles_test.dart create mode 100644 packages/genui/test/core/ai/prompts/image_style_prompts_test.dart create mode 100644 packages/genui/test/core/ai/prompts/prompt_registry_test.dart create mode 100644 packages/genui/test/core/ai/schemas/deck_schemas_ack_types_test.dart create mode 100644 packages/genui/test/core/ai/services/deck_generator_schema_test.dart create mode 100644 packages/genui/test/core/ai/services/error_classifier_test.dart create mode 100644 packages/genui/test/core/ai/services/prompt_builder_test.dart create mode 100644 packages/genui/test/core/ai/services/retry_policy_test.dart create mode 100644 packages/genui/test/core/ai/services/slide_key_utils_test.dart create mode 100644 packages/genui/test/core/ai/services/style_json_serializer_test.dart create mode 100644 packages/genui/test/core/ai/wizard_context_test.dart create mode 100644 packages/genui/test/core/tools/deck_document_store_test.dart create mode 100644 packages/genui/test/core/tools/deck_mutation_helpers_test.dart create mode 100644 packages/genui/test/core/tools/deck_tools_schemas_test.dart create mode 100644 packages/genui/test/core/tools/deck_tools_service_test.dart create mode 100644 packages/genui/test/core/utils/color_utils_test.dart create mode 100644 packages/genui/test/core/utils/deck_style_service_test.dart create mode 100644 packages/genui/test/core/utils/hash_utils_test.dart create mode 100644 packages/genui/test/core/utils/style_builder_test.dart create mode 100644 packages/genui/test/presentation/thumbnail_preview_service_test.dart create mode 100644 packages/genui/test/presentation/view/presentation_deck_host_test.dart create mode 100644 packages/genui/test/presentation/viewmodel/presentation_viewmodel_test.dart diff --git a/demo/macos/Flutter/GeneratedPluginRegistrant.swift b/demo/macos/Flutter/GeneratedPluginRegistrant.swift index 245fa6e8..8ac1b4ea 100644 --- a/demo/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/demo/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,7 +8,6 @@ import Foundation import file_picker import file_saver import file_selector_macos -import path_provider_foundation import screen_retriever_macos import shared_preferences_foundation import sqflite_darwin @@ -20,7 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/packages/genui/README.md b/packages/genui/README.md new file mode 100644 index 00000000..3b956b2d --- /dev/null +++ b/packages/genui/README.md @@ -0,0 +1,45 @@ +# superdeck_genui + +AI-powered presentation wizard for SuperDeck using GenUI and Gemini. + +This package provides a chat-based wizard that guides users through creating +presentations with AI assistance. It handles the full generation pipeline: +wizard conversation → outline → images → final deck. + +## What it provides + +- **Chat wizard UI** — 8-step GenUI conversation with radio, checkbox, slider, and style selectors +- **AI generation pipeline** — 3-phase deck generation (outline → images → final deck) using Gemini +- **Composable routes** — `genUiRoutes()` function for integration into any GoRouter setup +- **Presentation preview** — Thumbnail generation and deck hosting screens + +## Configuration + +Set your Gemini API key via either method: + +1. **Build-time** (recommended): `--dart-define=GOOGLE_AI_API_KEY=xxx` +2. **Runtime** (dev only): Create a `.env` file with `GOOGLE_AI_API_KEY=xxx` + +## Usage + +```dart +import 'package:superdeck_genui/superdeck_genui.dart'; + +// Add routes to your GoRouter +final router = GoRouter( + routes: [...genUiRoutes()], +); + +// Or use individual screens directly +const ChatScreen(); +const CreatingPresentationScreen(); +const PresentationDeckHost(); +``` + +## Related packages + +| Package | Description | +|---------|-------------| +| `superdeck` | Flutter presentation framework | +| `superdeck_core` | Core models and parsing | +| `superdeck_cli` | CLI tool for building decks | diff --git a/packages/genui/analysis_options.yaml b/packages/genui/analysis_options.yaml new file mode 100644 index 00000000..f3d426c5 --- /dev/null +++ b/packages/genui/analysis_options.yaml @@ -0,0 +1,2 @@ +include: package:flutter_lints/flutter.yaml +extends: ../../shared_analysis_options.yaml diff --git a/packages/genui/assets/examples/coffee_deck.json b/packages/genui/assets/examples/coffee_deck.json new file mode 100644 index 00000000..f2e640a5 --- /dev/null +++ b/packages/genui/assets/examples/coffee_deck.json @@ -0,0 +1,334 @@ +{ + "slides": [ + { + "key": "s3Uis7TW", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "align": "center", + "content": "# Introduction to Coffee\n### A Professional Foundation for Aspiring Baristas" + } + ], + "flex": 2 + } + ], + "options": { + "title": "Title Slide" + } + }, + { + "key": "doQ9LlAT", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "## What We Will Cover" + } + ], + "flex": 1 + }, + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "- **The Legend of Coffee:** Tracing its historical roots.\n- **From Bean to Cup:** Understanding varieties and processing.\n- **The Art of Roasting:** How flavor is developed.\n- **The Science of Brewing:** Principles of extraction." + } + ], + "flex": 3 + } + ], + "options": { + "title": "Agenda" + } + }, + { + "key": "0ED5cXqR", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "flex": 2, + "align": "centerLeft", + "content": "### A Brief History\n\nFrom Ethiopian discovery to global phenomenon" + }, + { + "type": "block", + "flex": 3, + "content": "- **9th Century:** Discovered in Ethiopian forests\n- **15th Century:** Cultivated on Arabian Peninsula\n- **17th Century:** Coffeehouses spread across Europe\n- **18th Century:** Americas become major producers" + } + ] + } + ], + "options": { + "title": "History of Coffee" + } + }, + { + "key": "F0h7UorN", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "align": "center", + "content": "## \"Good communication is as stimulating as black coffee, and just as hard to sleep after.\"\n\n##### — Anne Morrow Lindbergh" + } + ] + } + ], + "options": { + "title": "Inspirational Quote" + } + }, + { + "key": "AwbQbKjQ", + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + { + "type": "block", + "content": "## Arabica vs. Robusta" + } + ] + }, + { + "type": "section", + "flex": 3, + "blocks": [ + { + "type": "block", + "content": "| Attribute | Arabica | Robusta |\n|-----------|---------|----------|\n| **Flavor** | Complex, sweet, high acidity | Bold, strong, low acidity |\n| **Caffeine** | ~1.5% by weight | ~2.5% or more |\n| **Cultivation** | High altitudes, specific climates | Resilient, hot climates |\n| **Cost** | Premium pricing | Budget-friendly |" + } + ] + } + ], + "options": { + "title": "Bean Varieties Comparison" + } + }, + { + "key": "BE9T7PHM", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "## From Cherry to Green Bean" + } + ], + "flex": 1 + }, + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "Processing methods profoundly impact the final taste profile.\n\n- **Washed Process:** Removes all fruit before drying. Results in clean, bright, and acidic flavors.\n- **Natural Process:** Dries the entire cherry. Creates fruity, sweet, and heavy-bodied coffees.\n- **Honey Process:** Removes skin but leaves some mucilage. A balance between washed and natural methods." + } + ], + "flex": 3 + } + ], + "options": { + "title": "Coffee Processing" + } + }, + { + "key": "Nd1jUNeu", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "flex": 2, + "align": "centerLeft", + "content": "### The Art of Roasting\n\nTransforming potential into flavor" + }, + { + "type": "block", + "flex": 3, + "content": "- Roasting is a chemical process that brings out aroma and flavor\n- **Maillard Reaction:** Creates browning and savory notes\n- **Caramelization:** Develops sweetness and body\n- **First Crack:** Signals start of light roast\n- **Second Crack:** Indicates darker roast" + } + ] + } + ], + "options": { + "title": "The Art of Roasting" + } + }, + { + "key": "4UQI3blB", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "## Understanding Roast Profiles" + } + ], + "flex": 1 + }, + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "#### Light Roast\n- Bright acidity\n- Floral & fruity notes\n- Highlights origin characteristics\n- Lighter body", + "flex": 1 + }, + { + "type": "block", + "content": "#### Medium Roast\n- Balanced flavor\n- Sweet, caramel notes\n- Rounded acidity\n- Fuller body", + "flex": 1 + }, + { + "type": "block", + "content": "#### Dark Roast\n- Low acidity\n- Bold, roasty flavors\n- bittersweet, chocolate notes\n- Heavy body", + "flex": 1 + } + ], + "align": "topLeft", + "flex": 3 + } + ], + "options": { + "title": "Roast Profiles" + } + }, + { + "key": "YvfaPx4Z", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "flex": 2, + "align": "centerLeft", + "content": "### Four Pillars of Brewing\n\nMaster these for perfect extraction" + }, + { + "type": "block", + "flex": 3, + "content": "- **Grind Size:** Fine for espresso, coarse for French press\n- **Water Temperature:** 195-205°F (90-96°C)\n- **Brew Ratio:** Start with 1:16 coffee to water\n- **Contact Time:** Varies by brewing method" + } + ] + } + ], + "options": { + "title": "Brewing Principles" + } + }, + { + "key": "0UZxnk2G", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "## Exploring Brewing Methods" + } + ], + "flex": 1 + }, + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "#### Immersion\nGrounds are fully saturated in water for the entire brew time.\n\n- French Press\n- AeroPress\n- Clever Dripper", + "flex": 1 + }, + { + "type": "block", + "content": "#### Infusion / Drip\nWater passes through a bed of coffee grounds.\n\n- Pour-Over (V60, Chemex)\n- Automatic Drip Machine\n- Espresso", + "flex": 1 + } + ], + "flex": 3 + } + ], + "options": { + "title": "Brewing Methods" + } + }, + { + "key": "H9ausGBa", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "## Key Takeaways for the Barista" + } + ], + "flex": 1 + }, + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "- **Context is Key:** History and origin shape the coffee in the cup.\n- **Quality is Paramount:** Great coffee starts with great green beans.\n- **Roasting Creates Flavor:** The roaster develops the bean's potential.\n- **Brewing Unlocks Flavor:** Your skill as a barista brings that potential to life." + } + ], + "flex": 3 + } + ], + "options": { + "title": "Summary" + } + }, + { + "key": "fwg1xEdo", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "align": "center", + "content": "# Thank You\n## Continue Your Coffee Journey" + } + ], + "flex": 2 + } + ], + "options": { + "title": "Closing Slide" + } + } + ], + "style": { + "name": "Artisan Brewing", + "colors": { + "heading": "#4B2C20", + "body": "#F5F5DC", + "background": "#8B4513" + }, + "fonts": { + "headline": "playfairDisplay", + "body": "sourceSerif4" + } + } +} diff --git a/packages/genui/assets/examples/coffee_prompt.txt b/packages/genui/assets/examples/coffee_prompt.txt new file mode 100644 index 00000000..357aa201 --- /dev/null +++ b/packages/genui/assets/examples/coffee_prompt.txt @@ -0,0 +1,17 @@ +Generate a presentation with the following specifications: + +Topic: Introduction to Coffee +Target Audience: Aspiring Baristas +Presentation Approach: Professional Foundation +Key Areas to Emphasize: History, Bean Varieties, Roasting, Brewing +Number of Slides: 12 +Visual Style: Artisan Brewing + +Color Palette: + - Background: #8B4513 + - Heading text: #4B2C20 + - Body text: #F5F5DC +Headline Font: playfairDisplay +Body Font: sourceSerif4 + +Layout Guidance: Use sections as rows and blocks as columns. Use 1-2 blocks per section (never 4+). Put all bullets in a single block. Use two sections (title + body) for most slides. Center-align only titles; left-align body content. Do not use widget blocks or empty blocks. diff --git a/packages/genui/assets/examples/layout_patterns.md b/packages/genui/assets/examples/layout_patterns.md new file mode 100644 index 00000000..72eff2ed --- /dev/null +++ b/packages/genui/assets/examples/layout_patterns.md @@ -0,0 +1,306 @@ +# SuperDeck Layout Patterns + +Reference of working layout patterns for AI-generated presentations. + +--- + +## 1. Title Slide + +Single centered section with H1 title and H2 subtitle. + +```json +{ + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "align": "center", + "content": "# Main Title\n## Subtitle" + } + ] + } + ] +} +``` + +--- + +## 2. Standard Content (Title + Body) + +Two sections: title row (flex:1) + body row (flex:3). + +```json +{ + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + {"type": "block", "content": "## Slide Title"} + ] + }, + { + "type": "section", + "flex": 3, + "blocks": [ + { + "type": "block", + "content": "Introduction text.\n\n- **Point 1:** Description\n- **Point 2:** Description\n- **Point 3:** Description" + } + ] + } + ] +} +``` + +--- + +## 3. Two-Column Layout + +Title section + body section with 2 blocks side by side. Use `####` (H4) for column headers - smaller than slide title. + +```json +{ + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + {"type": "block", "content": "## Slide Title"} + ] + }, + { + "type": "section", + "flex": 3, + "blocks": [ + { + "type": "block", + "flex": 1, + "content": "#### Left Column\n\n- Item 1\n- Item 2\n- Item 3" + }, + { + "type": "block", + "flex": 1, + "content": "#### Right Column\n\n- Item A\n- Item B\n- Item C" + } + ] + } + ] +} +``` + +**Use for:** Comparisons, pros/cons, categories, before/after. + +--- + +## 4. Three-Column Layout + +Title section + body section with 3 blocks. Use `####` (H4) for column headers and add `align: "topLeft"` to section for consistent vertical alignment. + +```json +{ + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + {"type": "block", "content": "## Slide Title"} + ] + }, + { + "type": "section", + "flex": 3, + "align": "topLeft", + "blocks": [ + { + "type": "block", + "flex": 1, + "content": "#### Column 1\n\nDescription text." + }, + { + "type": "block", + "flex": 1, + "content": "#### Column 2\n\nDescription text." + }, + { + "type": "block", + "flex": 1, + "content": "#### Column 3\n\nDescription text." + } + ] + } + ] +} +``` + +**Use for:** 3 items of equal importance (e.g., 3 species, 3 options, 3 pillars). + +**Notes:** +- Without `align: "topLeft"`, columns may have inconsistent vertical positioning +- Use `####` (H4) not `###` (H3) for column headers - H3 is too large for narrow columns + +--- + +## 5. Quote / Statement Slide + +Single centered section with blockquote. + +```json +{ + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "align": "center", + "content": "> \"The quote text goes here.\"" + } + ] + } + ] +} +``` + +**Use for:** Impactful statements, transitions, breathing room between dense slides. + +--- + +## 6. Side-by-Side (Title + Content) + +Single section with 2 blocks: title/subtitle on left, content on right. Use `###` (H3) for title - smaller than standard slide titles since it shares space with content. + +```json +{ + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "flex": 2, + "align": "centerLeft", + "content": "### Section Title\n\nSubtitle or context" + }, + { + "type": "block", + "flex": 3, + "content": "- **Item 1:** Description\n- **Item 2:** Description\n- **Item 3:** Description" + } + ] + } + ] +} +``` + +**Use for:** Visual variety, when title needs more prominence alongside content. + +**Caution:** Long titles may wrap awkwardly in narrow left column. Use `###` (H3) not `##` (H2) for title to fit the narrower space. + +--- + +## 7. Closing Slide + +Similar to title slide - centered H1 + H2. + +```json +{ + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "align": "center", + "content": "# Thank You\n## Questions?" + } + ] + } + ] +} +``` + +--- + +## 8. Image Slide (REQUIRED layout for all images) + +**ALWAYS use a single section with the image in its own block** (like the Title-Left pattern). This gives the image the full slide height. Never use a title section on top of image slides. Never embed images inline within text content. + +```json +{ + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "flex": 3, + "content": "### Slide Title\n\nIntroduction text.\n\n- **Point 1:** Description\n- **Point 2:** Description\n- **Point 3:** Description" + }, + { + "type": "block", + "flex": 2, + "content": "![A description of the illustration](path/to/image.png)" + } + ] + } + ] +} +``` + +**Use for:** Any slide that has an available image — content, title, or closing. + +**Notes:** +- Single section with `flex: 2` (same as Title-Left pattern) +- Text block gets `flex: 3`, image block gets `flex: 2` (60/40 split) +- Use `###` (H3) for the title (not `##`) since it shares space with the image column +- Image fills its own column at the full height of the slide +- For title/closing slides, add `align: "center"` to the text block +- Only include the image if it is listed in the Available Images section + +--- + +## Image Placement Guidelines + +- **Maximum 3 images per presentation** — select the 3 most impactful slides +- **ALWAYS use Pattern 8** (single section, image in own block) — no title section on top +- **ALWAYS use a separate block for images** — never put `![](...)` inline within text content +- **Only use images from the Available Images section** — never invent image paths +- Each available image is mapped to a slide key (e.g., `intro: .superdeck/assets/slide-intro-illustration.png`) +- Place images on the slide matching their key + +--- + +## Alignment Reference + +| Value | Use Case | +|-------|----------| +| `center` | Title slides, quotes, closing slides | +| `centerLeft` | Side-by-side title blocks | +| `topLeft` | Multi-column sections (keeps headers aligned) | + +--- + +## Flex Ratios + +| Pattern | Title Section | Body Section | +|---------|---------------|--------------| +| Standard content | `flex: 1` | `flex: 3` | +| Quote/Title slides | `flex: 2` | - | +| Side-by-side blocks | - | `flex: 2` (title), `flex: 3` (content) | +| Image slide | `flex: 2` | `flex: 3` (text), `flex: 2` (image) | + +--- + +## Known Issues + +1. **Word breaks in narrow columns** - Long words in side-by-side layouts may break awkwardly +2. **Vertical misalignment** - Multi-column layouts need `align: "topLeft"` on section +3. **4+ blocks** - Avoid more than 3 blocks per section (gets cramped) diff --git a/packages/genui/assets/examples/polar_bear_deck.json b/packages/genui/assets/examples/polar_bear_deck.json new file mode 100644 index 00000000..b34e0830 --- /dev/null +++ b/packages/genui/assets/examples/polar_bear_deck.json @@ -0,0 +1,326 @@ +{ + "slides": [ + { + "key": "YECIK2Hz", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "flex": 3, + "align": "center", + "content": "# Polar Bear (Ursus maritimus) Ecology\n### A Field-Based Analysis of Denning and Diet" + }, + { + "type": "block", + "flex": 2, + "content": "![A polar bear standing on arctic sea ice](.superdeck/assets/slide-YECIK2Hz-illustration.png)" + } + ], + "flex": 2 + } + ], + "options": { + "title": "Title" + } + }, + { + "key": "doQ9LlAT", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "## Research Objectives & Agenda" + } + ], + "flex": 1 + }, + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "This presentation provides a technical analysis of recent field data concerning two critical aspects of polar bear ecology:\n\n- **Denning Site Selection:** Analyzing topographical and climatic determinants.\n- **Dietary Shifts:** Quantifying changes in prey consumption in response to environmental pressures.\n- **Methodological Advances:** Evaluating new technologies in field research." + } + ], + "flex": 3 + } + ], + "options": { + "title": "Agenda" + } + }, + { + "key": "i3obqTtt", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "align": "centerLeft", + "content": "### The Arctic Ecosystem in Flux\n\nContext for Ecological Pressure", + "flex": 2 + }, + { + "type": "block", + "content": "- **Sea Ice Decline:** Reduced summer/autumn extent\n- **Temporal Mismatch:** Shifted prey availability\n- **Human Activity:** Shipping and exploration\n- **Trophic Effects:** Impacts on food web", + "flex": 3 + } + ], + "flex": 2 + } + ], + "options": { + "title": "Ecosystem Context" + } + }, + { + "key": "s0hG70nC", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "align": "center", + "content": "## \"To understand the future of the polar bear, we must first quantify its response to the present.\"\n\n##### — Dr. Ian Stirling" + } + ], + "flex": 2 + } + ], + "options": { + "title": "Quote" + } + }, + { + "key": "77tyZAWT", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "flex": 3, + "content": "### Critical Denning Factors\n\nSite selection is driven by key environmental factors:\n\n- **Snow Accumulation:** Deep, stable drifts for thermal insulation\n- **Topography:** Sloped terrain (riverbanks, coastal bluffs)\n- **Substrate Stability:** Permafrost and soil type affect den integrity\n- **Disturbance Avoidance:** Proximity to human activity is negative" + }, + { + "type": "block", + "flex": 2, + "content": "![A cross-section illustration of a polar bear den in a snow drift](.superdeck/assets/slide-77tyZAWT-illustration.png)" + } + ] + } + ], + "options": { + "title": "Denning Factors" + } + }, + { + "key": "HWEgvfVg", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "## Denning Site Comparison" + } + ], + "flex": 1 + }, + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "| Parameter | Wrangel Island (RU) | Svalbard (NO) | Hudson Bay (CA) |\n|---|---|---|---|\n| **Avg. Snow Depth** | > 3 meters | 2-3 meters | 1.5-2 meters |\n| **Primary Topography** | Coastal Bluffs | Mountain Slopes | Peat Banks |\n| **Proximity to Coast** | < 1 km | < 5 km | < 10 km |\n| **Den Collapse Rate** | Low (<5%) | Moderate (5-10%) | High (>15%) |" + } + ], + "flex": 3 + } + ], + "options": { + "title": "Denning Data" + } + }, + { + "key": "fwVm6Wdw", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "## Detection Methods" + } + ], + "flex": 1 + }, + { + "type": "section", + "align": "topLeft", + "blocks": [ + { + "type": "block", + "content": "#### Aerial Surveys\n\n- High cost, weather dependent\n- Potential for disturbance\n- Visual den identification", + "flex": 1 + }, + { + "type": "block", + "content": "#### SAR Radar\n\n- All-weather mapping\n- Large-scale coverage\n- Detects snowpack changes", + "flex": 1 + }, + { + "type": "block", + "content": "#### FLIR Thermal\n\n- Thermal signature detection\n- Limited range\n- Requires low altitude", + "flex": 1 + } + ], + "flex": 3 + } + ], + "options": { + "title": "Detection Methodologies" + } + }, + { + "key": "gM2fxM7I", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "align": "center", + "content": "## \"The polar bear is a sentinel species, its diet a direct ledger of the sea ice's health.\"\n\n##### — Arctic Monitoring and Assessment Programme (AMAP)" + } + ], + "flex": 2 + } + ], + "options": { + "title": "Sentinel Species" + } + }, + { + "key": "ggNtKYRq", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "flex": 2, + "align": "centerLeft", + "content": "### Diet & Ice Decline\n\nAdapting to a changing food web" + }, + { + "type": "block", + "flex": 3, + "content": "- **Reduced Seal Access:** Primary high-fat prey less available\n- **Increased Onshore Time:** More time on land during ice-free periods\n- **Energetic Trade-offs:** Terrestrial food is lower in calories" + } + ] + } + ], + "options": { + "title": "Dietary Plasticity" + } + }, + { + "key": "CkxF0zMH", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "## Primary vs. Land-Based Diet" + } + ], + "flex": 1 + }, + { + "type": "section", + "blocks": [ + { + "type": "block", + "content": "#### Primary Prey (Ice-Based)\n\n- **Ringed Seal (Pusa hispida):** High fat, abundant near ice\n- **Bearded Seal (Erignathus barbatus):** Larger, important for adult males\n- **Caloric Density:** High, optimal for energy storage", + "flex": 1 + }, + { + "type": "block", + "content": "#### Supplemental Forage (Land-Based)\n\n- **Snow Goose Eggs:** Seasonal, high-protein\n- **Caribou Carcasses:** Opportunistic scavenging\n- **Berries & Kelp:** Low caloric value, marginal benefit", + "flex": 1 + } + ], + "flex": 3 + } + ], + "options": { + "title": "Food Source Comparison" + } + }, + { + "key": "OB2KF7fb", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "flex": 3, + "content": "### Conclusions & Next Steps\n\nPolar bears are adapting, but with significant energetic costs.\n\n- **Denning Vulnerability:** Southern populations face risks from inadequate snow\n- **Dietary Gaps:** Terrestrial food cannot replace caloric loss from seals\n- **Future Focus:** Long-term telemetry, isotopic diet analysis, remote sensing" + }, + { + "type": "block", + "flex": 2, + "content": "![A polar bear on a melting ice floe illustrating habitat loss](.superdeck/assets/slide-OB2KF7fb-illustration.png)" + } + ] + } + ], + "options": { + "title": "Conclusion" + } + }, + { + "key": "7mKXg9C9", + "sections": [ + { + "type": "section", + "blocks": [ + { + "type": "block", + "align": "center", + "content": "# Thank You\n## Questions & Discussion" + } + ], + "flex": 2 + } + ], + "options": { + "title": "Closing" + } + } + ], + "style": { + "name": "Glacial Precision", + "colors": { + "heading": "#F0F8FF", + "body": "#B0C4DE", + "background": "#4682B4" + }, + "fonts": { + "headline": "montserrat", + "body": "roboto" + } + } +} diff --git a/packages/genui/assets/examples/polar_bear_prompt.txt b/packages/genui/assets/examples/polar_bear_prompt.txt new file mode 100644 index 00000000..f8cfaaae --- /dev/null +++ b/packages/genui/assets/examples/polar_bear_prompt.txt @@ -0,0 +1,24 @@ +Generate a presentation with the following specifications: + +Topic: Polar Bears +Target Audience: Scientific Researchers +Presentation Approach: Field-Based Technical Analysis +Key Areas to Emphasize: Denning Sites, Dietary Shifts +Number of Slides: 12 +Visual Style: Glacial Precision + +Color Palette: + - Background: #4682B4 + - Heading text: #F0F8FF + - Body text: #B0C4DE +Headline Font: montserrat +Body Font: roboto + +Visual Direction: Minimalist + +Layout Guidance: Use sections as rows and blocks as columns. Use 1-2 blocks per section (never 4+). Put all bullets in a single block. Use two sections (title + body) for most slides. Center-align only titles; left-align body content. Do not use widget blocks or empty blocks. + +Available Images: + - YECIK2Hz: .superdeck/assets/slide-YECIK2Hz-illustration.png + - 77tyZAWT: .superdeck/assets/slide-77tyZAWT-illustration.png + - OB2KF7fb: .superdeck/assets/slide-OB2KF7fb-illustration.png diff --git a/packages/genui/assets/examples/zebra_deck.json b/packages/genui/assets/examples/zebra_deck.json new file mode 100644 index 00000000..706b9ff3 --- /dev/null +++ b/packages/genui/assets/examples/zebra_deck.json @@ -0,0 +1,343 @@ +{ + "slides": [ + { + "key": "s3Uis7TW", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "align": "center", + "content": "# Zebras: More Than Just Stripes\n### Exploring Their Stripes, Social Lives, and Fight for Survival" + } + ] + } + ], + "options": { + "title": "Title Slide" + } + }, + { + "key": "ZsyDx7dn", + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + { + "type": "block", + "content": "## Meet the Family" + } + ] + }, + { + "type": "section", + "flex": 3, + "align": "topLeft", + "blocks": [ + { + "type": "block", + "flex": 1, + "content": "#### Plains Zebra\n\nThe most common, found across eastern and southern Africa." + }, + { + "type": "block", + "flex": 1, + "content": "#### Mountain Zebra\n\nSmaller populations in mountainous regions of South Africa and Namibia." + }, + { + "type": "block", + "flex": 1, + "content": "#### Grevy's Zebra\n\nThe largest and most endangered, native to Kenya and Ethiopia." + } + ] + } + ], + "options": { + "title": "Meet the Family" + } + }, + { + "key": "36QToUSH", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "flex": 2, + "align": "centerLeft", + "content": "### Why the Stripes?\n\nThe iconic stripes are more than just decoration" + }, + { + "type": "block", + "flex": 3, + "content": "- **Pest Control:** Confuses biting flies\n- **Camouflage:** Motion dazzle confuses predators\n- **Social Cues:** Unique patterns for recognition\n- **Thermoregulation:** Air currents cool the skin" + } + ] + } + ], + "options": { + "title": "The Purpose of Stripes" + } + }, + { + "key": "YAu0AUH6", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "align": "center", + "content": "## \"No two zebras have the same stripe pattern. Each one is a unique natural barcode.\"\n\n##### — Nature's Design" + } + ] + } + ], + "options": { + "title": "A Zebra's Fingerprint" + } + }, + { + "key": "Yxd8A2zo", + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + { + "type": "block", + "content": "## A Home on the Plains" + } + ] + }, + { + "type": "section", + "flex": 3, + "blocks": [ + { + "type": "block", + "flex": 1, + "content": "#### Where They Live\n\n- Grasslands and savannas\n- Semi-arid regions\n- Woodlands and mountains" + }, + { + "type": "block", + "flex": 1, + "content": "#### Key Regions\n\n- East Africa (Kenya, Tanzania)\n- Southern Africa (Botswana, South Africa)\n- Horn of Africa (Ethiopia)" + } + ] + } + ], + "options": { + "title": "Habitat and Range" + } + }, + { + "key": "LZ6MD7YF", + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + { + "type": "block", + "content": "## The Social Structure" + } + ] + }, + { + "type": "section", + "flex": 3, + "blocks": [ + { + "type": "block", + "content": "Zebras are highly social animals that live in structured groups.\n\n- **Harems:** The most common group, led by a single stallion with several mares and their offspring.\n- **Bachelor Herds:** Young males and stallions without a harem form their own groups.\n- **Social Bonds:** Members form strong bonds, often grooming each other and watching for predators together." + } + ] + } + ], + "options": { + "title": "Social Structure" + } + }, + { + "key": "QoAt8Azo", + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + { + "type": "block", + "content": "## How Zebras Communicate" + } + ] + }, + { + "type": "section", + "flex": 3, + "blocks": [ + { + "type": "block", + "content": "| Signal | Meaning |\n|--------|--------|\n| Barking | Alert the herd of danger |\n| Snorting | Curious or greeting others |\n| Braying | Locate family members |\n| Ears forward | Alert and attentive |\n| Ears flat | Aggression or warning |" + } + ] + } + ], + "options": { + "title": "Zebra Communication" + } + }, + { + "key": "607q0NdE", + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + { + "type": "block", + "content": "## Threats to Survival" + } + ] + }, + { + "type": "section", + "flex": 3, + "blocks": [ + { + "type": "block", + "flex": 1, + "content": "#### Human Impact\n\n- **Habitat Loss:** Expansion reduces grazing lands\n- **Hunting:** Poaching for skins and bushmeat" + }, + { + "type": "block", + "flex": 1, + "content": "#### Environmental Pressures\n\n- **Climate Change:** Droughts affect water and food\n- **Competition:** Livestock compete for resources" + } + ] + } + ], + "options": { + "title": "Threats to Survival" + } + }, + { + "key": "N90svXbZ", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "align": "center", + "content": "## \"The future of the zebra depends on protecting the wild spaces they call home.\"\n\n##### — Conservation Call" + } + ] + } + ], + "options": { + "title": "Conservation Imperative" + } + }, + { + "key": "cSOKeSdJ", + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + { + "type": "block", + "content": "## Conservation in Action" + } + ] + }, + { + "type": "section", + "flex": 3, + "blocks": [ + { + "type": "block", + "flex": 1, + "content": "#### Protected Areas\n\n- National parks\n- Wildlife reserves\n- Migration corridors" + }, + { + "type": "block", + "flex": 1, + "content": "#### Active Programs\n\n- Anti-poaching patrols\n- Community education\n- Breeding programs" + } + ] + } + ], + "options": { + "title": "Conservation Efforts" + } + }, + { + "key": "5mVeMU7h", + "sections": [ + { + "type": "section", + "flex": 1, + "blocks": [ + { + "type": "block", + "content": "## Did You Know?" + } + ] + }, + { + "type": "section", + "flex": 3, + "blocks": [ + { + "type": "block", + "content": "**Q: Are zebras black with white stripes or white with black stripes?**\n\n**A:** They have black skin underneath their fur, so they are considered black with white stripes!\n\n**Q: What is a group of zebras called?**\n\n**A:** A group can be called a 'dazzle' or a 'zeal'." + } + ] + } + ], + "options": { + "title": "Fun Facts Quiz" + } + }, + { + "key": "lG9Q5zzI", + "sections": [ + { + "type": "section", + "flex": 2, + "blocks": [ + { + "type": "block", + "align": "center", + "content": "# Thank You\n## Questions?" + } + ] + } + ], + "options": { + "title": "Closing and Q&A" + } + } + ], + "style": { + "name": "Minimalist Chic", + "colors": { + "heading": "#F8F8F8", + "body": "#333333", + "background": "#A8A8A8" + }, + "fonts": { + "headline": "montserrat", + "body": "inter" + } + } +} diff --git a/packages/genui/assets/examples/zebra_prompt.txt b/packages/genui/assets/examples/zebra_prompt.txt new file mode 100644 index 00000000..392aa55d --- /dev/null +++ b/packages/genui/assets/examples/zebra_prompt.txt @@ -0,0 +1,17 @@ +Generate a presentation with the following specifications: + +Topic: zebras +Target Audience: General Public +Presentation Approach: Interactive +Key Areas to Emphasize: Habitat, Social Structure, Conservation Efforts, Unique Striping +Number of Slides: 12 +Visual Style: Minimalist Chic + +Color Palette: + - Background: #A8A8A8 + - Heading text: #F8F8F8 + - Body text: #333333 +Headline Font: montserrat +Body Font: inter + +Layout Guidance: Use sections as rows and blocks as columns. Use 1-2 blocks per section (never 4+). Put all bullets in a single block. Use two sections (title + body) for most slides. Center-align only titles; left-align body content. Do not use widget blocks or empty blocks. diff --git a/packages/genui/assets/prompts/deck_system.prompt b/packages/genui/assets/prompts/deck_system.prompt new file mode 100644 index 00000000..77543c08 --- /dev/null +++ b/packages/genui/assets/prompts/deck_system.prompt @@ -0,0 +1,176 @@ +You are a presentation generator that creates SuperDeck presentations in JSON format. + +## Output Format + +Generate a JSON object matching the Deck schema with a `slides` array and a `style` object. + +## Layout Model (CRITICAL) + +- A slide is a vertical stack of sections (rows). +- Each section lays out its blocks horizontally as columns. +- Use 1 block per section for most content slides. +- Use exactly 2 blocks per section for two-column layouts. +- Use 3 blocks ONLY for explicit comparisons (rare). +- Never use 4+ blocks in a single section. +- Do NOT create one block per bullet or sentence; keep bullets together in a single block. + +## Section Flex (CRITICAL for content visibility) + +For **two-section layouts** (title row + body row): +- Title sections: Use `flex: 1` +- Body sections with bullets/content: Use `flex: 3` +- This creates a 1:3 ratio (25% title, 75% body) +- WITHOUT proper flex, content gets cropped at the bottom + +For **single-section layouts** (Title-Left, Quote, Hero): +- Use `flex: 2` for the single section + +Example for title + body slide: +```json +\{ + "sections": [ + \{"type": "section", "flex": 1, "blocks": [...]}, + \{"type": "section", "flex": 3, "blocks": [...]} + ] +} +``` + +## Layout Variety (IMPORTANT) + +Vary your layouts throughout the presentation to maintain visual interest: +- Do NOT use the same layout for 2+ consecutive slides +- Standard Content should be max 40% of slides +- Use **Table layout** for structured data comparisons (not Two-Column with matching attributes) +- Use **Quote slides** for key takeaways or transitions between sections +- Use **Title-Left layout** for feature highlights +- Use **Three-column** sparingly - only when you have exactly 3 equal items + +Recommended distribution for a 10-slide deck: +- 1 title/hero slide +- 3-4 standard content slides (spread out, not consecutive) +- 1-2 two-column OR table slides (choose based on content type) +- 1 title-left slide for visual variety +- 1-2 quote/statement slides for breathing room +- 1 three-column slide (if comparing 3 things) +- 1 closing slide + +{{> deck_templates}} + +## Text Density + +- Titles <= 6 words. +- 3-5 bullets per slide, each <= 10 words. +- Avoid paragraphs except intro/summary slides. + +## Slide Structure + +Each slide has: +- `key`: Unique identifier - MUST match the key from the outline if provided +- `options`: \{ title, style } (optional) +- `sections`: Array of section blocks (required) +- `comments`: Speaker notes (optional) + +**IMPORTANT**: When generating from an outline, preserve the exact slide keys from the outline. The outline keys (e.g., "intro", "slide-1", "conclusion") are used to link images to slides. + +## Section Block + +- `type`: Must be "section" +- `flex`: Layout weight (default: 1) +- `align`: Alignment (topLeft, topCenter, topRight, centerLeft, center, centerRight, bottomLeft, bottomCenter, bottomRight) +- `blocks`: Array of content or widget blocks + +## Content Block (type: "block") + +- `type`: Must be "block" +- `content`: Markdown text +- `flex`: Layout weight +- `align`: Content alignment + +## Block Validity + +- `type: "block"` requires non-empty `content`. +- Do not emit empty blocks. +- Do not use widget blocks (reserved for future features). +- Do not use `scrollable` property (reserved for future features). +- Set `options.title` on every slide. +- If a style is provided in the prompt, set `options.style` on every slide. + +## DO NOT (Common Mistakes) + +- Do NOT use widget blocks or scrollable property - these are reserved for future features +- Do NOT omit `flex` values on sections - ALWAYS set flex explicitly +- Do NOT use more than 3 blocks per section (max 2 for most slides) +- Do NOT use `###` (H3) for column headers in two/three-column layouts - use `####` (H4) instead +- Do NOT forget `align: "topLeft"` on three-column body sections +- Do NOT forget `align: "centerLeft"` on Title-Left layout title blocks +- Title-Left layout is the ONLY layout that uses `###` (H3) for its title block + +## Markdown in Content + +Use standard markdown: +- Headings: # ## ### #### +- Bold/Italic: **bold** *italic* +- Lists: - item or 1. item +- Code: `inline` or ```lang code ``` +- Blockquotes: > quote text +- Alerts: > [!NOTE] message + +Images: Only use images from the Available Images section below. Include them using markdown syntax: `![description](path)` + +## Style Configuration + +When a color palette is provided in the prompt, include a top-level "style" object with: +- `name`: Style identifier +- `colors`: Object with `background`, `heading`, and `body` hex values +- `fonts`: Object with `headline` and `body` font IDs from the allowed enum values + +Color guidelines: +- Use the provided background color for slide backgrounds +- Use the provided heading color for main headings (h1, h2) +- Use the provided body color for body text and content +- Copy the exact hex values from the prompt into the style object + +Font guidelines: +- Use only font IDs from the schema's allowed enum values +- The schema description lists available fonts with their use cases +- Copy the exact font IDs from the prompt into the style object + +## Guidelines + +1. Match the requested slide count exactly +2. One idea per slide +3. Use flex for proportional layouts +4. Use alignment to position content +5. VARY your layouts - do not repeat the same layout consecutively +6. Include the style object when colors are provided in the prompt +7. ALWAYS include the "style" object with colors and fonts - this is required for proper rendering +8. For two-section slides: use `flex: 1` for title, `flex: 3` for body +9. For single-section slides (Title-Left, Quote, Hero): use `flex: 2` +10. Use available images where they enhance the slide content (see Available Images section) + +## Available Images + +{{{availableImages}}} + +When an image is available for a slide, use a **single section** (like the Title-Left layout) with the image in its own block. This gives the image the full slide height. Never use a title section on top of image slides. Never embed images inline within text. + +Example image slide layout: +``` +\{ + "sections": [ + \{ + "type": "section", + "flex": 2, + "blocks": [ + \{"type": "block", "flex": 3, "content": "### Slide Title\n\nContent..."}, + \{"type": "block", "flex": 2, "content": "![description](.superdeck/assets/slide-intro-illustration.png)"} + ] + } + ] +} +``` + +Only use images that are listed above. Do not invent image paths. +Maximum 3 images per presentation. Use `###` (H3) for titles on image slides. Text block gets `flex: 3`, image block gets `flex: 2`. + +{{{examples}}} diff --git a/packages/genui/assets/prompts/image_generation.prompt b/packages/genui/assets/prompts/image_generation.prompt new file mode 100644 index 00000000..5acd26b1 --- /dev/null +++ b/packages/genui/assets/prompts/image_generation.prompt @@ -0,0 +1,3 @@ +{{stylePrompt}} + +Render the subject above as a stylized, minimal composition centered in the frame. Keep the subject simple and clean with generous negative space. Avoid photorealism. No readable text, logos, or branding. diff --git a/packages/genui/assets/prompts/outline_system.prompt b/packages/genui/assets/prompts/outline_system.prompt new file mode 100644 index 00000000..16b00b2e --- /dev/null +++ b/packages/genui/assets/prompts/outline_system.prompt @@ -0,0 +1,78 @@ +You are a presentation outline generator. Create a structured outline for a SuperDeck presentation. + +## Output Format + +Generate a JSON object with: +- `topic`: The main presentation topic +- `slides`: Array of slide outlines + +## Slide Outline Structure + +Each slide should have: +- `key`: Unique identifier (e.g., "intro", "slide-1", "conclusion") +- `title`: Working title (will be refined in final generation) +- `purpose`: What this slide will communicate (1-2 sentences) +- `layoutHint`: Suggested layout type +- `imageRequirement`: Optional - only if an image would enhance this slide + +## Layout Hints + +- `title`: Opening/closing slides with big statement +- `content`: Standard bullet points +- `two-column`: Comparison or contrast +- `three-column`: Three equal items (rare) +- `quote`: Key takeaway or memorable statement +- `title-left`: Feature highlight with title on left + +## Image Requirements + +Include `imageRequirement` for **exactly 3 slides** — no more, no less. Pick the 3 most impactful slides for images: +- 1 title or closing slide: atmospheric or thematic imagery +- 1-2 content slides: illustrations that complement the text + +Skip images for all other slides (comparisons, data, quotes, minimalist content). + +Be specific in subject descriptions: +- Good: "team of diverse professionals brainstorming at whiteboard" +- Bad: "teamwork" +- Good: "colorful bar chart showing growth trend" +- Bad: "chart" + +## Guidelines + +1. Match the requested slide count exactly +2. One clear idea per slide +3. Vary layout hints - don't repeat the same layout consecutively +4. Include imageRequirement on exactly 3 slides (the most visually impactful ones) +5. Use descriptive kebab-case keys that reflect slide purpose + +## Example Output + +```json +\{ + "topic": "Introduction to Machine Learning", + "slides": [ + \{ + "key": "intro", + "title": "The AI Revolution", + "purpose": "Set the stage with an impactful opening about how ML is transforming industries", + "layoutHint": "title", + "imageRequirement": \{ + "subject": "futuristic neural network visualization with glowing connections" + } + }, + \{ + "key": "what-is-ml", + "title": "What is Machine Learning?", + "purpose": "Define ML in simple terms and differentiate from traditional programming", + "layoutHint": "content" + }, + \{ + "key": "types", + "title": "Types of Learning", + "purpose": "Compare supervised, unsupervised, and reinforcement learning", + "layoutHint": "three-column" + } + ] +} +``` diff --git a/packages/genui/assets/prompts/partials/_deck_templates.prompt b/packages/genui/assets/prompts/partials/_deck_templates.prompt new file mode 100644 index 00000000..312f4bb2 --- /dev/null +++ b/packages/genui/assets/prompts/partials/_deck_templates.prompt @@ -0,0 +1,343 @@ +## Layout Selection Guide + +Choose layouts based on your content type: + +| Content Type | Layout | When to Use | +|--------------|--------|-------------| +| Single concept + bullets | **Standard Content** | Default for most slides | +| Feature introduction | **Title-Left** | When title needs equal prominence with content | +| Two categories to compare | **Two-Column** | Qualitative comparison, pros/cons | +| Three equal items | **Three-Column** | Types, levels, options of equal weight | +| Structured data comparison | **Table** | Multiple attributes across items | +| Key takeaway or transition | **Quote** | Between dense content, emphasis | +| Opening or section divider | **Title Slide** | Start of presentation or major section | +| End of presentation | **Closing Slide** | Final slide with call-to-action | + +### Table vs Two-Column Decision + +| Scenario | Choose | +|----------|--------| +| Each item has its own descriptive bullets | Two-Column | +| Comparing same attributes across items | Table | +| Long-form descriptions | Two-Column | +| Short, uniform data points | Table | + +--- + +## Layout Templates + +### 1. Title Slide + +Single centered section with H1 title and H3 subtitle. + +```json +\{ + "sections": [ + \{ + "type": "section", + "flex": 2, + "blocks": [ + \{ + "type": "block", + "align": "center", + "content": "# Main Title\\n### Subtitle" + } + ] + } + ] +} +``` + +**Use for:** Opening slide, section dividers. + +--- + +### 2. Standard Content (Title + Body) + +Two sections: title row (flex:1) + body row (flex:3). + +```json +\{ + "sections": [ + \{ + "type": "section", + "flex": 1, + "blocks": [ + \{"type": "block", "content": "## Slide Title"} + ] + }, + \{ + "type": "section", + "flex": 3, + "blocks": [ + \{ + "type": "block", + "content": "Introduction text.\\n\\n- **Point 1:** Description\\n- **Point 2:** Description\\n- **Point 3:** Description" + } + ] + } + ] +} +``` + +**Use for:** Explaining a single concept with supporting points. This is the default layout. + +--- + +### 3. Two-Column Layout + +Title section + body section with 2 blocks side by side. Use `####` (H4) for column headers. + +```json +\{ + "sections": [ + \{ + "type": "section", + "flex": 1, + "blocks": [ + \{"type": "block", "content": "## Slide Title"} + ] + }, + \{ + "type": "section", + "flex": 3, + "blocks": [ + \{ + "type": "block", + "flex": 1, + "content": "#### Left Category\\n\\n- Descriptive point 1\\n- Descriptive point 2\\n- Descriptive point 3" + }, + \{ + "type": "block", + "flex": 1, + "content": "#### Right Category\\n\\n- Descriptive point A\\n- Descriptive point B\\n- Descriptive point C" + } + ] + } + ] +} +``` + +**Use for:** Comparing two categories with their own descriptive content. Good for pros/cons, before/after, two approaches. + +**Choose Two-Column over Table when:** Each category has unique descriptive bullets, not uniform attributes. + +--- + +### 4. Table Layout + +Title section + body section with a markdown table. Best for structured comparisons. + +```json +\{ + "sections": [ + \{ + "type": "section", + "flex": 1, + "blocks": [ + \{"type": "block", "content": "## Comparison Title"} + ] + }, + \{ + "type": "section", + "flex": 3, + "blocks": [ + \{ + "type": "block", + "content": "| Attribute | Option A | Option B |\\n|-----------|----------|----------|\\n| Feature 1 | Value | Value |\\n| Feature 2 | Value | Value |\\n| Feature 3 | Value | Value |" + } + ] + } + ] +} +``` + +**Use for:** Comparing the same attributes across multiple items. Good for specifications, feature matrices, schedules. + +**Choose Table over Two-Column when:** You're comparing uniform attributes (rows) across items (columns). + +**Example - Bean Comparison as Table:** +```markdown +| Attribute | Arabica | Robusta | +|-----------|---------|---------| +| Flavor | Complex, sweet | Bold, strong | +| Caffeine | ~1.5% | ~2.5% | +| Cost | Premium | Budget | +``` + +--- + +### 5. Three-Column Layout + +Title section + body section with 3 blocks. Use `####` (H4) for column headers and `align: "topLeft"` on section. + +```json +\{ + "sections": [ + \{ + "type": "section", + "flex": 1, + "blocks": [ + \{"type": "block", "content": "## Slide Title"} + ] + }, + \{ + "type": "section", + "flex": 3, + "align": "topLeft", + "blocks": [ + \{ + "type": "block", + "flex": 1, + "content": "#### Option 1\\n\\nDescription text." + }, + \{ + "type": "block", + "flex": 1, + "content": "#### Option 2\\n\\nDescription text." + }, + \{ + "type": "block", + "flex": 1, + "content": "#### Option 3\\n\\nDescription text." + } + ] + } + ] +} +``` + +**Use for:** Three items of equal importance (types, levels, pillars). Use sparingly - only when you have exactly 3 things to compare. + +**Required:** `align: "topLeft"` on body section for consistent vertical alignment. + +--- + +### 6. Quote / Statement Slide + +Single centered section with large quote text using H2 heading. + +```json +\{ + "sections": [ + \{ + "type": "section", + "flex": 2, + "blocks": [ + \{ + "type": "block", + "align": "center", + "content": "## \\"The quote text goes here.\\"\n\n##### — Attribution" + } + ] + } + ] +} +``` + +**Use for:** Key takeaways, transitions between sections, breathing room after dense content. + +**Important:** Use `##` (H2) for the quote text to make it large and impactful. Wrap the quote in escaped quotes. Add attribution with `#####` (H5) preceded by an em dash (—). Do NOT use blockquote syntax (`>`) for quotes as it renders too small. + +--- + +### 7. Title-Left Layout + +Single section with 2 blocks: title on left, content on right. Use `###` (H3) for title. + +```json +\{ + "sections": [ + \{ + "type": "section", + "flex": 2, + "blocks": [ + \{ + "type": "block", + "flex": 2, + "align": "centerLeft", + "content": "### Feature Title\\n\\nBrief context or subtitle" + }, + \{ + "type": "block", + "flex": 3, + "content": "- **Point 1:** Description\\n- **Point 2:** Description\\n- **Point 3:** Description\\n- **Point 4:** Description" + } + ] + } + ] +} +``` + +**Use for:** Feature highlights, visual variety, when introducing a concept that needs both context and detail. + +**Required:** `align: "centerLeft"` on title block. + +**Caution:** Keep titles short - long titles wrap awkwardly in narrow column. + +--- + +### 8. Closing Slide + +Centered H1 + H2 for call-to-action. + +```json +\{ + "sections": [ + \{ + "type": "section", + "flex": 2, + "blocks": [ + \{ + "type": "block", + "align": "center", + "content": "# Thank You\\n## Questions?" + } + ] + } + ] +} +``` + +**Use for:** Final slide. Uses `##` (H2) for call-to-action to make it prominent. + +--- + +## Layout Variety Rules + +1. **Never repeat** the same layout type for 2+ consecutive slides +2. **Standard Content** should be max 40% of slides (e.g., 4 out of 10) +3. **Include at least one** Quote or Title-Left for visual variety +4. **Use Table** when comparing structured data instead of Two-Column with matching bullets + +--- + +## Reference Tables + +### Alignment + +| Value | Use Case | +|-------|----------| +| `center` | Title slides, quotes, closing slides | +| `centerLeft` | Title-Left layout title block | +| `topLeft` | Multi-column sections (keeps headers aligned) | + +### Flex Ratios + +| Pattern | Section Flex | Block Flex | +|---------|--------------|------------| +| Standard/Two-Col/Three-Col/Table | `flex: 1` (title), `flex: 3` (body) | `flex: 1` per column | +| Quote/Title/Closing | `flex: 2` (single section) | - | +| Title-Left | `flex: 2` (single section) | `flex: 2` (title), `flex: 3` (content) | + +### Heading Sizes + +| Context | Use | +|---------|-----| +| Hero/Title main title | `#` (H1) | +| Hero/Title subtitle | `###` (H3) | +| Slide title (full width) | `##` (H2) | +| Column header (2-3 col) | `####` (H4) | +| Title-Left layout title | `###` (H3) | +| Quote text | `##` (H2) | +| Quote attribution | `#####` (H5) | diff --git a/packages/genui/assets/prompts/wizard_system.prompt b/packages/genui/assets/prompts/wizard_system.prompt new file mode 100644 index 00000000..168b9421 --- /dev/null +++ b/packages/genui/assets/prompts/wizard_system.prompt @@ -0,0 +1,254 @@ +You are a presentation wizard. The user's first message is the presentation topic. Your goal is to help the user build a presentation by walking them through the steps. + +## Steps + +1. AskUserRadio (step1) - 3 audience options +2. AskUserRadio (step2) - 3 approach options tailored to the selected audience +3. AskUserCheckbox (step3) - 4 emphasis topics relevant to the topic +4. AskUserSlider (step4) - 5-20 slides, unit: "slides", suggest a default based on complexity +5. AskUserStyle (step5) - 3 visual styles with colors and fonts +6. AskUserImageStyle (step6) - 3 image directions +7. SummaryCard (step7) - display ALL previous selections: Topic, Audience, Approach, Emphasis, Slide Count, Style, Image Style +8. Say "Creating your presentation..." + +## AskUserRadio Example (for steps 1-2) +```json +\{ + "AskUserRadio": \{ + "question": "Who is your target audience?", + "description": "Select the group that best describes your viewers.", + "options": [ + \{"title": "Business Professionals", "description": "Corporate stakeholders and executives"}, + \{"title": "Students", "description": "Academic learners in educational settings"}, + \{"title": "General Public", "description": "Broad audience with varied backgrounds"} + ], + "action": \{"name": "submit_answer", "context": []} + } +} +``` + +## AskUserCheckbox Example (for step 3) +```json +\{ + "AskUserCheckbox": \{ + "question": "What topics should we emphasize?", + "description": "Select all that apply (1-3 topics).", + "items": ["History and Origins", "Key Concepts", "Practical Applications", "Future Trends"], + "minSelections": 1, + "maxSelections": 3, + "action": \{"name": "submit_answer", "context": []} + } +} +``` + +## AskUserSlider Example (for step 4) +```json +\{ + "AskUserSlider": \{ + "question": "How many slides do you need?", + "description": "Choose based on topic complexity and presentation time.", + "minValue": 5, + "maxValue": 20, + "defaultValue": 10, + "unit": "slides", + "action": \{"name": "submit_answer", "context": []} + } +} +``` + +## AskUserText Example (for free-form responses) +```json +\{ + "AskUserText": \{ + "question": "Any specific requirements?", + "description": "Enter any additional details for your presentation.", + "placeholder": "e.g., Include company branding, 15-minute time limit", + "maxLength": 500, + "multiline": true, + "action": \{"name": "submit_answer", "context": []} + } +} +``` + +## AskUserStyle Example (for step 5) +```json +\{ + "AskUserStyle": \{ + "question": "Choose a visual style", + "description": "Pick the palette and fonts that best fit your audience.", + "styleOptions": [ + \{ + "id": "professional_clean", + "title": "Professional & Clean", + "description": "Muted palette with crisp typography.", + "colors": ["#F8FAFC", "#1E3A8A", "#475569"], + "headlineFont": "montserrat", + "bodyFont": "openSans" + }, + \{ + "id": "playful_bright", + "title": "Playful & Bright", + "description": "Cheerful colors with friendly fonts.", + "colors": ["#F5F3FF", "#5B21B6", "#6B7280"], + "headlineFont": "lobster", + "bodyFont": "inter" + }, + \{ + "id": "warm_inviting", + "title": "Warm & Inviting", + "description": "Soft neutrals with approachable typography.", + "colors": ["#FFFBEB", "#92400E", "#57534E"], + "headlineFont": "poppins", + "bodyFont": "lato" + } + ], + "action": \{"name": "submit_answer", "context": []} + } +} +``` + +## AskUserImageStyle Example (for step 6) +```json +\{ + "AskUserImageStyle": \{ + "question": "Choose an image style", + "description": "Select the visual direction for imagery.", + "subject": "solar system with planets", + "imageStyles": ["watercolor", "minimalist", "gradient"], + "action": \{"name": "submit_answer", "context": []} + } +} +``` + +## SummaryCard Example (for step 7) +```json +\{ + "SummaryCard": \{ + "title": "Summary", + "items": [ + \{"label": "Topic", "title": "Introduction to Astronomy"}, + \{"label": "Audience", "title": "Middle School Students"}, + \{"label": "Approach", "title": "Interactive & Visual"}, + \{"label": "Emphasis", "text": "Planets, Stars, Space Exploration"}, + \{"label": "Slide Count", "text": "12 slides"}, + \{ + "label": "Style", + "title": "Cosmic Blue", + "description": "Deep space theme with vibrant accents", + "colors": ["#0F172A", "#60A5FA", "#94A3B8"], + "headlineFont": "oswald", + "bodyFont": "inter" + }, + \{"label": "Image Style", "imageStyleId": "minimalist"} + ], + "generateSlidesAction": \{"name": "generate_slides", "context": []} + } +} +``` + +## Content Guidance + +- Tailor all options to the user's topic +- Each step builds on previous selections +- Keep text responses brief (1 sentence acknowledging the selection) +- NEVER pre-select or indicate a preferred/recommended option. All options must be presented as equally valid choices. Do not add "(recommended)", "(suggested)", or any marker that implies a default. Let the user choose freely. + +## Handling Alternative Options + +If the user asks for different/alternative/other options while on a step: +- Stay on the CURRENT step - do NOT go back to previous steps +- Replace the current surface with new options tailored to their feedback +- The topic and any previous selections remain unchanged + +Tool sequence for alternative options (same as normal): +1. deleteSurface(surfaceId: "wizard") - removes the current surface +2. beginRendering(surfaceId: "wizard", root: "root") - renders new options +3. provideFinalOutput - brief acknowledgment + +Example: If on step 2 (approach) and user asks "can you give me other options?", show new approach options for step 2. + +## Critical: One Step Per Turn, One Surface at a Time + +IMPORTANT: Only display ONE surface at a time. Never render multiple surfaces in a single turn. + +For each user message, call these tools in ORDER then STOP: +1. deleteSurface(surfaceId: "wizard") - ALWAYS call this first (safe no-op if surface doesn't exist) +2. beginRendering(surfaceId: "wizard", root: "root") - ONE surface only +3. provideFinalOutput with response: brief acknowledgment (no JSON) + +ALWAYS call deleteSurface first, even on step 1. It safely does nothing if no surface exists yet. +Without deleteSurface, multiple surfaces will stack on screen. + +STOP IMMEDIATELY after provideFinalOutput. Do not call any more tools. +Do not render multiple surfaces. Do not proceed to the next step. +Wait for the user to respond before continuing. + +## Widget ID + +When calling surfaceUpdate, the component's "id" field MUST be "root". When calling beginRendering, the "root" parameter MUST be "root". + +## Surface ID + +ALWAYS use "wizard" as the surfaceId for ALL tool calls: +- deleteSurface(surfaceId: "wizard") +- beginRendering(surfaceId: "wizard", root: "root") + +Using a consistent surfaceId ensures only one wizard surface exists at a time. If you use different surfaceIds, multiple surfaces will stack on screen. + +## Step Guidance + +**Step 1 - Audience** +Think about WHO will receive this presentation. Consider: +- Knowledge level: Are they beginners, intermediate, or experts on this topic? +- Context: Professional setting, educational, casual, or mixed? +- Expectations: What does this audience typically expect from presentations? + +Offer 3 distinct audience segments relevant to the topic. + +**Step 2 - Approach** +Think about HOW to deliver the content to the selected audience. Consider: +- Learning style: Do they prefer data, stories, visuals, or hands-on examples? +- Engagement: Should it be interactive, informative, persuasive, or inspirational? +- Pacing: Quick overview vs. deep dive? + +Tailor the 3 approaches to what would resonate with the chosen audience. + +**Step 3 - Emphasis** +Think about WHAT aspects of the topic matter most. Consider: +- Core concepts vs. practical applications +- Theory vs. examples +- Breadth vs. depth +- Current state vs. future implications + +Offer 4 subtopics that represent meaningful choices, not just variations. + +**Step 4 - Slide Count** +Think about the RIGHT density for this presentation. Consider: +- Topic complexity: Simple topics need fewer slides; complex ones need more +- Audience attention span: Children and casual audiences prefer fewer slides +- Selected emphasis: More subtopics selected may warrant more slides + +Suggest a default that balances completeness with engagement. + +**Step 5 - Style** +Think about the VISUAL MOOD that matches audience and topic. Consider: +- Audience expectations: Formal audiences expect muted, professional palettes +- Topic nature: Creative topics can use bold colors; serious topics need restraint +- Readability: Ensure sufficient contrast between text and background +- Cohesion: Colors should work together as a harmonious palette + +Offer 3 distinct visual directions with colors and font pairings. + +**Step 6 - Image Style** +Think about the VISUAL STORYTELLING approach. Consider: +- Should images be realistic, illustrative, or abstract? +- What mood should visuals convey? +- How do visuals complement the selected color palette? + +Offer 3 distinct visual directions with vivid descriptions. + +**Step 7 - Summary** +Display ALL previous selections clearly organized by category. + +**Step 8 - Generate** +The UI handles generation. Simply acknowledge that the presentation is being created. diff --git a/packages/genui/lib/src/ai/catalog/ask_user_checkbox.dart b/packages/genui/lib/src/ai/catalog/ask_user_checkbox.dart new file mode 100644 index 00000000..45d0f52d --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_checkbox.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; + +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:genui/genui.dart'; +import 'package:remix/remix.dart'; + +import '../schemas/genui_action_schema.dart'; +import './user_action_dispatch.dart'; +import '../../debug_logger.dart'; +import '../../ui/ui.dart'; + +import 'ask_user_question_cards.dart'; +import 'catalog_question_step.dart'; + +part 'ask_user_checkbox.g.dart'; + +// ─────────────────────────────────── SCHEMA ─────────────────────────────────── + +/// Schema for AskUserCheckbox component. +/// +/// Displays a question with checkbox items for multiple selection. +@AckType(name: 'AskUserCheckbox') +final _askUserCheckboxSchema = Ack.object({ + 'question': Ack.string().describe('The question to display to the user'), + 'description': Ack.string().optional().describe( + 'Additional context or instructions', + ), + 'items': Ack.list( + Ack.string(), + ).describe('Checkbox items as strings for multiple selection'), + 'selectedItems': Ack.list( + Ack.string(), + ).optional().describe('Initially selected items'), + 'minSelections': Ack.integer().optional().describe( + 'Minimum selections required, default 1', + ), + 'maxSelections': Ack.integer().optional().describe( + 'Maximum selections allowed', + ), + 'action': actionSchema, +}).describe('A question with checkbox items. User selects one or more items.'); + +// ─────────────────────────────────── CATALOG ITEM ─────────────────────────────────── + +/// AskUserCheckbox catalog component for multiple-selection questions. +final askUserCheckbox = CatalogItem( + name: 'AskUserCheckbox', + dataSchema: _askUserCheckboxSchema.toJsonSchemaBuilder(), + exampleData: [ + () => ''' + [ + { + "id": "root", + "component": { + "AskUserCheckbox": { + "question": "What topics should we cover?", + "description": "Select all that apply.", + "items": ["History", "Current State", "Future Trends", "Case Studies"], + "minSelections": 1, + "maxSelections": 3, + "action": {"name": "submit_answer", "context": []} + } + } + } + ] + ''', + ], + widgetBuilder: (context) { + final data = AskUserCheckboxType.parse(context.data); + return _AskUserCheckboxContent(data: data, itemContext: context); + }, +); + +// ─────────────────────────────────── WIDGET ─────────────────────────────────── + +class _AskUserCheckboxContent extends StatefulWidget { + final AskUserCheckboxType data; + final CatalogItemContext itemContext; + + const _AskUserCheckboxContent({ + required this.data, + required this.itemContext, + }); + + @override + State<_AskUserCheckboxContent> createState() => + _AskUserCheckboxContentState(); +} + +class _AskUserCheckboxContentState extends State<_AskUserCheckboxContent> { + Set _selectedChoices = {}; + + @override + void initState() { + super.initState(); + _selectedChoices = widget.data.selectedItems?.toSet() ?? {}; + } + + bool get _canSubmit { + final minSelections = widget.data.minSelections ?? 1; + final maxSelections = widget.data.maxSelections; + final count = _selectedChoices.length; + if (count < minSelections) return false; + if (maxSelections != null && count > maxSelections) return false; + return true; + } + + Map _buildActionContext() { + return { + 'selectedOptions': _selectedChoices.toList(), + 'message': _selectedChoices.join(', '), + }; + } + + void _submitAction() => submitCatalogActionIfValid( + canSubmit: _canSubmit, + itemContext: widget.itemContext, + rawAction: widget.data.action, + contextBuilder: _buildActionContext, + ); + + @override + Widget build(BuildContext context) { + return CatalogQuestionStep( + question: widget.data.question, + description: widget.data.description, + body: _buildItems(), + canSubmit: _canSubmit, + onSubmit: _submitAction, + ); + } + + Widget _buildItems() { + final items = widget.data.items; + final column = FlexBoxStyler().column().spacing(8); + + if (items.isEmpty) { + debugLog.log('AskUserCheckbox', 'WARNING: checkbox input has no items.'); + return const SdBody('No options available'); + } + + return column( + children: items.map((choice) { + final isSelected = _selectedChoices.contains(choice); + return CheckboxOptionCard( + label: choice, + selected: isSelected, + onTap: () { + setState(() { + if (isSelected) { + _selectedChoices = {..._selectedChoices}..remove(choice); + } else { + _selectedChoices = {..._selectedChoices}..add(choice); + } + }); + }, + ); + }).toList(), + ); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_checkbox.g.dart b/packages/genui/lib/src/ai/catalog/ask_user_checkbox.g.dart new file mode 100644 index 00000000..5a213424 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_checkbox.g.dart @@ -0,0 +1,66 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'ask_user_checkbox.dart'; + +List _$ackListCast(Object? value) => (value as List).cast(); + +/// Extension type for AskUserCheckbox +extension type AskUserCheckboxType(Map _data) + implements Map { + static AskUserCheckboxType parse(Object? data) { + return _askUserCheckboxSchema.parseAs( + data, + (validated) => AskUserCheckboxType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _askUserCheckboxSchema.safeParseAs( + data, + (validated) => AskUserCheckboxType(validated as Map), + ); + } + + Map toJson() => _data; + + String get question => _data['question'] as String; + + String? get description => _data['description'] as String?; + + List get items => _$ackListCast(_data['items']); + + List? get selectedItems => _data['selectedItems'] != null + ? _$ackListCast(_data['selectedItems']) + : null; + + int? get minSelections => _data['minSelections'] as int?; + + int? get maxSelections => _data['maxSelections'] as int?; + + ActionType get action => ActionType(_data['action'] as Map); + + AskUserCheckboxType copyWith({ + String? question, + String? description, + List? items, + List? selectedItems, + int? minSelections, + int? maxSelections, + Map? action, + }) { + return AskUserCheckboxType.parse({ + 'question': question ?? this.question, + 'description': description ?? this.description, + 'items': items ?? this.items, + 'selectedItems': selectedItems ?? this.selectedItems, + 'minSelections': minSelections ?? this.minSelections, + 'maxSelections': maxSelections ?? this.maxSelections, + 'action': action ?? this.action, + }); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_image_style.dart b/packages/genui/lib/src/ai/catalog/ask_user_image_style.dart new file mode 100644 index 00000000..bd6d7bd0 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_image_style.dart @@ -0,0 +1,357 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:genui/genui.dart'; +import 'package:remix/remix.dart'; + +import './user_action_dispatch.dart'; +import '../prompts/image_style_prompts.dart'; +import '../schemas/genui_action_schema.dart'; +import '../schemas/wizard_context_keys.dart'; +import '../services/image_generator_service.dart'; +import '../../env_config.dart'; +import '../../debug_logger.dart'; +import '../../ui/ui.dart'; + +import 'ask_user_question_cards.dart'; +import 'catalog_question_step.dart'; + +part 'ask_user_image_style.g.dart'; + +typedef ImageGeneratorServiceFactory = + ImageGeneratorService Function({required String apiKey}); + +ImageGeneratorServiceFactory _defaultImageGeneratorServiceFactory = + ({required String apiKey}) { + return ImageGeneratorService(apiKey: apiKey); + }; + +@visibleForTesting +ImageGeneratorServiceFactory imageGeneratorServiceFactory = + _defaultImageGeneratorServiceFactory; + +@visibleForTesting +void resetImageGeneratorServiceFactory() { + imageGeneratorServiceFactory = _defaultImageGeneratorServiceFactory; +} + +// ─────────────────────────────────── SCHEMA ─────────────────────────────────── + +/// Schema for AskUserImageStyle component. +/// +/// Displays a question with image style options that generate preview images. +@AckType(name: 'AskUserImageStyle') +final _askUserImageStyleSchema = + Ack.object({ + 'question': Ack.string().describe('The question to display to the user'), + 'description': Ack.string().optional().describe( + 'Additional context or instructions', + ), + 'subject': Ack.string().describe( + 'Visual subject for image generation, shared across all styles', + ), + 'imageStyles': Ack.list( + Ack.enumValues( + ImageStyle.values, + ).describe('Image style ID'), + ).describe(ImageStyle.schemaDescription(count: 3)), + 'action': actionSchema, + }).describe( + 'A question with image style options. Generates preview images for each style.', + ); + +// ─────────────────────────────────── CATALOG ITEM ─────────────────────────────────── + +/// AskUserImageStyle catalog component for image style selection with previews. +final askUserImageStyle = CatalogItem( + name: 'AskUserImageStyle', + dataSchema: _askUserImageStyleSchema.toJsonSchemaBuilder(), + exampleData: [ + () => ''' + [ + { + "id": "root", + "component": { + "AskUserImageStyle": { + "question": "Choose an image style", + "description": "Select the visual direction for imagery.", + "subject": "solar system with planets", + "imageStyles": ["watercolor", "minimalist", "gradient"], + "action": {"name": "submit_answer", "context": []} + } + } + } + ] + ''', + ], + widgetBuilder: (context) { + final data = AskUserImageStyleType.parse(context.data); + return _AskUserImageStyleContent(data: data, itemContext: context); + }, +); + +// ─────────────────────────────────── WIDGET ─────────────────────────────────── + +class _AskUserImageStyleContent extends StatefulWidget { + final AskUserImageStyleType data; + final CatalogItemContext itemContext; + + const _AskUserImageStyleContent({ + required this.data, + required this.itemContext, + }); + + @override + State<_AskUserImageStyleContent> createState() => + _AskUserImageStyleContentState(); +} + +class _AskUserImageStyleContentState extends State<_AskUserImageStyleContent> { + int? _selectedImageStyleIndex; + ImageStyle? _selectedImageStyle; + final Map _generatedImages = {}; + final Set _loadingImages = {}; + final Set _failedImages = {}; + String? _imageError; + int _generationId = 0; + + bool get _canSubmit => _selectedImageStyle != null; + + @override + void initState() { + super.initState(); + _generateImages(); + } + + @override + void didUpdateWidget(covariant _AskUserImageStyleContent oldWidget) { + super.didUpdateWidget(oldWidget); + + final subjectChanged = oldWidget.data.subject != widget.data.subject; + final stylesChanged = !_sameStyleIds( + oldWidget.data.imageStyles, + widget.data.imageStyles, + ); + + if (subjectChanged || stylesChanged) { + _resetImageState(); + _generateImages(); + } + } + + bool _sameStyleIds(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i].name != b[i].name) return false; + } + return true; + } + + void _resetImageState() { + _selectedImageStyleIndex = null; + _selectedImageStyle = null; + _generatedImages.clear(); + _loadingImages.clear(); + _failedImages.clear(); + _imageError = null; + } + + bool _isCurrentGeneration(int generationId) => generationId == _generationId; + + void _setImageError(String message, {required int generationId}) { + if (!mounted || !_isCurrentGeneration(generationId)) return; + setState(() => _imageError = message); + } + + Future _generateImages() async { + final generationId = ++_generationId; + final subject = widget.data.subject; + final styles = widget.data.imageStyles; + + if (styles.isEmpty) return; + + if (!EnvConfig.hasGeminiApiKey) { + _setImageError('API key not configured', generationId: generationId); + return; + } + + if (styles.isEmpty) { + _setImageError( + 'No valid image styles configured', + generationId: generationId, + ); + return; + } + + if (!mounted || !_isCurrentGeneration(generationId)) return; + final service = imageGeneratorServiceFactory( + apiKey: EnvConfig.geminiApiKey, + ); + + try { + setState(() { + _imageError = null; + for (var i = 0; i < styles.length; i++) { + _loadingImages.add(i); + } + }); + + final futures = styles.asMap().entries.map((entry) async { + final index = entry.key; + final style = entry.value; + + final stylePrompt = style.buildPrompt(subject); + final prompt = ImageGeneratorService.buildPrompt(stylePrompt); + debugLog.log('IMG', 'Generating $index (${style.name}): $stylePrompt'); + + final result = await service.generateImage(prompt); + + if (!mounted || !_isCurrentGeneration(generationId)) return; + + setState(() { + if (result.success && result.bytes != null) { + _generatedImages[index] = result.bytes!; + _failedImages.remove(index); + } else { + _failedImages.add(index); + } + _loadingImages.remove(index); + }); + }); + + await Future.wait(futures); + } catch (e) { + debugLog.error('IMG', 'Failed to generate previews: $e'); + _setImageError('Failed to generate previews', generationId: generationId); + } finally { + service.dispose(); + } + } + + Future _retryImage(int index) async { + final generationId = _generationId; + final subject = widget.data.subject; + final styles = widget.data.imageStyles; + if (styles.isEmpty) return; + if (!EnvConfig.hasGeminiApiKey) return; + + if (index >= styles.length) return; + + if (!mounted || !_isCurrentGeneration(generationId)) return; + final style = styles[index]; + final service = imageGeneratorServiceFactory( + apiKey: EnvConfig.geminiApiKey, + ); + setState(() { + _failedImages.remove(index); + _loadingImages.add(index); + }); + + try { + final stylePrompt = style.buildPrompt(subject); + final prompt = ImageGeneratorService.buildPrompt(stylePrompt); + debugLog.log('IMG', 'Retrying $index (${style.name}): $stylePrompt'); + + final result = await service.generateImage(prompt); + + if (!mounted || !_isCurrentGeneration(generationId)) return; + setState(() { + if (result.success && result.bytes != null) { + _generatedImages[index] = result.bytes!; + } else { + _failedImages.add(index); + } + _loadingImages.remove(index); + }); + } catch (e) { + debugLog.error('IMG', 'Retry failed for $index: $e'); + if (!mounted || !_isCurrentGeneration(generationId)) return; + setState(() { + _failedImages.add(index); + _loadingImages.remove(index); + }); + } finally { + service.dispose(); + } + } + + Map _buildActionContext() { + if (_selectedImageStyle case final style?) { + return { + WizardContextKeys.imageStyleId: style.id, + WizardContextKeys.imageStyleName: style.title, + WizardContextKeys.imageStyleDescription: style.description, + WizardContextKeys.message: style.title, + }; + } + return {}; + } + + void _submitAction() => submitCatalogActionIfValid( + canSubmit: _canSubmit, + itemContext: widget.itemContext, + rawAction: widget.data.action, + contextBuilder: _buildActionContext, + ); + + @override + Widget build(BuildContext context) { + return CatalogQuestionStep( + question: widget.data.question, + description: widget.data.description, + body: _buildImageStyles(), + canSubmit: _canSubmit, + onSubmit: _submitAction, + ); + } + + Widget _buildImageStyles() { + if (_imageError != null) { + return Center( + child: SdCaption( + _imageError!, + style: TextStyler().color(Colors.red.shade400), + ), + ); + } + + final styles = widget.data.imageStyles; + + if (styles.isEmpty) { + return const SdBody('No valid image styles configured'); + } + + final optionsRow = FlexBoxStyler().row().spacing(16); + + return optionsRow( + children: styles.asMap().entries.map((entry) { + final index = entry.key; + final style = entry.value; + final isSelected = _selectedImageStyleIndex == index; + final hasFailed = _failedImages.contains(index); + + return Expanded( + child: ImageStyleOptionCard( + style: style, + imageBytes: _generatedImages[index], + isLoading: _loadingImages.contains(index), + hasFailed: hasFailed, + selected: isSelected, + onTap: () { + setState(() { + _selectedImageStyleIndex = index; + _selectedImageStyle = style; + }); + }, + onRetry: hasFailed ? () => _retryImage(index) : null, + ), + ); + }).toList(), + ); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_image_style.g.dart b/packages/genui/lib/src/ai/catalog/ask_user_image_style.g.dart new file mode 100644 index 00000000..9bca854a --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_image_style.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'ask_user_image_style.dart'; + +List _$ackListCast(Object? value) => (value as List).cast(); + +/// Extension type for AskUserImageStyle +extension type AskUserImageStyleType(Map _data) + implements Map { + static AskUserImageStyleType parse(Object? data) { + return _askUserImageStyleSchema.parseAs( + data, + (validated) => AskUserImageStyleType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _askUserImageStyleSchema.safeParseAs( + data, + (validated) => AskUserImageStyleType(validated as Map), + ); + } + + Map toJson() => _data; + + String get question => _data['question'] as String; + + String? get description => _data['description'] as String?; + + String get subject => _data['subject'] as String; + + List get imageStyles => + _$ackListCast(_data['imageStyles']); + + ActionType get action => ActionType(_data['action'] as Map); + + AskUserImageStyleType copyWith({ + String? question, + String? description, + String? subject, + List? imageStyles, + Map? action, + }) { + return AskUserImageStyleType.parse({ + 'question': question ?? this.question, + 'description': description ?? this.description, + 'subject': subject ?? this.subject, + 'imageStyles': imageStyles ?? this.imageStyles, + 'action': action ?? this.action, + }); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_question_cards.dart b/packages/genui/lib/src/ai/catalog/ask_user_question_cards.dart new file mode 100644 index 00000000..c3e74cb0 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_question_cards.dart @@ -0,0 +1,334 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:remix/remix.dart'; + +import '../prompts/font_styles.dart'; +import '../prompts/image_style_prompts.dart'; +import '../../ui/ui.dart'; +import '../../utils/color_utils.dart'; +import '../../utils/font_utils.dart'; + +// ─────────────────────────────────── STYLING UTILITIES ─────────────────────────────────── + +/// Returns body text style for selected state. +TextStyler? selectedBodyStyle(bool selected) => + selected ? TextStyler().color(FortalTokens.accent12()) : null; + +/// Returns caption text style for selected state. +TextStyler? selectedCaptionStyle(bool selected) => + selected ? TextStyler().color(FortalTokens.accent11()) : null; + +// ─────────────────────────────────── CARD WIDGETS ─────────────────────────────────── + +/// Radio option card with title and optional description. +class RadioOptionCard extends StatelessWidget { + final String title; + final String? description; + final bool selected; + final VoidCallback? onTap; + + const RadioOptionCard({ + super.key, + required this.title, + this.description, + required this.selected, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final content = FlexBoxStyler() + .column() + .spacing(4) + .crossAxisAlignment(CrossAxisAlignment.start); + + return Semantics( + inMutuallyExclusiveGroup: true, + selected: selected, + label: 'Option: $title${description != null ? ', $description' : ''}', + child: Pressable( + onPress: onTap, + child: SdCard( + isSelected: selected, + style: FlexBoxStyler().minHeight(SdTokens.cardMinHeight), + child: content( + children: [ + SdBody(title, style: selectedBodyStyle(selected)), + if (description != null && description!.isNotEmpty) + SdCaption(description!, style: selectedCaptionStyle(selected)), + ], + ), + ), + ), + ); + } +} + +/// Checkbox option card with label and checkbox indicator. +class CheckboxOptionCard extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback? onTap; + + const CheckboxOptionCard({ + super.key, + required this.label, + required this.selected, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final content = FlexBoxStyler().spacing(12); + + return Semantics( + checked: selected, + label: 'Checkbox: $label', + child: Pressable( + onPress: onTap, + child: SdCard( + isSelected: selected, + child: content( + children: [ + SdCheckbox(selected: selected), + Expanded( + child: SdBody(label, style: selectedBodyStyle(selected)), + ), + ], + ), + ), + ), + ); + } +} + +/// Style option card with color swatches and font previews. +class StyleOptionCard extends StatelessWidget { + final String title; + final String description; + final List colors; + final String headlineFontId; + final String bodyFontId; + final bool selected; + final VoidCallback? onTap; + + const StyleOptionCard({ + super.key, + required this.title, + required this.description, + required this.colors, + required this.headlineFontId, + required this.bodyFontId, + required this.selected, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final content = FlexBoxStyler() + .column() + .mainAxisSize(MainAxisSize.min) + .spacing(4) + .crossAxisAlignment(CrossAxisAlignment.start); + + final colorRow = FlexBoxStyler().paddingY(5).row(); + + final headlineFont = HeadlineFont.fromId(headlineFontId); + final bodyFont = BodyFont.fromId(bodyFontId); + + final headlineFontFamily = headlineFont?.fontFamily ?? headlineFontId; + final bodyFontFamily = bodyFont?.fontFamily ?? bodyFontId; + + final bodyStyle = selectedBodyStyle(selected); + final captionStyle = selectedCaptionStyle(selected); + + final headlineFontLoaded = tryGetGoogleFontFamily(headlineFontFamily); + final bodyFontLoaded = tryGetGoogleFontFamily(bodyFontFamily); + + return Semantics( + inMutuallyExclusiveGroup: true, + selected: selected, + label: 'Style: $title, $description', + child: Pressable( + onPress: onTap, + child: SdCard( + isSelected: selected, + style: FlexBoxStyler().minHeight(SdTokens.cardMinHeight), + child: content( + children: [ + SdBody(title, style: bodyStyle), + SdCaption(description, style: captionStyle), + colorRow( + children: colors + .map( + (color) => SdColorCircle( + color: hexToColor(color), + interactive: true, + ), + ) + .toList(), + ), + SdBody( + headlineFont?.title ?? headlineFontId, + style: headlineFontLoaded != null + ? TextStyler() + .fontFamily(headlineFontLoaded) + .merge(bodyStyle) + : bodyStyle, + ), + SdCaption( + bodyFont?.title ?? bodyFontId, + style: bodyFontLoaded != null + ? TextStyler() + .fontFamily(bodyFontLoaded) + .merge(captionStyle) + : captionStyle, + ), + ], + ), + ), + ), + ); + } +} + +/// Image style option card with generated preview image. +class ImageStyleOptionCard extends StatelessWidget { + final ImageStyle style; + final Uint8List? imageBytes; + final bool isLoading; + final bool hasFailed; + final bool selected; + final VoidCallback? onTap; + final VoidCallback? onRetry; + + const ImageStyleOptionCard({ + super.key, + required this.style, + this.imageBytes, + this.isLoading = false, + this.hasFailed = false, + required this.selected, + this.onTap, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final content = FlexBoxStyler().column().crossAxisAlignment( + CrossAxisAlignment.stretch, + ); + + return Semantics( + inMutuallyExclusiveGroup: true, + selected: selected, + label: 'Image style: ${style.title}', + child: Pressable( + onPress: onTap, + child: SdCard( + isSelected: selected, + style: FlexBoxStyler() + .crossAxisAlignment(CrossAxisAlignment.stretch) + .paddingAll(0), + child: content( + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.circular(SdTokens.cardInnerRadius), + ), + child: _buildImagePreview(context), + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: SdBody(style.title, style: selectedBodyStyle(selected)), + ), + ], + ), + ), + ), + ); + } + + Widget _buildImagePreview(BuildContext context) { + if (isLoading) { + return Box( + style: BoxStyler() + .color(FortalTokens.gray3()) + .alignment(Alignment.center), + child: SdSpinner(), + ); + } + + if (imageBytes != null) { + return Image.memory( + imageBytes!, + fit: BoxFit.cover, + errorBuilder: (ctx, error, stackTrace) => _buildPlaceholder(ctx), + ); + } + + if (hasFailed) { + return _buildRetryPlaceholder(context); + } + + return _buildPlaceholder(context); + } + + Widget _buildRetryPlaceholder(BuildContext context) { + return Box( + style: BoxStyler() + .color(FortalTokens.gray3()) + .alignment(Alignment.center), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + Icon( + Icons.broken_image_outlined, + color: FortalTokens.gray8.resolve(context), + size: 32, + ), + GestureDetector( + onTap: onRetry, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon( + Icons.refresh, + color: FortalTokens.accent11.resolve(context), + size: 16, + ), + Text( + 'Retry', + style: FortalTokens.text2 + .mix() + .resolve(context) + .copyWith(color: FortalTokens.accent11.resolve(context)), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPlaceholder(BuildContext context) { + return Box( + style: BoxStyler() + .color(FortalTokens.gray3()) + .alignment(Alignment.center), + child: Icon( + Icons.image_outlined, + color: FortalTokens.gray7.resolve(context), + size: 40, + ), + ); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_radio.dart b/packages/genui/lib/src/ai/catalog/ask_user_radio.dart new file mode 100644 index 00000000..21a8880e --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_radio.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; + +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:genui/genui.dart'; +import 'package:remix/remix.dart'; + +import '../schemas/genui_action_schema.dart'; +import './user_action_dispatch.dart'; +import '../../debug_logger.dart'; +import '../../ui/ui.dart'; + +import 'ask_user_question_cards.dart'; +import 'catalog_question_step.dart'; + +part 'ask_user_radio.g.dart'; + +// ─────────────────────────────────── SCHEMA ─────────────────────────────────── + +/// Schema for a radio option with title and optional description. +@AckType(name: 'InputOption') +final _inputOptionSchema = Ack.object({ + 'title': Ack.string().describe('Option title displayed to user'), + 'description': Ack.string().optional().describe('Optional description text'), +}).describe('Option with title and optional description'); + +/// Schema for AskUserRadio component. +/// +/// Displays a question with radio button options for single selection. +@AckType(name: 'AskUserRadio') +final _askUserRadioSchema = Ack.object({ + 'question': Ack.string().describe('The question to display to the user'), + 'description': Ack.string().optional().describe( + 'Additional context or instructions', + ), + 'options': Ack.list( + _inputOptionSchema, + ).describe('Radio options with title and description for single selection'), + 'action': actionSchema, +}).describe('A question with radio button options. User selects one option.'); + +// ─────────────────────────────────── CATALOG ITEM ─────────────────────────────────── + +/// AskUserRadio catalog component for single-selection questions. +final askUserRadio = CatalogItem( + name: 'AskUserRadio', + dataSchema: _askUserRadioSchema.toJsonSchemaBuilder(), + exampleData: [ + () => ''' + [ + { + "id": "root", + "component": { + "AskUserRadio": { + "question": "Who is your target audience?", + "description": "Select the group that best describes your viewers.", + "options": [ + {"title": "Business Professionals", "description": "Corporate stakeholders"}, + {"title": "Students", "description": "Academic learners"}, + {"title": "General Public", "description": "Broad audience"} + ], + "action": {"name": "submit_answer", "context": []} + } + } + } + ] + ''', + ], + widgetBuilder: (context) { + final data = AskUserRadioType.parse(context.data); + return _AskUserRadioContent(data: data, itemContext: context); + }, +); + +// ─────────────────────────────────── WIDGET ─────────────────────────────────── + +class _AskUserRadioContent extends StatefulWidget { + final AskUserRadioType data; + final CatalogItemContext itemContext; + + const _AskUserRadioContent({required this.data, required this.itemContext}); + + @override + State<_AskUserRadioContent> createState() => _AskUserRadioContentState(); +} + +class _AskUserRadioContentState extends State<_AskUserRadioContent> { + int? _selectedIndex; + + bool get _canSubmit => _selectedIndex != null; + + Map _buildActionContext() { + if (_selectedIndex == null) return {}; + final option = widget.data.options[_selectedIndex!]; + return { + 'selectedOption': option.title, + 'selectedDescription': option.description, + 'message': option.title, + }; + } + + void _submitAction() => submitCatalogActionIfValid( + canSubmit: _canSubmit, + itemContext: widget.itemContext, + rawAction: widget.data.action, + contextBuilder: _buildActionContext, + ); + + @override + Widget build(BuildContext context) { + return CatalogQuestionStep( + question: widget.data.question, + description: widget.data.description, + body: _buildOptions(), + canSubmit: _canSubmit, + onSubmit: _submitAction, + ); + } + + Widget _buildOptions() { + final options = widget.data.options; + final optionsRow = FlexBoxStyler() + .spacing(16) + .wrap(WidgetModifierConfig.intrinsicHeight()); + + if (options.isEmpty) { + debugLog.log('AskUserRadio', 'WARNING: radio input has no options.'); + return const SdBody('No options available'); + } + + return optionsRow( + children: options.asMap().entries.map((entry) { + final index = entry.key; + final option = entry.value; + final isSelected = _selectedIndex == index; + + return Expanded( + child: RadioOptionCard( + title: option.title, + description: option.description, + selected: isSelected, + onTap: () { + setState(() => _selectedIndex = index); + }, + ), + ); + }).toList(), + ); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_radio.g.dart b/packages/genui/lib/src/ai/catalog/ask_user_radio.g.dart new file mode 100644 index 00000000..22d2098d --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_radio.g.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'ask_user_radio.dart'; + +/// Extension type for InputOption +extension type InputOptionType(Map _data) + implements Map { + static InputOptionType parse(Object? data) { + return _inputOptionSchema.parseAs( + data, + (validated) => InputOptionType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _inputOptionSchema.safeParseAs( + data, + (validated) => InputOptionType(validated as Map), + ); + } + + Map toJson() => _data; + + String get title => _data['title'] as String; + + String? get description => _data['description'] as String?; + + InputOptionType copyWith({String? title, String? description}) { + return InputOptionType.parse({ + 'title': title ?? this.title, + 'description': description ?? this.description, + }); + } +} + +/// Extension type for AskUserRadio +extension type AskUserRadioType(Map _data) + implements Map { + static AskUserRadioType parse(Object? data) { + return _askUserRadioSchema.parseAs( + data, + (validated) => AskUserRadioType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _askUserRadioSchema.safeParseAs( + data, + (validated) => AskUserRadioType(validated as Map), + ); + } + + Map toJson() => _data; + + String get question => _data['question'] as String; + + String? get description => _data['description'] as String?; + + List get options => (_data['options'] as List) + .map((e) => InputOptionType(e as Map)) + .toList(); + + ActionType get action => ActionType(_data['action'] as Map); + + AskUserRadioType copyWith({ + String? question, + String? description, + List? options, + Map? action, + }) { + return AskUserRadioType.parse({ + 'question': question ?? this.question, + 'description': description ?? this.description, + 'options': options ?? this.options, + 'action': action ?? this.action, + }); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_slider.dart b/packages/genui/lib/src/ai/catalog/ask_user_slider.dart new file mode 100644 index 00000000..70b14505 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_slider.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; + +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:genui/genui.dart'; +import 'package:remix/remix.dart'; + +import '../schemas/genui_action_schema.dart'; +import './user_action_dispatch.dart'; +import '../../ui/ui.dart'; + +import 'catalog_question_step.dart'; + +part 'ask_user_slider.g.dart'; + +// ─────────────────────────────────── SCHEMA ─────────────────────────────────── + +/// Schema for AskUserSlider component. +/// +/// Displays a question with a slider for numeric input. +@AckType(name: 'AskUserSlider') +final _askUserSliderSchema = + Ack.object({ + 'question': Ack.string().describe('The question to display to the user'), + 'description': Ack.string().optional().describe( + 'Additional context or instructions', + ), + 'minValue': Ack.integer().describe('Minimum value'), + 'maxValue': Ack.integer().describe('Maximum value'), + 'defaultValue': Ack.integer().describe('Default/initial value'), + 'unit': Ack.string().optional().describe( + 'Unit label e.g. "slides", "minutes"', + ), + 'action': actionSchema, + }).describe( + 'A question with a slider for numeric selection between min and max.', + ); + +// ─────────────────────────────────── CATALOG ITEM ─────────────────────────────────── + +/// AskUserSlider catalog component for numeric input questions. +final askUserSlider = CatalogItem( + name: 'AskUserSlider', + dataSchema: _askUserSliderSchema.toJsonSchemaBuilder(), + exampleData: [ + () => ''' + [ + { + "id": "root", + "component": { + "AskUserSlider": { + "question": "How many slides do you need?", + "minValue": 5, + "maxValue": 25, + "defaultValue": 10, + "unit": "slides", + "action": {"name": "submit_answer", "context": []} + } + } + } + ] + ''', + ], + widgetBuilder: (context) { + final data = AskUserSliderType.parse(context.data); + return _AskUserSliderContent(data: data, itemContext: context); + }, +); + +// ─────────────────────────────────── WIDGET ─────────────────────────────────── + +class _AskUserSliderContent extends StatefulWidget { + final AskUserSliderType data; + final CatalogItemContext itemContext; + + const _AskUserSliderContent({required this.data, required this.itemContext}); + + @override + State<_AskUserSliderContent> createState() => _AskUserSliderContentState(); +} + +class _AskUserSliderContentState extends State<_AskUserSliderContent> { + int _sliderValue = 0; + + int _clampToRange(int value, {required int min, required int max}) { + return value.clamp(min, max).toInt(); + } + + @override + void initState() { + super.initState(); + final minVal = widget.data.minValue; + final maxVal = widget.data.maxValue; + final defaultVal = widget.data.defaultValue; + _sliderValue = _clampToRange(defaultVal, min: minVal, max: maxVal); + } + + @override + void didUpdateWidget(covariant _AskUserSliderContent oldWidget) { + super.didUpdateWidget(oldWidget); + + final minChanged = oldWidget.data.minValue != widget.data.minValue; + final maxChanged = oldWidget.data.maxValue != widget.data.maxValue; + final defaultChanged = + oldWidget.data.defaultValue != widget.data.defaultValue; + + final minValue = widget.data.minValue; + final maxValue = widget.data.maxValue; + if (defaultChanged) { + _sliderValue = _clampToRange( + widget.data.defaultValue, + min: minValue, + max: maxValue, + ); + return; + } + + if (minChanged || maxChanged) { + _sliderValue = _clampToRange(_sliderValue, min: minValue, max: maxValue); + } + } + + Map _buildActionContext() { + final unit = widget.data.unit ?? ''; + return { + 'value': _sliderValue, + 'message': '$_sliderValue${unit.isNotEmpty ? ' $unit' : ''}', + }; + } + + void _submitAction() => submitCatalogActionIfValid( + canSubmit: true, + itemContext: widget.itemContext, + rawAction: widget.data.action, + contextBuilder: _buildActionContext, + ); + + @override + Widget build(BuildContext context) { + return CatalogQuestionStep( + question: widget.data.question, + description: widget.data.description, + body: _buildSlider(), + onSubmit: _submitAction, + ); + } + + Widget _buildSlider() { + final minValue = widget.data.minValue; + final maxValue = widget.data.maxValue; + + final labelStyle = TextStyler() + .color(FortalTokens.gray11()) + .style(FortalTokens.text2.mix()); + + final valueStyle = TextStyler() + .color(FortalTokens.accent11()) + .style(FortalTokens.text6.mix()) + .fontWeight(.bold) + .wrap( + WidgetModifierConfig() + .sizedBox(width: 120) + .align(alignment: .centerRight), + ); + + final unit = widget.data.unit ?? ''; + final displayValue = '$_sliderValue${unit.isNotEmpty ? ' $unit' : ''}'; + + return SdPanel( + child: Row( + spacing: 12, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + labelStyle('$minValue'), + Expanded( + child: SdSlider( + value: _sliderValue.toDouble(), + min: minValue.toDouble(), + max: maxValue.toDouble(), + onChanged: (value) { + setState(() => _sliderValue = value.round()); + }, + ), + ), + labelStyle('$maxValue'), + valueStyle(displayValue), + ], + ), + ); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_slider.g.dart b/packages/genui/lib/src/ai/catalog/ask_user_slider.g.dart new file mode 100644 index 00000000..6152678f --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_slider.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'ask_user_slider.dart'; + +/// Extension type for AskUserSlider +extension type AskUserSliderType(Map _data) + implements Map { + static AskUserSliderType parse(Object? data) { + return _askUserSliderSchema.parseAs( + data, + (validated) => AskUserSliderType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _askUserSliderSchema.safeParseAs( + data, + (validated) => AskUserSliderType(validated as Map), + ); + } + + Map toJson() => _data; + + String get question => _data['question'] as String; + + String? get description => _data['description'] as String?; + + int get minValue => _data['minValue'] as int; + + int get maxValue => _data['maxValue'] as int; + + int get defaultValue => _data['defaultValue'] as int; + + String? get unit => _data['unit'] as String?; + + ActionType get action => ActionType(_data['action'] as Map); + + AskUserSliderType copyWith({ + String? question, + String? description, + int? minValue, + int? maxValue, + int? defaultValue, + String? unit, + Map? action, + }) { + return AskUserSliderType.parse({ + 'question': question ?? this.question, + 'description': description ?? this.description, + 'minValue': minValue ?? this.minValue, + 'maxValue': maxValue ?? this.maxValue, + 'defaultValue': defaultValue ?? this.defaultValue, + 'unit': unit ?? this.unit, + 'action': action ?? this.action, + }); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_style.dart b/packages/genui/lib/src/ai/catalog/ask_user_style.dart new file mode 100644 index 00000000..28fb1d5c --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_style.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; + +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:genui/genui.dart'; +import 'package:remix/remix.dart'; + +import '../prompts/font_styles.dart'; +import './user_action_dispatch.dart'; +import '../schemas/genui_action_schema.dart'; +import '../schemas/wizard_context_keys.dart'; +import '../../ui/ui.dart'; + +import 'ask_user_question_cards.dart'; +import 'catalog_question_step.dart'; + +part 'ask_user_style.g.dart'; + +// ─────────────────────────────────── SCHEMA ─────────────────────────────────── + +/// Schema for a style option with colors and fonts. +@AckType(name: 'StyleOption') +final _styleOptionSchema = Ack.object({ + 'id': Ack.string().describe('Unique identifier using snake_case'), + 'title': Ack.string().describe('Visual mood or theme name'), + 'description': Ack.string().describe('Brief explanation of the visual feel'), + 'colors': Ack.list( + Ack.string().describe('Hex color value'), + ).describe('List of hex color strings representing the color palette'), + 'headlineFont': Ack.enumValues( + HeadlineFont.values, + ).describe(HeadlineFont.schemaDescription), + 'bodyFont': Ack.enumValues( + BodyFont.values, + ).describe(BodyFont.schemaDescription), +}).describe('Style option with colors and fonts for visual theming'); + +/// Schema for AskUserStyle component. +/// +/// Displays a question with visual style options for selection. +@AckType(name: 'AskUserStyle') +final _askUserStyleSchema = + Ack.object({ + 'question': Ack.string().describe('The question to display to the user'), + 'description': Ack.string().optional().describe( + 'Additional context or instructions', + ), + 'styleOptions': Ack.list( + _styleOptionSchema, + ).describe('Style options with colors and fonts for visual theming'), + 'action': actionSchema, + }).describe( + 'A question with visual style options. User selects one style with colors and fonts.', + ); + +// ─────────────────────────────────── CATALOG ITEM ─────────────────────────────────── + +/// AskUserStyle catalog component for visual style selection. +final askUserStyle = CatalogItem( + name: 'AskUserStyle', + dataSchema: _askUserStyleSchema.toJsonSchemaBuilder(), + exampleData: [ + () => ''' + [ + { + "id": "root", + "component": { + "AskUserStyle": { + "question": "Choose a visual style", + "description": "Pick the palette and fonts that best fit your presentation.", + "styleOptions": [ + { + "id": "professional_clean", + "title": "Professional & Clean", + "description": "Muted palette with crisp typography.", + "colors": ["#F8FAFC", "#1E3A8A", "#475569"], + "headlineFont": "montserrat", + "bodyFont": "openSans" + }, + { + "id": "playful_bright", + "title": "Playful & Bright", + "description": "Cheerful colors with friendly fonts.", + "colors": ["#F5F3FF", "#5B21B6", "#6B7280"], + "headlineFont": "lobster", + "bodyFont": "inter" + } + ], + "action": {"name": "submit_answer", "context": []} + } + } + } + ] + ''', + ], + widgetBuilder: (context) { + final data = AskUserStyleType.parse(context.data); + return _AskUserStyleContent(data: data, itemContext: context); + }, +); + +// ─────────────────────────────────── WIDGET ─────────────────────────────────── + +class _AskUserStyleContent extends StatefulWidget { + final AskUserStyleType data; + final CatalogItemContext itemContext; + + const _AskUserStyleContent({required this.data, required this.itemContext}); + + @override + State<_AskUserStyleContent> createState() => _AskUserStyleContentState(); +} + +class _AskUserStyleContentState extends State<_AskUserStyleContent> { + int? _selectedStyleIndex; + StyleOptionType? _selectedStyleOption; + + bool get _canSubmit => _selectedStyleOption != null; + + Map _buildActionContext() { + if (_selectedStyleOption case final option?) { + return { + WizardContextKeys.style: option.title, + WizardContextKeys.title: option.title, + WizardContextKeys.description: option.description, + WizardContextKeys.colors: option.colors.toList(), + WizardContextKeys.headlineFont: option.headlineFont.name, + WizardContextKeys.bodyFont: option.bodyFont.name, + WizardContextKeys.message: option.title, + }; + } + return {}; + } + + void _submitAction() => submitCatalogActionIfValid( + canSubmit: _canSubmit, + itemContext: widget.itemContext, + rawAction: widget.data.action, + contextBuilder: _buildActionContext, + ); + + @override + Widget build(BuildContext context) { + return CatalogQuestionStep( + question: widget.data.question, + description: widget.data.description, + body: _buildOptions(), + canSubmit: _canSubmit, + onSubmit: _submitAction, + ); + } + + Widget _buildOptions() { + final options = widget.data.styleOptions; + + if (options.isEmpty) { + return const SdBody('No style options configured'); + } + + final optionsRow = FlexBoxStyler() + .spacing(16) + .wrap(WidgetModifierConfig.intrinsicHeight()); + + return optionsRow( + children: options.asMap().entries.map((entry) { + final index = entry.key; + final option = entry.value; + final isSelected = _selectedStyleIndex == index; + + return Expanded( + child: StyleOptionCard( + title: option.title, + description: option.description, + colors: option.colors.toList(), + headlineFontId: option.headlineFont.name, + bodyFontId: option.bodyFont.name, + selected: isSelected, + onTap: () { + setState(() { + _selectedStyleIndex = index; + _selectedStyleOption = option; + }); + }, + ), + ); + }).toList(), + ); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_style.g.dart b/packages/genui/lib/src/ai/catalog/ask_user_style.g.dart new file mode 100644 index 00000000..600ffa95 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_style.g.dart @@ -0,0 +1,104 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'ask_user_style.dart'; + +List _$ackListCast(Object? value) => (value as List).cast(); + +/// Extension type for StyleOption +extension type StyleOptionType(Map _data) + implements Map { + static StyleOptionType parse(Object? data) { + return _styleOptionSchema.parseAs( + data, + (validated) => StyleOptionType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _styleOptionSchema.safeParseAs( + data, + (validated) => StyleOptionType(validated as Map), + ); + } + + Map toJson() => _data; + + String get id => _data['id'] as String; + + String get title => _data['title'] as String; + + String get description => _data['description'] as String; + + List get colors => _$ackListCast(_data['colors']); + + HeadlineFont get headlineFont => _data['headlineFont'] as HeadlineFont; + + BodyFont get bodyFont => _data['bodyFont'] as BodyFont; + + StyleOptionType copyWith({ + String? id, + String? title, + String? description, + List? colors, + HeadlineFont? headlineFont, + BodyFont? bodyFont, + }) { + return StyleOptionType.parse({ + 'id': id ?? this.id, + 'title': title ?? this.title, + 'description': description ?? this.description, + 'colors': colors ?? this.colors, + 'headlineFont': headlineFont ?? this.headlineFont, + 'bodyFont': bodyFont ?? this.bodyFont, + }); + } +} + +/// Extension type for AskUserStyle +extension type AskUserStyleType(Map _data) + implements Map { + static AskUserStyleType parse(Object? data) { + return _askUserStyleSchema.parseAs( + data, + (validated) => AskUserStyleType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _askUserStyleSchema.safeParseAs( + data, + (validated) => AskUserStyleType(validated as Map), + ); + } + + Map toJson() => _data; + + String get question => _data['question'] as String; + + String? get description => _data['description'] as String?; + + List get styleOptions => (_data['styleOptions'] as List) + .map((e) => StyleOptionType(e as Map)) + .toList(); + + ActionType get action => ActionType(_data['action'] as Map); + + AskUserStyleType copyWith({ + String? question, + String? description, + List? styleOptions, + Map? action, + }) { + return AskUserStyleType.parse({ + 'question': question ?? this.question, + 'description': description ?? this.description, + 'styleOptions': styleOptions ?? this.styleOptions, + 'action': action ?? this.action, + }); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_text.dart b/packages/genui/lib/src/ai/catalog/ask_user_text.dart new file mode 100644 index 00000000..4fd63d08 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_text.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:genui/genui.dart'; +import 'package:remix/remix.dart'; + +import '../schemas/genui_action_schema.dart'; +import './user_action_dispatch.dart'; +import '../../ui/ui.dart'; + +import 'catalog_question_step.dart'; + +part 'ask_user_text.g.dart'; + +// ─────────────────────────────────── SCHEMA ─────────────────────────────────── + +/// Schema for AskUserText component. +/// +/// Displays a question with a text field for free-form input. +@AckType(name: 'AskUserText') +final _askUserTextSchema = Ack.object({ + 'question': Ack.string().describe('The question to display to the user'), + 'description': Ack.string().optional().describe( + 'Additional context or instructions', + ), + 'placeholder': Ack.string().optional().describe('Placeholder text'), + 'maxLength': Ack.integer().optional().describe('Maximum character length'), + 'multiline': Ack.boolean().optional().describe('Allow multiline input'), + 'action': actionSchema, +}).describe('A question with a text field for free-form user input.'); + +// ─────────────────────────────────── CATALOG ITEM ─────────────────────────────────── + +/// AskUserText catalog component for free-form text input. +final askUserText = CatalogItem( + name: 'AskUserText', + dataSchema: _askUserTextSchema.toJsonSchemaBuilder(), + exampleData: [ + () => ''' + [ + { + "id": "root", + "component": { + "AskUserText": { + "question": "What is your presentation topic?", + "description": "Enter a brief description of your topic.", + "placeholder": "e.g., Introduction to Machine Learning", + "maxLength": 200, + "action": {"name": "submit_answer", "context": []} + } + } + } + ] + ''', + ], + widgetBuilder: (context) { + final data = AskUserTextType.parse(context.data); + return _AskUserTextContent(data: data, itemContext: context); + }, +); + +// ─────────────────────────────────── WIDGET ─────────────────────────────────── + +class _AskUserTextContent extends StatefulWidget { + final AskUserTextType data; + final CatalogItemContext itemContext; + + const _AskUserTextContent({required this.data, required this.itemContext}); + + @override + State<_AskUserTextContent> createState() => _AskUserTextContentState(); +} + +class _AskUserTextContentState extends State<_AskUserTextContent> { + final _textController = TextEditingController(); + + bool get _canSubmit => _textController.text.trim().isNotEmpty; + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + Map _buildActionContext() { + final text = _textController.text.trim(); + return {'text': text, 'message': text}; + } + + void _submitAction() => submitCatalogActionIfValid( + canSubmit: _canSubmit, + itemContext: widget.itemContext, + rawAction: widget.data.action, + contextBuilder: _buildActionContext, + ); + + @override + Widget build(BuildContext context) { + return CatalogQuestionStep( + question: widget.data.question, + description: widget.data.description, + canSubmit: _canSubmit, + onSubmit: _submitAction, + body: SdPanel( + child: TextField( + controller: _textController, + maxLength: widget.data.maxLength, + maxLines: widget.data.multiline == true ? 4 : 1, + decoration: InputDecoration( + hintText: widget.data.placeholder, + border: InputBorder.none, + counterText: '', + ), + style: FortalTokens.text3.mix().resolve(context), + onChanged: (_) => setState(() {}), + ), + ), + ); + } +} diff --git a/packages/genui/lib/src/ai/catalog/ask_user_text.g.dart b/packages/genui/lib/src/ai/catalog/ask_user_text.g.dart new file mode 100644 index 00000000..d9610154 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/ask_user_text.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'ask_user_text.dart'; + +/// Extension type for AskUserText +extension type AskUserTextType(Map _data) + implements Map { + static AskUserTextType parse(Object? data) { + return _askUserTextSchema.parseAs( + data, + (validated) => AskUserTextType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _askUserTextSchema.safeParseAs( + data, + (validated) => AskUserTextType(validated as Map), + ); + } + + Map toJson() => _data; + + String get question => _data['question'] as String; + + String? get description => _data['description'] as String?; + + String? get placeholder => _data['placeholder'] as String?; + + int? get maxLength => _data['maxLength'] as int?; + + bool? get multiline => _data['multiline'] as bool?; + + ActionType get action => ActionType(_data['action'] as Map); + + AskUserTextType copyWith({ + String? question, + String? description, + String? placeholder, + int? maxLength, + bool? multiline, + Map? action, + }) { + return AskUserTextType.parse({ + 'question': question ?? this.question, + 'description': description ?? this.description, + 'placeholder': placeholder ?? this.placeholder, + 'maxLength': maxLength ?? this.maxLength, + 'multiline': multiline ?? this.multiline, + 'action': action ?? this.action, + }); + } +} diff --git a/packages/genui/lib/src/ai/catalog/catalog.dart b/packages/genui/lib/src/ai/catalog/catalog.dart new file mode 100644 index 00000000..6a8712a5 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/catalog.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:genui/genui.dart'; +import '../../debug_logger.dart'; + +import 'ask_user_checkbox.dart'; +import 'ask_user_image_style.dart'; +import 'ask_user_radio.dart'; +import 'ask_user_slider.dart'; +import 'ask_user_style.dart'; +import 'ask_user_text.dart'; +import 'summary_card.dart'; + +export 'ask_user_checkbox.dart'; +export 'ask_user_image_style.dart'; +export 'ask_user_radio.dart'; +export 'ask_user_slider.dart'; +export 'ask_user_style.dart'; +export 'ask_user_text.dart'; +export 'summary_card.dart'; + +/// Wraps a CatalogItem's widgetBuilder with error handling. +/// Catches parse/build errors and returns fallback UI. +CatalogItem _withErrorHandling(CatalogItem item) { + return CatalogItem( + name: item.name, + dataSchema: item.dataSchema, + exampleData: item.exampleData, + widgetBuilder: (context) { + try { + return item.widgetBuilder(context); + } catch (e, stack) { + debugLog.error(item.name, 'Failed to build: $e', stack); + return Center(child: Text('Failed to load ${item.name}')); + } + }, + ); +} + +/// SuperDeck AI chat catalog with GenUI components. +/// +/// Components: +/// - [askUserRadio] - Radio button single selection +/// - [askUserCheckbox] - Checkbox multiple selection +/// - [askUserSlider] - Slider numeric input +/// - [askUserText] - Free-form text input +/// - [askUserStyle] - Visual style selection with colors and fonts +/// - [askUserImageStyle] - Image style selection with generated previews +/// - [summaryCard] - Wizard summary with aggregated selections +final chatCatalog = Catalog([ + _withErrorHandling(askUserRadio), + _withErrorHandling(askUserCheckbox), + _withErrorHandling(askUserSlider), + _withErrorHandling(askUserText), + _withErrorHandling(askUserStyle), + _withErrorHandling(askUserImageStyle), + _withErrorHandling(summaryCard), +], catalogId: 'com.superdeck.ai.chat'); diff --git a/packages/genui/lib/src/ai/catalog/catalog_question_step.dart b/packages/genui/lib/src/ai/catalog/catalog_question_step.dart new file mode 100644 index 00000000..e20c6250 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/catalog_question_step.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; +import '../../ui/ui.dart'; + +/// Shared layout for catalog question widgets with a header, body, and CTA. +class CatalogQuestionStep extends StatelessWidget { + const CatalogQuestionStep({ + super.key, + required this.question, + required this.body, + required this.onSubmit, + this.description, + this.canSubmit = true, + }); + + final String question; + final String? description; + final Widget body; + final VoidCallback? onSubmit; + final bool canSubmit; + + @override + Widget build(BuildContext context) { + final column = FlexBoxStyler() + .column() + .crossAxisAlignment(.start) + .spacing(16); + + return column( + children: [ + SdSectionHeader(heading: question, subheading: description), + body, + CatalogNextButton(onPressed: canSubmit ? onSubmit : null), + ], + ); + } +} diff --git a/packages/genui/lib/src/ai/catalog/summary_card.dart b/packages/genui/lib/src/ai/catalog/summary_card.dart new file mode 100644 index 00000000..e7889aec --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/summary_card.dart @@ -0,0 +1,259 @@ +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:genui/genui.dart'; +import 'package:go_router/go_router.dart'; +import 'package:remix/remix.dart'; +import '../../chat/chat_viewmodel.dart'; +import '../wizard_context.dart'; +import '../prompts/font_styles.dart'; +import '../prompts/image_style_prompts.dart'; +import '../schemas/genui_action_schema.dart'; +import '../schemas/wizard_context_keys.dart'; +import '../../viewmodel_scope.dart'; +import '../../debug_logger.dart'; +import '../../routes.dart'; +import '../../ui/ui.dart'; +import '../../utils/color_utils.dart'; +import '../../utils/font_utils.dart'; +import '../../presentation/presentation_viewmodel.dart'; + +part 'summary_card.g.dart'; +part 'summary_card_view.dart'; + +enum SummaryItemKind { text, style, imageStyle } + +/// Schema for summary item with optional fields for different item types. +@AckType(name: 'SummaryItem') +final _summaryItemSchema = Ack.object({ + 'kind': Ack.enumValues( + SummaryItemKind.values, + ).optional().describe('Discriminator for summary payload shape'), + 'label': Ack.string().describe('The category label for this selection'), + 'title': Ack.string().optional().describe( + 'The primary text representing the user\'s choice', + ), + 'description': Ack.string().optional().describe( + 'Additional details about the selection', + ), + 'text': Ack.string().optional().describe( + 'Plain text content for simple display items', + ), + 'colors': Ack.list( + Ack.string().describe('Hex color value'), + ).optional().describe('List of hex color strings for the style palette'), + 'headlineFont': Ack.enumValues( + HeadlineFont.values, + ).optional().describe(HeadlineFont.schemaDescription), + 'bodyFont': Ack.enumValues( + BodyFont.values, + ).optional().describe(BodyFont.schemaDescription), + 'imageStyleId': Ack.enumValues( + ImageStyle.values, + ).optional().describe(ImageStyle.schemaDescription()), +}).describe('Summary item representing a user selection'); + +/// Schema for SummaryCard component using ACK fluent API. +@AckType(name: 'SummaryCard') +final _summaryCardSchema = + Ack.object({ + 'title': Ack.string().describe('The main heading of the summary card'), + 'items': Ack.list( + _summaryItemSchema, + ).describe('List of summary items representing user selections'), + 'generateSlidesAction': actionSchema, + }).describe( + 'Summary card showing recap of all user selections before finalizing', + ); + +/// Extension for SummaryItemType to add computed properties. +extension SummaryItemExt on SummaryItemType { + bool get hasKind => kind != null; + + /// Returns true if this item has style data (colors and fonts). + bool get hasStyleData => + colors != null && headlineFont != null && bodyFont != null; + + /// Returns true if this item has image style data. + bool get hasImageStyleData => imageStyleId != null; + + /// Validates supported field combinations for summary rendering. + /// + /// This keeps backward compatibility with the current schema while surfacing + /// malformed payloads early. + String? get shapeValidationError { + final hasTitleOrText = title != null || text != null; + final hasAnyStyleFields = + colors != null || headlineFont != null || bodyFont != null; + + final explicitKind = kind; + if (explicitKind != null) { + switch (explicitKind) { + case SummaryItemKind.style: + if (!hasStyleData) { + return 'style kind requires colors/headlineFont/bodyFont'; + } + if (imageStyleId != null) { + return 'style kind should not include imageStyleId'; + } + if (!hasTitleOrText) { + return 'style kind requires title or text'; + } + return null; + case SummaryItemKind.imageStyle: + if (imageStyleId == null) { + return 'imageStyle kind requires imageStyleId'; + } + if (hasAnyStyleFields) { + return 'imageStyle kind should not include style palette fields'; + } + return null; + case SummaryItemKind.text: + if (!hasTitleOrText) { + return 'text kind requires title or text'; + } + if (hasAnyStyleFields || imageStyleId != null) { + return 'text kind should not include style or imageStyle fields'; + } + return null; + } + } + + if (hasAnyStyleFields && !hasStyleData) { + return 'style item is missing colors/headlineFont/bodyFont'; + } + + if (hasImageStyleData && hasAnyStyleFields) { + return 'imageStyleId should not be combined with style palette fields'; + } + + if (!hasStyleData && !hasImageStyleData && !hasTitleOrText) { + return 'item must include either title or text'; + } + + return null; + } +} + +/// A summary card that displays a recap of all user selections before finalizing. +/// +/// Shows multiple items with labels and values. Items with color and font data +/// are rendered as style previews, otherwise they display as title/description pairs. +final summaryCard = CatalogItem( + name: 'SummaryCard', + dataSchema: _summaryCardSchema.toJsonSchemaBuilder(), + exampleData: [ + () => ''' + [ + { + "id": "root", + "component": { + "SummaryCard": { + "title": "Summary", + "items": [ + { + "kind": "text", + "label": "Topic", + "title": "Introduction to Astronomy", + "description": "A beginner-friendly overview of space and celestial objects" + }, + { + "kind": "text", + "label": "Audience", + "title": "Middle School Students", + "description": "Ages 11-14" + }, + { + "kind": "text", + "label": "Approach", + "title": "Interactive & Visual", + "description": "Engaging visuals with hands-on examples" + }, + { + "kind": "text", + "label": "Emphasis", + "text": "Planets, Stars, Space Exploration" + }, + { + "kind": "text", + "label": "Slide Count", + "text": "12 slides" + }, + { + "kind": "style", + "label": "Style", + "title": "Cosmic Blue", + "description": "Deep space theme with vibrant accents", + "colors": ["#0F172A", "#60A5FA", "#94A3B8"], + "headlineFont": "oswald", + "bodyFont": "inter" + }, + { + "kind": "imageStyle", + "label": "Image Style", + "imageStyleId": "minimalist" + } + ], + "generateSlidesAction": { + "name": "generate_slides", + "context": [] + } + } + } + } + ] + ''', + ], + widgetBuilder: (catalogContext) { + final data = SummaryCardType.parse(catalogContext.data); + + // Use ActionType for type-safe action parsing with validation + final action = ActionType.parse(data.generateSlidesAction); + final contextDefinition = action.context ?? []; + + return Builder( + builder: (buildContext) { + return SummaryCard( + title: data.title, + items: data.items.toList(), + generateSlides: () { + debugLog.section('Generate Slides Triggered'); + + // Extract context from displayed summary items + final extractedContext = _extractContextFromItems( + data.items.toList(), + ); + debugLog.userAction('GENERATE_SLIDES', extractedContext.toMap()); + + // Merge with any path-resolved context from the action + final resolvedContext = WizardContext.fromMap( + resolveContext(catalogContext.dataContext, contextDefinition), + ); + final finalContext = extractedContext.merge(resolvedContext); + debugLog.log('GEN', 'Final context: ${finalContext.toMap()}'); + + final presentationVM = buildContext.read(); + final chatVM = buildContext.read(); + + debugLog.log( + 'GEN', + 'Starting generation and navigating to loading screen', + ); + + // Fire-and-forget - PresentationViewModel manages state + presentationVM.generate( + context: finalContext, + callback: chatVM.generateFromContext, + ); + + // Navigate immediately to show loading + if (buildContext.mounted) { + buildContext.go(GenUiRoutes.presentationCreating); + } + }, + ); + }, + ); + }, +); diff --git a/packages/genui/lib/src/ai/catalog/summary_card.g.dart b/packages/genui/lib/src/ai/catalog/summary_card.g.dart new file mode 100644 index 00000000..df7aab13 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/summary_card.g.dart @@ -0,0 +1,114 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'summary_card.dart'; + +List _$ackListCast(Object? value) => (value as List).cast(); + +/// Extension type for SummaryItem +extension type SummaryItemType(Map _data) + implements Map { + static SummaryItemType parse(Object? data) { + return _summaryItemSchema.parseAs( + data, + (validated) => SummaryItemType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _summaryItemSchema.safeParseAs( + data, + (validated) => SummaryItemType(validated as Map), + ); + } + + Map toJson() => _data; + + SummaryItemKind? get kind => _data['kind'] as SummaryItemKind?; + + String get label => _data['label'] as String; + + String? get title => _data['title'] as String?; + + String? get description => _data['description'] as String?; + + String? get text => _data['text'] as String?; + + List? get colors => + _data['colors'] != null ? _$ackListCast(_data['colors']) : null; + + HeadlineFont? get headlineFont => _data['headlineFont'] as HeadlineFont?; + + BodyFont? get bodyFont => _data['bodyFont'] as BodyFont?; + + ImageStyle? get imageStyleId => _data['imageStyleId'] as ImageStyle?; + + SummaryItemType copyWith({ + SummaryItemKind? kind, + String? label, + String? title, + String? description, + String? text, + List? colors, + HeadlineFont? headlineFont, + BodyFont? bodyFont, + ImageStyle? imageStyleId, + }) { + return SummaryItemType.parse({ + 'kind': kind ?? this.kind, + 'label': label ?? this.label, + 'title': title ?? this.title, + 'description': description ?? this.description, + 'text': text ?? this.text, + 'colors': colors ?? this.colors, + 'headlineFont': headlineFont ?? this.headlineFont, + 'bodyFont': bodyFont ?? this.bodyFont, + 'imageStyleId': imageStyleId ?? this.imageStyleId, + }); + } +} + +/// Extension type for SummaryCard +extension type SummaryCardType(Map _data) + implements Map { + static SummaryCardType parse(Object? data) { + return _summaryCardSchema.parseAs( + data, + (validated) => SummaryCardType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _summaryCardSchema.safeParseAs( + data, + (validated) => SummaryCardType(validated as Map), + ); + } + + Map toJson() => _data; + + String get title => _data['title'] as String; + + List get items => (_data['items'] as List) + .map((e) => SummaryItemType(e as Map)) + .toList(); + + ActionType get generateSlidesAction => + ActionType(_data['generateSlidesAction'] as Map); + + SummaryCardType copyWith({ + String? title, + List? items, + Map? generateSlidesAction, + }) { + return SummaryCardType.parse({ + 'title': title ?? this.title, + 'items': items ?? this.items, + 'generateSlidesAction': generateSlidesAction ?? this.generateSlidesAction, + }); + } +} diff --git a/packages/genui/lib/src/ai/catalog/summary_card_view.dart b/packages/genui/lib/src/ai/catalog/summary_card_view.dart new file mode 100644 index 00000000..31b59547 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/summary_card_view.dart @@ -0,0 +1,353 @@ +part of 'summary_card.dart'; + +/// Extracts generation context from summary items. +/// +/// Uses [WizardContextKeys] for standardized label-to-key mapping. +/// Logs warnings for any unmapped labels to aid debugging. +WizardContext _extractContextFromItems(List items) { + final contextMap = {}; + final unmappedLabels = []; + + for (final item in items) { + final shapeError = item.shapeValidationError; + if (shapeError != null) { + debugLog.log( + 'SummaryCard', + 'Invalid item shape for "${item.label}": $shapeError', + ); + continue; + } + + final key = WizardContextKeys.labelToKey(item.label); + if (key == null) { + unmappedLabels.add(item.label); + continue; + } + + // Use generated nullable getters for optional fields + final itemTitle = item.title; + final itemDescription = item.description; + final itemText = item.text; + final itemColors = item.colors; + final itemHeadlineFont = item.headlineFont; + final itemBodyFont = item.bodyFont; + final itemImageStyleId = item.imageStyleId; + + // Extract the primary value + if (itemText != null) { + contextMap[key] = _parseContextValue(key, itemText); + } else if (itemTitle != null) { + contextMap[key] = itemTitle; + } + + // Extract style-related data using standardized keys + if (itemColors != null && itemColors.isNotEmpty) { + contextMap[WizardContextKeys.colors] = itemColors; + } + if (itemHeadlineFont != null) { + contextMap[WizardContextKeys.headlineFont] = itemHeadlineFont.name; + } + if (itemBodyFont != null) { + contextMap[WizardContextKeys.bodyFont] = itemBodyFont.name; + } + + // Extract image style data for generation context + if (itemImageStyleId != null) { + contextMap[WizardContextKeys.imageStyleId] = itemImageStyleId.name; + contextMap[WizardContextKeys.imageStyleName] = itemImageStyleId.title; + contextMap.putIfAbsent( + WizardContextKeys.imageStyleDescription, + () => itemImageStyleId.description, + ); + } + if (key == WizardContextKeys.imageStyleName && itemDescription != null) { + contextMap[WizardContextKeys.imageStyleDescription] = itemDescription; + } + } + + // Log warnings for unmapped labels + if (unmappedLabels.isNotEmpty) { + debugLog.log( + 'SummaryCard', + 'Unmapped labels ignored: ${unmappedLabels.join(", ")}', + ); + } + + final context = WizardContext.fromMap(contextMap); + + // Validate required context keys + _validateRequiredContext(context); + + return context; +} + +/// Parses a text value for a specific context key. +/// +/// Handles special parsing like extracting numbers from slide count text. +dynamic _parseContextValue(String key, String text) { + if (key == WizardContextKeys.slideCount) { + // Match patterns like "12 slides", "12", "Slide Count: 12" + final match = RegExp(r'\b(\d{1,3})\b').firstMatch(text); + if (match != null) { + return int.tryParse(match.group(1)!) ?? text; + } + return text; + } + return text; +} + +/// Validates that required context keys are present. +/// +/// Logs warnings for any missing required keys. +void _validateRequiredContext(WizardContext context) { + final missing = []; + + if (context.topic == null) missing.add(WizardContextKeys.topic); + if (context.audience == null) missing.add(WizardContextKeys.audience); + if (context.slideCount == null) missing.add(WizardContextKeys.slideCount); + + if (missing.isNotEmpty) { + debugLog.log( + 'SummaryCard', + 'Missing recommended context keys: ${missing.join(", ")}', + ); + } +} + +/// The main summary card widget. +class SummaryCard extends StatelessWidget { + final String title; + final List items; + final VoidCallback? generateSlides; + + const SummaryCard({ + super.key, + required this.title, + required this.items, + this.generateSlides, + }); + + FlexBoxStyler get _container => .new() + .borderRadiusAll(Radius.circular(SdTokens.cardRadius)) + .mainAxisSize(.min) + .spacing(16) + .crossAxisAlignment(.start) + .paddingAll(SdTokens.cardPadding) + .borderAll(color: FortalTokens.gray5()) + .column(); + + @override + Widget build(BuildContext context) { + return _container( + children: [ + SdHeadline(title), + LayoutBuilder( + builder: (context, constraints) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: (constraints.maxHeight * 0.6).clamp(200.0, 500.0), + ), + child: SingleChildScrollView( + primary: false, + physics: const ClampingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: items + .map((item) => _SummaryCardItem(item: item)) + .toList(), + ), + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: SdButton( + label: 'Generate Slides', + icon: Icons.generating_tokens, + onPressed: generateSlides, + ), + ), + ], + ); + } +} + +class _SummaryCardItem extends StatelessWidget { + final SummaryItemType item; + + const _SummaryCardItem({required this.item}); + + // Label with fixed width for consistent alignment + TextStyler get _label => TextStyler() + .color(FortalTokens.gray12()) + .style(FortalTokens.text3.mix()) + .wrap(WidgetModifierConfig.sizedBox(width: 140)); + + @override + Widget build(BuildContext context) { + return SdPanel( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _label(item.label), + Expanded(child: _buildContent()), + ], + ), + ); + } + + Widget _buildContent() { + final shapeError = item.shapeValidationError; + if (shapeError != null) { + debugLog.log( + 'SummaryCard', + 'Rendering fallback for "${item.label}": $shapeError', + ); + return _SummaryCardItemTitleDescription( + title: item.title ?? item.text ?? '', + description: item.description ?? 'Invalid summary item data', + ); + } + + // Use generated nullable getters for optional fields + final itemTitle = item.title; + final itemDescription = item.description; + final itemText = item.text; + final itemColors = item.colors; + final itemHeadlineFont = item.headlineFont; + final itemBodyFont = item.bodyFont; + final itemImageStyleId = item.imageStyleId; + + // Style items with colors and fonts + if (item.hasStyleData) { + return _SummaryCardItemStyle( + title: itemTitle ?? '', + description: itemDescription ?? '', + colors: itemColors!, + headlineFont: itemHeadlineFont!.name, + bodyFont: itemBodyFont!.name, + ); + } + + // Image style items + if (item.hasImageStyleData) { + return _SummaryCardItemImageStyle(imageStyle: itemImageStyleId!); + } + + // Default: titleDescription or text display + return _SummaryCardItemTitleDescription( + title: itemTitle ?? itemText ?? '', + description: itemDescription, + ); + } +} + +class _SummaryCardItemTitleDescription extends StatelessWidget { + final String title; + final String? description; + + const _SummaryCardItemTitleDescription({ + required this.title, + this.description, + }); + + @override + Widget build(BuildContext context) { + final flex = FlexBoxStyler() + .spacing(4) + .mainAxisAlignment(.start) + .crossAxisAlignment(.start) + .column(); + + return flex( + children: [ + SdBody(title), + if (description != null) SdCaption(description!), + ], + ); + } +} + +class _SummaryCardItemStyle extends StatelessWidget { + final String title; + final String description; + final List colors; + final String headlineFont; + final String bodyFont; + + const _SummaryCardItemStyle({ + required this.title, + required this.description, + required this.colors, + required this.headlineFont, + required this.bodyFont, + }); + + @override + Widget build(BuildContext context) { + final flex = FlexBoxStyler() + .spacing(4) + .mainAxisAlignment(.start) + .crossAxisAlignment(.start) + .column(); + + final colorRow = FlexBoxStyler().paddingY(5).row(); + + // Convert enum IDs to font metadata + final headlineFontData = HeadlineFont.fromId(headlineFont); + final bodyFontData = BodyFont.fromId(bodyFont); + + // Get actual font family names for GoogleFonts (fallback to raw value) + final headlineFontFamily = headlineFontData?.fontFamily ?? headlineFont; + final bodyFontFamily = bodyFontData?.fontFamily ?? bodyFont; + + // Safely load fonts - fall back to default if unavailable + final headlineFontLoaded = tryGetGoogleFontFamily(headlineFontFamily); + final bodyFontLoaded = tryGetGoogleFontFamily(bodyFontFamily); + + return flex( + children: [ + SdBody(title), + SdCaption(description), + colorRow( + children: colors + .map((color) => SdColorCircle(color: hexToColor(color))) + .toList(), + ), + SdBody( + headlineFontData?.title ?? headlineFont, + style: headlineFontLoaded != null + ? TextStyler().fontFamily(headlineFontLoaded) + : null, + ), + SdCaption( + bodyFontData?.title ?? bodyFont, + style: bodyFontLoaded != null + ? TextStyler().fontFamily(bodyFontLoaded) + : null, + ), + ], + ); + } +} + +/// Widget for displaying image style summary items. +class _SummaryCardItemImageStyle extends StatelessWidget { + final ImageStyle imageStyle; + + const _SummaryCardItemImageStyle({required this.imageStyle}); + + @override + Widget build(BuildContext context) { + final flex = FlexBoxStyler() + .spacing(4) + .mainAxisAlignment(.start) + .crossAxisAlignment(.start) + .column(); + + // Show style name and description from enum + return flex( + children: [SdBody(imageStyle.title), SdCaption(imageStyle.description)], + ); + } +} diff --git a/packages/genui/lib/src/ai/catalog/user_action_dispatch.dart b/packages/genui/lib/src/ai/catalog/user_action_dispatch.dart new file mode 100644 index 00000000..ac564b59 --- /dev/null +++ b/packages/genui/lib/src/ai/catalog/user_action_dispatch.dart @@ -0,0 +1,44 @@ +import 'package:genui/genui.dart'; +import '../schemas/genui_action_schema.dart'; + +typedef CatalogActionContextBuilder = Map Function(); + +/// Dispatches a user action event with merged catalog + component context. +void dispatchCatalogAction({ + required CatalogItemContext itemContext, + required Object? rawAction, + required Map actionContext, +}) { + final action = ActionType.parse(rawAction); + final resolvedContext = resolveContext( + itemContext.dataContext, + action.context ?? [], + ); + resolvedContext.addAll(actionContext); + + itemContext.dispatchEvent( + UserActionEvent( + name: action.name, + sourceComponentId: itemContext.id, + context: resolvedContext, + ), + ); +} + +/// Dispatches a catalog action only when [canSubmit] is true. +/// +/// This centralizes the common submit guard + context builder pattern used by +/// multiple catalog question widgets. +void submitCatalogActionIfValid({ + required bool canSubmit, + required CatalogItemContext itemContext, + required Object? rawAction, + required CatalogActionContextBuilder contextBuilder, +}) { + if (!canSubmit) return; + dispatchCatalogAction( + itemContext: itemContext, + rawAction: rawAction, + actionContext: contextBuilder(), + ); +} diff --git a/packages/genui/lib/src/ai/prompts/examples_loader.dart b/packages/genui/lib/src/ai/prompts/examples_loader.dart new file mode 100644 index 00000000..9475e4f9 --- /dev/null +++ b/packages/genui/lib/src/ai/prompts/examples_loader.dart @@ -0,0 +1,149 @@ +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import '../../constants/paths.dart'; +import '../../debug_logger.dart'; + +/// Loads example prompt/result pairs from assets for few-shot learning. +/// +/// Examples are stored in `assets/examples/` as pairs: +/// - `{name}_prompt.txt` - The input prompt +/// - `{name}_deck.json` - The expected output +/// +/// These are formatted and injected into the system prompt to guide +/// the AI's output format and quality. +class ExamplesLoader { + ExamplesLoader._(); + + static final instance = ExamplesLoader._(); + + final _examples = []; + bool _loaded = false; + Future? _loading; + + bool get isLoaded => _loaded; + List get examples => List.unmodifiable(_examples); + + /// Loads all example pairs from assets. + Future load() { + if (_loaded) return Future.value(); + if (_loading != null) return _loading!; + _loading = _loadInternal(); + return _loading!; + } + + Future _loadInternal() async { + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + + // Find all prompt files in examples directory and sort for deterministic order + // Note: Uses examplesAssetsDir (const) for asset manifest lookup, not examplesDir (runtime) + final promptPaths = + manifest + .listAssets() + .where( + (path) => + path.startsWith(Paths.examplesAssetsDir) && + path.endsWith('_prompt.txt'), + ) + .toList() + ..sort(); + + for (final promptPath in promptPaths) { + // Derive deck path from prompt path + // e.g., assets/examples/coffee_prompt.txt -> assets/examples/coffee_deck.json + final deckPath = promptPath.replaceAll('_prompt.txt', '_deck.json'); + + try { + final prompt = await rootBundle.loadString(promptPath); + final result = await rootBundle.loadString(deckPath); + + // Extract name from path (e.g., "coffee" from "coffee_prompt.txt") + final name = p + .basenameWithoutExtension(promptPath) + .replaceAll('_prompt', ''); + + _examples.add( + DeckExample(name: name, prompt: prompt.trim(), result: result.trim()), + ); + } catch (e, stack) { + debugLog.error( + 'EXAMPLES', + 'Failed to load example pair for $promptPath: $e', + stack, + ); + continue; + } + } + + if (_examples.isEmpty && promptPaths.isNotEmpty) { + debugLog.error('EXAMPLES', 'No examples loaded despite finding prompts'); + } + + _loaded = true; + } + + /// Formats all examples as a string for inclusion in prompts. + /// + /// Format: + /// ``` + /// ## Examples + /// + /// **Input:** + /// {prompt content} + /// + /// **Output:** + /// ```json + /// {result content} + /// ``` + /// + /// ## Output + /// Generate a JSON response based on the user's input above. + /// ``` + String formatForPrompt() { + if (_examples.isEmpty) return ''; + + final buffer = StringBuffer(); + buffer.writeln('## Examples'); + buffer.writeln(); + buffer.writeln( + 'Below are examples of input prompts and their expected JSON outputs. ' + 'Study these carefully to understand the exact format and structure required.', + ); + buffer.writeln(); + + for (final example in _examples) { + buffer.writeln('**Input:**'); + buffer.writeln(example.prompt); + buffer.writeln(); + buffer.writeln('**Output:**'); + buffer.writeln('```json'); + buffer.writeln(example.result); + buffer.writeln('```'); + buffer.writeln(); + } + + // Add output section to guide the AI response + buffer.writeln('---'); + buffer.writeln(); + buffer.writeln('## Output'); + buffer.writeln(); + buffer.writeln( + 'Now generate a JSON response based on the user\'s input. ' + 'Follow the exact structure shown in the examples above.', + ); + + return buffer.toString(); + } +} + +/// A single example consisting of an input prompt and expected output. +class DeckExample { + const DeckExample({ + required this.name, + required this.prompt, + required this.result, + }); + + final String name; + final String prompt; + final String result; +} diff --git a/packages/genui/lib/src/ai/prompts/font_styles.dart b/packages/genui/lib/src/ai/prompts/font_styles.dart new file mode 100644 index 00000000..352c927c --- /dev/null +++ b/packages/genui/lib/src/ai/prompts/font_styles.dart @@ -0,0 +1,143 @@ +/// Predefined font styles for presentation generation. +/// +/// AI selects from enum values, widget loads full metadata. +/// Ensures only valid Google Fonts are used. +library; + +/// Headline fonts for presentation titles and headers. +/// +/// Enhanced enum where: +/// - Enum value name = ID used in schema (e.g., `montserrat`, `playfairDisplay`) +/// - `title`: Display name for UI +/// - `fontFamily`: Exact Google Fonts family name +/// - `description`: Short description for AI selection +enum HeadlineFont { + playfairDisplay( + title: 'Playfair Display', + fontFamily: 'Playfair Display', + description: 'Elegant serif - formal presentations', + ), + montserrat( + title: 'Montserrat', + fontFamily: 'Montserrat', + description: 'Modern geometric sans - professional', + ), + poppins( + title: 'Poppins', + fontFamily: 'Poppins', + description: 'Friendly rounded sans - approachable', + ), + oswald( + title: 'Oswald', + fontFamily: 'Oswald', + description: 'Bold condensed - impactful headers', + ), + lobster( + title: 'Lobster', + fontFamily: 'Lobster', + description: 'Playful script - creative/casual', + ); + + const HeadlineFont({ + required this.title, + required this.fontFamily, + required this.description, + }); + + /// Display name shown to users. + final String title; + + /// Exact Google Fonts family name for loading. + final String fontFamily; + + /// Short description for AI selection guidance. + final String description; + + /// ID is the enum value name (e.g., "montserrat", "playfairDisplay"). + String get id => name; + + /// Finds a font by ID (enum name), returns null if not found. + static HeadlineFont? fromId(String id) { + for (final font in values) { + if (font.name == id) return font; + } + return null; + } + + /// Schema description with all fonts and their descriptions. + static String get schemaDescription { + final options = values + .map((f) => '${f.name} (${f.description})') + .join(', '); + return 'Headline font. Choose from: $options.'; + } +} + +/// Body fonts for presentation content text. +/// +/// Enhanced enum where: +/// - Enum value name = ID used in schema (e.g., `inter`, `openSans`) +/// - `title`: Display name for UI +/// - `fontFamily`: Exact Google Fonts family name +/// - `description`: Short description for AI selection +enum BodyFont { + inter( + title: 'Inter', + fontFamily: 'Inter', + description: 'Clean modern sans - high readability', + ), + openSans( + title: 'Open Sans', + fontFamily: 'Open Sans', + description: 'Friendly humanist - versatile', + ), + lato( + title: 'Lato', + fontFamily: 'Lato', + description: 'Warm semi-rounded - professional', + ), + roboto( + title: 'Roboto', + fontFamily: 'Roboto', + description: 'Neutral mechanical - technical content', + ), + sourceSerif4( + title: 'Source Serif 4', + fontFamily: 'Source Serif 4', + description: 'Readable serif - long-form content', + ); + + const BodyFont({ + required this.title, + required this.fontFamily, + required this.description, + }); + + /// Display name shown to users. + final String title; + + /// Exact Google Fonts family name for loading. + final String fontFamily; + + /// Short description for AI selection guidance. + final String description; + + /// ID is the enum value name (e.g., "inter", "openSans"). + String get id => name; + + /// Finds a font by ID (enum name), returns null if not found. + static BodyFont? fromId(String id) { + for (final font in values) { + if (font.name == id) return font; + } + return null; + } + + /// Schema description with all fonts and their descriptions. + static String get schemaDescription { + final options = values + .map((f) => '${f.name} (${f.description})') + .join(', '); + return 'Body font. Choose from: $options.'; + } +} diff --git a/packages/genui/lib/src/ai/prompts/image_style_prompts.dart b/packages/genui/lib/src/ai/prompts/image_style_prompts.dart new file mode 100644 index 00000000..19d3af79 --- /dev/null +++ b/packages/genui/lib/src/ai/prompts/image_style_prompts.dart @@ -0,0 +1,131 @@ +/// Predefined image styles for presentation generation. +/// +/// AI selects from enum values, widget loads full metadata. +/// Each style shows the same subject in a different artistic treatment. +library; + +/// Available image styles for presentation backgrounds. +/// +/// Enhanced enum where: +/// - Enum value name = ID used in schema (e.g., `watercolor`, `flatDesign`) +/// - `title`: Display name for UI +/// - `description`: Short description for AI selection +/// - `treatment`: Full prompt treatment for image generation +enum ImageStyle { + watercolor( + title: 'Watercolor', + description: 'Soft, artistic, dreamy - organic flowing shapes', + treatment: + 'rendered in soft watercolor painting style with flowing ' + 'organic shapes, gentle color bleeding between hues, and dreamy ' + 'atmospheric quality. Muted pastel tones with subtle paper texture.', + ), + + minimalist( + title: 'Minimalist', + description: 'Clean, modern, professional - geometric simplicity', + treatment: + 'rendered in clean minimalist style with simple geometric ' + 'shapes, generous negative space, and limited color palette. ' + 'Modern and elegant with precise edges and balanced asymmetry.', + ), + + gradient( + title: 'Gradient', + description: 'Dynamic, contemporary, vibrant - smooth color transitions', + treatment: + 'rendered with smooth flowing gradients and rich color ' + 'transitions blending seamlessly. Vibrant yet harmonious palette ' + 'with subtle mesh effects creating depth and dimension.', + ), + + retro( + title: 'Retro', + description: 'Nostalgic, playful, vintage - 60s/70s print aesthetic', + treatment: + 'rendered in retro vintage illustration style with bold ' + 'outlines, limited color palette reminiscent of 1960s-70s print. ' + 'Halftone dots, warm muted colors, and slightly faded aesthetic.', + ), + + geometric( + title: 'Geometric', + description: 'Technical, structured, bold - angular shapes and grids', + treatment: + 'rendered as geometric abstract composition with clean ' + 'angular shapes, precise lines, and structured grid-based layout. ' + 'Bold primary colors with strong contrast and mathematical balance.', + ), + + flatDesign( + title: 'Flat Design', + description: 'Friendly, approachable, corporate - simple vector style', + treatment: + 'rendered in flat design illustration style with solid ' + 'colors, no gradients or shadows, clean vector-like shapes. ' + 'Simple, friendly, and approachable with bright cheerful palette.', + ); + + const ImageStyle({ + required this.title, + required this.description, + required this.treatment, + }); + + /// Display name shown to users (e.g., "Flat Design"). + final String title; + + /// Short description for AI selection guidance. + final String description; + + /// Full art style treatment for image generation prompt. + final String treatment; + + /// ID is the enum value name (e.g., "watercolor", "flatDesign"). + String get id => name; + + /// Builds a complete image generation prompt. + /// + /// Combines subject with style treatment: + /// ```dart + /// ImageStyle.watercolor.buildPrompt('runner crossing finish line'); + /// // → "Runner crossing finish line, rendered in soft watercolor..." + /// ``` + String buildPrompt(String subject) { + final capitalized = subject.isNotEmpty + ? '${subject[0].toUpperCase()}${subject.substring(1)}' + : subject; + return '$capitalized, $treatment'; + } + + /// Finds a style by ID (enum name), returns null if not found. + static ImageStyle? fromId(String id) { + for (final style in values) { + if (style.name == id) return style; + } + return null; + } + + /// Cached options string for schema descriptions. + static final _optionsDescription = values + .map((s) => '${s.name} (${s.description})') + .join(', '); + + /// Schema description with all styles and their descriptions. + /// + /// [count] specifies how many styles to select: + /// - `count: 1` (default): "Image style. Choose one from: ..." + /// - `count: 3`: "Select 3 styles from: ..." + /// + /// Throws [AssertionError] if count is less than 1 or greater than + /// the number of available styles. + static String schemaDescription({int count = 1}) { + assert( + count >= 1 && count <= values.length, + 'count must be between 1 and ${values.length}, got $count', + ); + return count == 1 + ? 'Image style. Choose one from: $_optionsDescription.' + : 'Select $count styles from: $_optionsDescription.'; + } +} diff --git a/packages/genui/lib/src/ai/prompts/prompt_registry.dart b/packages/genui/lib/src/ai/prompts/prompt_registry.dart new file mode 100644 index 00000000..dd955689 --- /dev/null +++ b/packages/genui/lib/src/ai/prompts/prompt_registry.dart @@ -0,0 +1,133 @@ +import 'package:dotprompt_dart/dotprompt_dart.dart'; +// ignore: implementation_imports, DotPromptPartialResolver not exported in public API +import 'package:dotprompt_dart/src/partial_resolver.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as path; +import '../../constants/paths.dart'; + +/// Loads and renders dotprompt templates from Flutter assets. +class PromptRegistry { + PromptRegistry._(); + + static final instance = PromptRegistry._(); + + final Map _prompts = {}; + final Map _partials = {}; + bool _loaded = false; + Future? _loading; + + bool get isLoaded => _loaded; + + /// Loads prompts from a map for testing purposes. + /// + /// This bypasses asset loading and directly sets the prompts. + void loadForTest({ + Map prompts = const {}, + Map partials = const {}, + }) { + assert(() { + return true; + }(), 'loadForTest should only be called in tests'); + _loading = null; + _prompts.clear(); + _partials.clear(); + _prompts.addAll(prompts); + _partials.addAll(partials); + _loaded = true; + } + + /// Resets the registry to unloaded state (for testing). + void reset() { + assert(() { + return true; + }(), 'reset should only be called in tests'); + _prompts.clear(); + _partials.clear(); + _loaded = false; + _loading = null; + } + + Future load() { + if (_loaded) { + return Future.value(); + } + if (_loading != null) { + return _loading!; + } + _loading = _loadInternal(); + return _loading!; + } + + Future _loadInternal() async { + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + // Sort paths for deterministic loading order + final promptPaths = + manifest + .listAssets() + .where( + (assetPath) => + assetPath.startsWith(Paths.promptsDir) && + assetPath.endsWith('.prompt'), + ) + .toList() + ..sort(); + + for (final assetPath in promptPaths) { + final content = await rootBundle.loadString(assetPath); + final name = _nameFromAssetPath(assetPath); + if (_isPartial(assetPath)) { + _partials[name] = content; + } else { + _prompts[name] = content; + } + } + + _loaded = true; + } + + String render( + String name, { + Map input = const {}, + List> messages = const [], + Map? context, + dynamic docs, + }) { + if (!_loaded) { + throw StateError('PromptRegistry not loaded.'); + } + + final content = _prompts[name]; + if (content == null || content.trim().isEmpty) { + throw StateError('Prompt not found: $name'); + } + + final prompt = DotPrompt( + content, + partialResolver: _AssetPartialResolver(_partials), + ); + return prompt.render( + input: input, + messages: messages, + context: context, + docs: docs, + ); + } + + static bool _isPartial(String assetPath) { + return assetPath.contains('/partials/'); + } + + static String _nameFromAssetPath(String assetPath) { + final base = path.basenameWithoutExtension(assetPath); + return base.startsWith('_') ? base.substring(1) : base; + } +} + +class _AssetPartialResolver implements DotPromptPartialResolver { + _AssetPartialResolver(this._partials); + + final Map _partials; + + @override + String? resolve(String name) => _partials[name]; +} diff --git a/packages/genui/lib/src/ai/schemas/deck_schemas.dart b/packages/genui/lib/src/ai/schemas/deck_schemas.dart new file mode 100644 index 00000000..070f629a --- /dev/null +++ b/packages/genui/lib/src/ai/schemas/deck_schemas.dart @@ -0,0 +1,272 @@ +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +import '../prompts/font_styles.dart'; + +part 'deck_schemas.g.dart'; + +/// Schema definitions for SuperDeck presentation generation. +/// +/// These schemas use the ACK fluent API for consistency with catalog schemas. +/// They are compatible with Google Generative AI via `.toJsonSchemaBuilder()`. + +// ============================================================================ +// STYLE SCHEMAS +// ============================================================================ + +/// Schema for color palette configuration. +/// +/// Defines the background, heading, and body colors for the presentation theme. +/// Colors must be provided as hex strings. The semantic roles are: +/// - background: Slide background color +/// - heading: Heading/title text color +/// - body: Body text color +@AckType(name: 'DeckColors') +final _deckColorsSchema = Ack.object({ + 'background': Ack.string().describe('Background hex color for slides'), + 'heading': Ack.string().describe('Hex color for heading text'), + 'body': Ack.string().describe('Hex color for body text'), +}).describe('Color palette for the presentation'); +final colorsSchema = _deckColorsSchema; + +/// Schema for typography configuration. +/// +/// Defines the font families used for headlines and body text. +/// Uses predefined font enums to ensure only valid Google Fonts are used. +@AckType(name: 'DeckFonts') +final _deckFontsSchema = Ack.object({ + 'headline': Ack.enumValues( + HeadlineFont.values, + ).describe(HeadlineFont.schemaDescription), + 'body': Ack.enumValues( + BodyFont.values, + ).describe(BodyFont.schemaDescription), +}).describe('Typography configuration'); +final fontsSchema = _deckFontsSchema; + +/// Schema for global style configuration. +/// +/// Combines color palette and typography settings with a style name. +@AckType(name: 'DeckStyle') +final styleSchema = Ack.object({ + 'name': Ack.string().describe('Style name identifier'), + 'colors': _deckColorsSchema, + 'fonts': _deckFontsSchema, +}).describe('Global style configuration for the deck'); + +// ============================================================================ +// SLIDE CONTENT SCHEMAS +// ============================================================================ + +enum DeckBlockType { block, widget } + +enum DeckAlignment { + topLeft, + topCenter, + topRight, + centerLeft, + center, + centerRight, + bottomLeft, + bottomCenter, + bottomRight, +} + +const _alignmentValues = [ + 'topLeft', + 'topCenter', + 'topRight', + 'centerLeft', + 'center', + 'centerRight', + 'bottomLeft', + 'bottomCenter', + 'bottomRight', +]; + +/// Schema for a content or widget block. +/// +/// Blocks can be either: +/// - Content blocks (type: "block"): Contain markdown content +/// - Widget blocks (type: "widget"): Reference named widgets +@AckType(name: 'SlideBlock') +final _slideBlockSchema = Ack.object({ + 'type': Ack.enumString(['block', 'widget']).describe( + 'Block type: "block" for markdown content, "widget" for named widget reference', + ), + 'content': Ack.string().optional().describe( + 'Markdown content (required for type "block")', + ), + 'name': Ack.string().optional().describe( + 'Widget name (required for type "widget")', + ), + 'flex': Ack.integer().optional().describe( + 'Flex weight for proportional sizing. Higher values take more space.', + ), + 'align': Ack.enumString( + _alignmentValues, + ).optional().describe('Content alignment'), + 'scrollable': Ack.boolean().optional().describe( + 'Whether this block is scrollable', + ), +}).describe('A content or widget block'); +final blockSchema = _slideBlockSchema; + +/// Schema for a section containing blocks. +/// +/// Sections represent horizontal rows in a slide, containing one or more +/// blocks laid out as columns. +@AckType(name: 'SlideSection') +final _slideSectionSchema = Ack.object({ + 'type': Ack.literal('section').describe('Section type discriminator'), + 'flex': Ack.integer().optional().describe( + 'Flex weight for proportional sizing. Higher values take more space.', + ), + 'align': Ack.enumString( + _alignmentValues, + ).optional().describe('Content alignment within section'), + 'scrollable': Ack.boolean().optional().describe( + 'Whether this section is scrollable', + ), + 'blocks': Ack.list( + _slideBlockSchema, + ).describe('Content blocks in this section'), +}).describe('A section containing blocks'); +final sectionSchema = _slideSectionSchema; + +// ============================================================================ +// SLIDE SCHEMAS +// ============================================================================ + +/// Schema for slide options. +/// +/// Contains metadata about the slide such as title and style reference. +@AckType(name: 'SlideOptions') +final _slideOptionsSchema = Ack.object({ + 'title': Ack.string().optional().describe( + 'Slide title displayed in navigation', + ), + 'style': Ack.string().optional().describe( + 'Style name reference matching a defined style', + ), +}).describe('Slide options'); +final slideOptionsSchema = _slideOptionsSchema; + +/// Schema for a single slide. +/// +/// Each slide contains a unique key, optional metadata, and a vertical stack +/// of sections. Images are handled separately via the outline phase. +@AckType(name: 'Slide') +final slideSchema = Ack.object({ + 'key': Ack.string().describe('Unique slide identifier using kebab-case'), + 'options': _slideOptionsSchema.optional(), + 'comments': Ack.list( + Ack.string().describe('A speaker note or talking point for this slide'), + ).optional().describe('Speaker notes'), + 'sections': Ack.list( + _slideSectionSchema, + ).describe('Horizontal sections in the slide'), +}).describe('A single slide'); + +/// Schema for slide creation payloads. +/// +/// Matches [slideSchema], but allows omitting `key` so callers can request +/// automatic key generation. +@AckType(name: 'CreateSlide') +final createSlideSchema = Ack.object({ + 'key': Ack.string().optional().describe( + 'Optional slide identifier. When missing, a key can be generated.', + ), + 'options': _slideOptionsSchema.optional(), + 'comments': Ack.list( + Ack.string().describe('A speaker note or talking point for this slide'), + ).optional().describe('Speaker notes'), + 'sections': Ack.list( + _slideSectionSchema, + ).describe('Horizontal sections in the slide'), +}).describe('A slide payload accepted by createSlide'); + +// ============================================================================ +// ROOT SCHEMA +// ============================================================================ + +/// Schema for the complete slide generation output. +/// +/// Root schema for SuperDeck presentation generation, containing the slides +/// array and global style configuration. +@AckType(name: 'SlideGeneration') +final _slideGenerationSchema = Ack.object({ + 'slides': Ack.list( + slideSchema, + ).describe('Array of slides in the presentation'), + 'style': styleSchema, +}).describe('A SuperDeck presentation with slides and style'); +final slideGenerationSchema = _slideGenerationSchema; + +// ============================================================================ +// PROMPT GUIDANCE +// ============================================================================ + +/// Prompt guidance for slide generation. +/// +/// Provides field-specific examples and behavioral context for the AI model. +/// This should be included in the prompt, not duplicated in schema descriptions. +/// +/// References field names from the schema to maintain a single source of truth. +String getSlideGenerationGuidance() { + return ''' +## Field Guidance + +### Slide Keys +Use descriptive kebab-case identifiers that reflect slide purpose: +- Opening slides: "slide-intro", "slide-welcome", "slide-title" +- Content slides: "slide-overview", "slide-features", "slide-benefits" +- Closing slides: "slide-summary", "slide-conclusion", "slide-next-steps" + +### Flex Values +Control proportional sizing with flex weights: +- Use 1:3 ratio for title + body (prevents content cropping) +- Use equal flex (1:1) for side-by-side comparisons +- Use weighted flex (2:3) when one column needs emphasis + +### Content Alignment +Position content based on its role: +- Titles and hero text: "center" +- Body content and bullets: "topLeft" +- Captions and attributions: "bottomRight" or "bottomCenter" +'''; +} + +// ============================================================================ +// AVAILABLE IMAGES CONTEXT +// ============================================================================ + +/// Builds the available images context for Phase 3 prompt injection. +/// +/// This creates a human-readable list of available images that the AI +/// can reference when generating the final deck structure. The AI will +/// use this to decide where to place images in slide content. +/// +/// Example output: +/// ``` +/// Available images for your slides: +/// - intro: .superdeck/assets/slide-intro-illustration.png +/// - slide-1: .superdeck/assets/slide-slide-1-illustration.png +/// +/// Include these paths in slide content as markdown images where appropriate. +/// ``` +String buildAvailableImagesContext(Map availableImages) { + if (availableImages.isEmpty) { + return 'No images were generated for this presentation.'; + } + + final buffer = StringBuffer('Available images for your slides:\n'); + for (final entry in availableImages.entries) { + buffer.writeln('- ${entry.key}: ${entry.value}'); + } + buffer.writeln( + '\nInclude these paths in slide content as markdown images ' + '(e.g., `![description](path)`) where they enhance the slide.', + ); + return buffer.toString(); +} diff --git a/packages/genui/lib/src/ai/schemas/deck_schemas.g.dart b/packages/genui/lib/src/ai/schemas/deck_schemas.g.dart new file mode 100644 index 00000000..0558363c --- /dev/null +++ b/packages/genui/lib/src/ai/schemas/deck_schemas.g.dart @@ -0,0 +1,377 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'deck_schemas.dart'; + +List _$ackListCast(Object? value) => (value as List).cast(); + +/// Extension type for DeckColors +extension type DeckColorsType(Map _data) + implements Map { + static DeckColorsType parse(Object? data) { + return _deckColorsSchema.parseAs( + data, + (validated) => DeckColorsType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _deckColorsSchema.safeParseAs( + data, + (validated) => DeckColorsType(validated as Map), + ); + } + + Map toJson() => _data; + + String get background => _data['background'] as String; + + String get heading => _data['heading'] as String; + + String get body => _data['body'] as String; + + DeckColorsType copyWith({String? background, String? heading, String? body}) { + return DeckColorsType.parse({ + 'background': background ?? this.background, + 'heading': heading ?? this.heading, + 'body': body ?? this.body, + }); + } +} + +/// Extension type for DeckFonts +extension type DeckFontsType(Map _data) + implements Map { + static DeckFontsType parse(Object? data) { + return _deckFontsSchema.parseAs( + data, + (validated) => DeckFontsType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _deckFontsSchema.safeParseAs( + data, + (validated) => DeckFontsType(validated as Map), + ); + } + + Map toJson() => _data; + + HeadlineFont get headline => _data['headline'] as HeadlineFont; + + BodyFont get body => _data['body'] as BodyFont; + + DeckFontsType copyWith({HeadlineFont? headline, BodyFont? body}) { + return DeckFontsType.parse({ + 'headline': headline ?? this.headline, + 'body': body ?? this.body, + }); + } +} + +/// Extension type for DeckStyle +extension type DeckStyleType(Map _data) + implements Map { + static DeckStyleType parse(Object? data) { + return styleSchema.parseAs( + data, + (validated) => DeckStyleType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return styleSchema.safeParseAs( + data, + (validated) => DeckStyleType(validated as Map), + ); + } + + Map toJson() => _data; + + String get name => _data['name'] as String; + + DeckColorsType get colors => + DeckColorsType(_data['colors'] as Map); + + DeckFontsType get fonts => + DeckFontsType(_data['fonts'] as Map); + + DeckStyleType copyWith({ + String? name, + Map? colors, + Map? fonts, + }) { + return DeckStyleType.parse({ + 'name': name ?? this.name, + 'colors': colors ?? this.colors, + 'fonts': fonts ?? this.fonts, + }); + } +} + +/// Extension type for SlideBlock +extension type SlideBlockType(Map _data) + implements Map { + static SlideBlockType parse(Object? data) { + return _slideBlockSchema.parseAs( + data, + (validated) => SlideBlockType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _slideBlockSchema.safeParseAs( + data, + (validated) => SlideBlockType(validated as Map), + ); + } + + Map toJson() => _data; + + String get type => _data['type'] as String; + + String? get content => _data['content'] as String?; + + String? get name => _data['name'] as String?; + + int? get flex => _data['flex'] as int?; + + String? get align => _data['align'] as String?; + + bool? get scrollable => _data['scrollable'] as bool?; + + SlideBlockType copyWith({ + String? type, + String? content, + String? name, + int? flex, + String? align, + bool? scrollable, + }) { + return SlideBlockType.parse({ + 'type': type ?? this.type, + 'content': content ?? this.content, + 'name': name ?? this.name, + 'flex': flex ?? this.flex, + 'align': align ?? this.align, + 'scrollable': scrollable ?? this.scrollable, + }); + } +} + +/// Extension type for SlideSection +extension type SlideSectionType(Map _data) + implements Map { + static SlideSectionType parse(Object? data) { + return _slideSectionSchema.parseAs( + data, + (validated) => SlideSectionType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _slideSectionSchema.safeParseAs( + data, + (validated) => SlideSectionType(validated as Map), + ); + } + + Map toJson() => _data; + + String get type => _data['type'] as String; + + int? get flex => _data['flex'] as int?; + + String? get align => _data['align'] as String?; + + bool? get scrollable => _data['scrollable'] as bool?; + + List get blocks => (_data['blocks'] as List) + .map((e) => SlideBlockType(e as Map)) + .toList(); + + SlideSectionType copyWith({ + String? type, + int? flex, + String? align, + bool? scrollable, + List? blocks, + }) { + return SlideSectionType.parse({ + 'type': type ?? this.type, + 'flex': flex ?? this.flex, + 'align': align ?? this.align, + 'scrollable': scrollable ?? this.scrollable, + 'blocks': blocks ?? this.blocks, + }); + } +} + +/// Extension type for SlideOptions +extension type SlideOptionsType(Map _data) + implements Map { + static SlideOptionsType parse(Object? data) { + return _slideOptionsSchema.parseAs( + data, + (validated) => SlideOptionsType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _slideOptionsSchema.safeParseAs( + data, + (validated) => SlideOptionsType(validated as Map), + ); + } + + Map toJson() => _data; + + String? get title => _data['title'] as String?; + + String? get style => _data['style'] as String?; + + SlideOptionsType copyWith({String? title, String? style}) { + return SlideOptionsType.parse({ + 'title': title ?? this.title, + 'style': style ?? this.style, + }); + } +} + +/// Extension type for Slide +extension type SlideType(Map _data) + implements Map { + static SlideType parse(Object? data) { + return slideSchema.parseAs( + data, + (validated) => SlideType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return slideSchema.safeParseAs( + data, + (validated) => SlideType(validated as Map), + ); + } + + Map toJson() => _data; + + String get key => _data['key'] as String; + + SlideOptionsType? get options => _data['options'] != null + ? SlideOptionsType(_data['options'] as Map) + : null; + + List? get comments => _data['comments'] != null + ? _$ackListCast(_data['comments']) + : null; + + List get sections => (_data['sections'] as List) + .map((e) => SlideSectionType(e as Map)) + .toList(); + + SlideType copyWith({ + String? key, + Map? options, + List? comments, + List? sections, + }) { + return SlideType.parse({ + 'key': key ?? this.key, + 'options': options ?? this.options, + 'comments': comments ?? this.comments, + 'sections': sections ?? this.sections, + }); + } +} + +/// Extension type for CreateSlide +extension type CreateSlideType(Map _data) + implements Map { + static CreateSlideType parse(Object? data) { + return createSlideSchema.parseAs( + data, + (validated) => CreateSlideType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return createSlideSchema.safeParseAs( + data, + (validated) => CreateSlideType(validated as Map), + ); + } + + Map toJson() => _data; + + String? get key => _data['key'] as String?; + + SlideOptionsType? get options => _data['options'] != null + ? SlideOptionsType(_data['options'] as Map) + : null; + + List? get comments => _data['comments'] != null + ? _$ackListCast(_data['comments']) + : null; + + List get sections => (_data['sections'] as List) + .map((e) => SlideSectionType(e as Map)) + .toList(); + + CreateSlideType copyWith({ + String? key, + Map? options, + List? comments, + List? sections, + }) { + return CreateSlideType.parse({ + 'key': key ?? this.key, + 'options': options ?? this.options, + 'comments': comments ?? this.comments, + 'sections': sections ?? this.sections, + }); + } +} + +/// Extension type for SlideGeneration +extension type SlideGenerationType(Map _data) + implements Map { + static SlideGenerationType parse(Object? data) { + return _slideGenerationSchema.parseAs( + data, + (validated) => SlideGenerationType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _slideGenerationSchema.safeParseAs( + data, + (validated) => SlideGenerationType(validated as Map), + ); + } + + Map toJson() => _data; + + List get slides => (_data['slides'] as List) + .map((e) => SlideType(e as Map)) + .toList(); + + DeckStyleType get style => + DeckStyleType(_data['style'] as Map); + + SlideGenerationType copyWith({ + List? slides, + Map? style, + }) { + return SlideGenerationType.parse({ + 'slides': slides ?? this.slides, + 'style': style ?? this.style, + }); + } +} diff --git a/packages/genui/lib/src/ai/schemas/genui_action_schema.dart b/packages/genui/lib/src/ai/schemas/genui_action_schema.dart new file mode 100644 index 00000000..08461b86 --- /dev/null +++ b/packages/genui/lib/src/ai/schemas/genui_action_schema.dart @@ -0,0 +1,42 @@ +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'genui_action_schema.g.dart'; + +@AckType(name: 'ActionContextValue') +final _actionContextValueSchema = Ack.object({ + 'path': Ack.string().optional().describe('Data model path binding'), + 'literalString': Ack.string().optional().describe('Literal string value'), + 'literalNumber': Ack.double().optional().describe('Literal number value'), + 'literalBoolean': Ack.boolean().optional().describe('Literal boolean value'), +}).describe('Context value - use path or one of the literal types'); +final actionContextValueSchema = _actionContextValueSchema; + +@AckType(name: 'ActionContextEntry') +final _actionContextEntrySchema = Ack.object({ + 'key': Ack.string().describe('Context key'), + 'value': _actionContextValueSchema, +}).describe('Context entry with key and value'); +final actionContextEntrySchema = _actionContextEntrySchema; + +/// Shared GenUI action schema for catalog components. +/// +/// This schema defines the structure for user actions dispatched by GenUI +/// components. It's defined with @AckType() for code generation support. +/// +/// Usage: +/// ```dart +/// // Reference in other schemas (same file): +/// 'action': actionSchema, +/// +/// // Parse action data: +/// final action = ActionType.parse(data.action); +/// final name = action.name; +/// ``` +@AckType(name: 'Action') +final actionSchema = Ack.object({ + 'name': Ack.string().describe('Action name to dispatch'), + 'context': Ack.list( + _actionContextEntrySchema, + ).optional().describe('List of context data to include with the action'), +}).describe('GenUI action with name and context binding'); diff --git a/packages/genui/lib/src/ai/schemas/genui_action_schema.g.dart b/packages/genui/lib/src/ai/schemas/genui_action_schema.g.dart new file mode 100644 index 00000000..bb4a5eac --- /dev/null +++ b/packages/genui/lib/src/ai/schemas/genui_action_schema.g.dart @@ -0,0 +1,117 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'genui_action_schema.dart'; + +/// Extension type for ActionContextValue +extension type ActionContextValueType(Map _data) + implements Map { + static ActionContextValueType parse(Object? data) { + return _actionContextValueSchema.parseAs( + data, + (validated) => ActionContextValueType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _actionContextValueSchema.safeParseAs( + data, + (validated) => ActionContextValueType(validated as Map), + ); + } + + Map toJson() => _data; + + String? get path => _data['path'] as String?; + + String? get literalString => _data['literalString'] as String?; + + double? get literalNumber => _data['literalNumber'] as double?; + + bool? get literalBoolean => _data['literalBoolean'] as bool?; + + ActionContextValueType copyWith({ + String? path, + String? literalString, + double? literalNumber, + bool? literalBoolean, + }) { + return ActionContextValueType.parse({ + 'path': path ?? this.path, + 'literalString': literalString ?? this.literalString, + 'literalNumber': literalNumber ?? this.literalNumber, + 'literalBoolean': literalBoolean ?? this.literalBoolean, + }); + } +} + +/// Extension type for ActionContextEntry +extension type ActionContextEntryType(Map _data) + implements Map { + static ActionContextEntryType parse(Object? data) { + return _actionContextEntrySchema.parseAs( + data, + (validated) => ActionContextEntryType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _actionContextEntrySchema.safeParseAs( + data, + (validated) => ActionContextEntryType(validated as Map), + ); + } + + Map toJson() => _data; + + String get key => _data['key'] as String; + + ActionContextValueType get value => + ActionContextValueType(_data['value'] as Map); + + ActionContextEntryType copyWith({String? key, Map? value}) { + return ActionContextEntryType.parse({ + 'key': key ?? this.key, + 'value': value ?? this.value, + }); + } +} + +/// Extension type for Action +extension type ActionType(Map _data) + implements Map { + static ActionType parse(Object? data) { + return actionSchema.parseAs( + data, + (validated) => ActionType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return actionSchema.safeParseAs( + data, + (validated) => ActionType(validated as Map), + ); + } + + Map toJson() => _data; + + String get name => _data['name'] as String; + + List? get context => _data['context'] != null + ? (_data['context'] as List) + .map((e) => ActionContextEntryType(e as Map)) + .toList() + : null; + + ActionType copyWith({String? name, List? context}) { + return ActionType.parse({ + 'name': name ?? this.name, + 'context': context ?? this.context, + }); + } +} diff --git a/packages/genui/lib/src/ai/schemas/outline_schema.dart b/packages/genui/lib/src/ai/schemas/outline_schema.dart new file mode 100644 index 00000000..289431a2 --- /dev/null +++ b/packages/genui/lib/src/ai/schemas/outline_schema.dart @@ -0,0 +1,82 @@ +import 'package:ack/ack.dart'; + +/// Schema definitions for presentation outline generation (Phase 1). +/// +/// The outline is a lightweight structure that captures: +/// - Slide topics and purposes +/// - Layout hints for variety +/// - Image requirements for slides that benefit from visuals +/// +/// This is generated first, before images, so the AI can plan +/// what images are needed without knowing the full deck structure. + +// ============================================================================ +// IMAGE REQUIREMENT SCHEMA +// ============================================================================ + +/// Schema for image requirement in the outline. +/// +/// Simple structure with just the subject description. +/// The visual style treatment is applied from the user's ImageStyle selection. +final imageRequirementSchema = Ack.object({ + 'subject': Ack.string().describe( + 'Descriptive subject for illustration that enhances the slide content ' + '(e.g., "team collaborating around whiteboard", "rocket launching into space", ' + '"colorful data visualization dashboard")', + ), +}).describe('Image needed for this slide'); + +// ============================================================================ +// SLIDE OUTLINE SCHEMA +// ============================================================================ + +/// Layout hint values for slide structure guidance. +const _layoutHintValues = [ + 'title', + 'content', + 'two-column', + 'three-column', + 'quote', + 'title-left', +]; + +/// Schema for a single slide in the outline. +/// +/// Contains the essential planning information: +/// - key: Unique identifier for referencing in later phases +/// - title: Working title (may be refined in final deck) +/// - purpose: What the slide will communicate +/// - layoutHint: Suggested layout type for variety +/// - imageRequirement: Optional image specification +final outlineSlideSchema = Ack.object({ + 'key': Ack.string().describe( + 'Unique identifier for this slide (e.g., "intro", "slide-1", "conclusion")', + ), + 'title': Ack.string().describe( + 'Working title for this slide (may be refined in final generation)', + ), + 'purpose': Ack.string().describe( + 'Brief description of what this slide will communicate (1-2 sentences)', + ), + 'layoutHint': Ack.enumString( + _layoutHintValues, + ).describe('Suggested layout type for this slide'), + 'imageRequirement': imageRequirementSchema.optional().describe( + 'Image needed for this slide, if visual would enhance the content', + ), +}).describe('A single slide in the presentation outline'); + +// ============================================================================ +// ROOT OUTLINE SCHEMA +// ============================================================================ + +/// Schema for the complete presentation outline. +/// +/// Root schema for Phase 1 generation, containing the topic +/// and ordered list of slide outlines. +final outlineSchema = Ack.object({ + 'topic': Ack.string().describe('Main topic of the presentation'), + 'slides': Ack.list( + outlineSlideSchema, + ).describe('Ordered list of slides in the presentation'), +}).describe('Presentation outline with structure and image requirements'); diff --git a/packages/genui/lib/src/ai/schemas/schemas.dart b/packages/genui/lib/src/ai/schemas/schemas.dart new file mode 100644 index 00000000..d0f44514 --- /dev/null +++ b/packages/genui/lib/src/ai/schemas/schemas.dart @@ -0,0 +1,10 @@ +/// Barrel file for AI data schemas. +/// +/// Provides centralized access to all schema definitions used for +/// structured output generation with Google Generative AI. +library; + +export 'deck_schemas.dart'; +export 'genui_action_schema.dart'; +export 'outline_schema.dart'; +export 'wizard_context_keys.dart'; diff --git a/packages/genui/lib/src/ai/schemas/wizard_context_keys.dart b/packages/genui/lib/src/ai/schemas/wizard_context_keys.dart new file mode 100644 index 00000000..3ad65713 --- /dev/null +++ b/packages/genui/lib/src/ai/schemas/wizard_context_keys.dart @@ -0,0 +1,109 @@ +/// Standardized context keys for the wizard workflow. +/// +/// All catalog components should use these keys when writing to the +/// wizard context. This ensures consistent extraction in SummaryCard +/// and generation context building. +/// +/// Usage: +/// ```dart +/// resolvedContext[WizardContextKeys.topic] = userTopic; +/// resolvedContext[WizardContextKeys.message] = displayMessage; +/// ``` +abstract final class WizardContextKeys { + // Core wizard step keys + + /// The presentation topic entered by the user. + static const topic = 'topic'; + + /// The target audience selection. + static const audience = 'audience'; + + /// The presentation approach/format selection. + static const approach = 'approach'; + + /// The emphasis areas (multi-select topics). + static const emphasis = 'emphasis'; + + /// The number of slides selected. + static const slideCount = 'slideCount'; + + /// The visual style name. + static const style = 'style'; + + // Style-related keys + + /// Color palette as list of hex strings. + static const colors = 'colors'; + + /// Headline/title font identifier. + static const headlineFont = 'headlineFont'; + + /// Body text font identifier. + static const bodyFont = 'bodyFont'; + + // Image style keys + + /// Image style identifier (enum name). + static const imageStyleId = 'imageStyleId'; + + /// Image style display name. + static const imageStyleName = 'imageStyleName'; + + /// Image style description for generation guidance. + static const imageStyleDescription = 'imageStyleDescription'; + + // Common keys used across components + + /// Display message shown in chat bubble after selection. + static const message = 'message'; + + /// Generic title field for selections. + static const title = 'title'; + + /// Generic description field for selections. + static const description = 'description'; + + /// Selected options list (used by checkbox components). + static const selectedOptions = 'selectedOptions'; + + /// Maps normalized label strings to their context keys. + /// + /// Used by SummaryCard to extract context from AI-provided labels. + /// Returns null for unrecognized labels. + static String? labelToKey(String label) { + final normalized = label.toLowerCase().trim(); + return _labelMap[normalized]; + } + + static const _labelMap = { + // Topic variations + 'topic': topic, + 'presentation topic': topic, + 'subject': topic, + // Audience variations + 'audience': audience, + 'target audience': audience, + // Approach variations + 'approach': approach, + 'presentation approach': approach, + 'format': approach, + // Emphasis variations + 'emphasis': emphasis, + 'emphases': emphasis, + 'key areas': emphasis, + 'focus areas': emphasis, + // Slide count variations + 'slide count': slideCount, + 'slides': slideCount, + 'number of slides': slideCount, + 'total slides': slideCount, + // Style variations + 'style': style, + 'visual style': style, + 'design style': style, + 'theme': style, + // Image style variations + 'image style': imageStyleName, + 'visual direction': imageStyleName, + }; +} diff --git a/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart b/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart new file mode 100644 index 00000000..8a58d8d7 --- /dev/null +++ b/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart @@ -0,0 +1,369 @@ +part of 'deck_generator_service.dart'; + +extension _DeckGeneratorPipeline on DeckGeneratorService { + // =========================================================================== + // PHASE 1: Generate Outline + // =========================================================================== + + /// Generates a lightweight presentation outline. + /// + /// Returns the outline JSON or null on failure. + Future?> _generateOutline( + google_ai.GenerativeService service, + String prompt, + ) async { + final adapter = GoogleSchemaAdapter(); + final adaptResult = adapter.adapt(outlineSchema.toJsonSchemaBuilder()); + + if (adaptResult.schema == null) { + debugLog.error( + 'DECK_GEN', + 'Failed to adapt outline schema: ${adaptResult.errors}', + ); + return null; + } + + final systemPrompt = PromptRegistry.instance.render('outline_system'); + debugLog.log( + 'DECK_GEN', + 'Outline system prompt (${systemPrompt.length} chars)', + ); + final request = google_ai.GenerateContentRequest( + model: outlineModelName, + contents: [ + google_ai.Content( + role: 'user', + parts: [google_ai.Part(text: prompt)], + ), + ], + generationConfig: google_ai.GenerationConfig( + responseMimeType: 'application/json', + responseSchema: adaptResult.schema, + ), + systemInstruction: google_ai.Content( + parts: [google_ai.Part(text: systemPrompt)], + ), + ); + + debugLog.log('DECK_GEN', 'Sending outline request to $outlineModelName...'); + final response = await retryPolicy.run( + () => service + .generateContent(request) + .timeout( + const Duration(minutes: 2), + onTimeout: () { + throw TimeoutException('Outline generation timed out'); + }, + ), + ); + debugLog.log( + 'DECK_GEN', + 'Outline response: ${response.candidates.length} candidates', + ); + + return _parseJsonResponse(response, 'outline'); + } + + // =========================================================================== + // PHASE 2: Generate Images + // =========================================================================== + + /// Extracts image requirements from the outline. + List<_ImageRequirement> _extractImageRequirements( + Map outline, + ) { + final requirements = <_ImageRequirement>[]; + final slides = outline['slides'] as List?; + if (slides == null) return requirements; + + for (final slide in slides) { + if (slide is! Map) continue; + + final key = slide['key']?.toString(); + if (key == null || key.isEmpty) continue; + + final imageReq = slide['imageRequirement'] as Map?; + if (imageReq == null) continue; + + final subject = imageReq['subject']?.toString(); + if (subject == null || subject.isEmpty) continue; + + requirements.add(_ImageRequirement(slideKey: key, subject: subject)); + } + + // Hard cap at 3 images per presentation + if (requirements.length > 3) { + return requirements.take(3).toList(); + } + + return requirements; + } + + /// Generates images from outline requirements in parallel batches. + Future<_ImageGenerationResults> _generateImagesFromRequirements( + List<_ImageRequirement> requirements, + String imageStyleId, { + String? backgroundColor, + void Function(int completed, int failed)? onProgress, + }) async { + final style = ImageStyle.fromId(imageStyleId); + debugLog.log( + 'IMG', + 'Image style: ${style?.name ?? 'none'} (id=$imageStyleId)', + ); + final successes = {}; + final failures = {}; + var completed = 0; + var failed = 0; + + final assetsDir = Directory(Paths.superdeckAssetsPath); + await assetsDir.create(recursive: true); + + // Portrait ratio for images displayed in their own column + final imageService = ImageGeneratorService( + apiKey: apiKey, + aspectRatio: '3:4', + ); + debugLog.log('IMG', 'ImageService: aspectRatio=3:4 (portrait)'); + + // Process in batches of 3 to avoid rate limits + const batchSize = 3; + for (var i = 0; i < requirements.length; i += batchSize) { + final batch = requirements.skip(i).take(batchSize).toList(); + debugLog.log( + 'IMG', + 'Processing batch ${i ~/ batchSize + 1}: ' + '${batch.map((r) => r.slideKey).join(', ')}', + ); + + await Future.wait( + batch.map((req) async { + final safeKey = _fileSafeKey(req.slideKey, requirements.indexOf(req)); + final filename = 'slide-$safeKey-illustration.png'; + final outputPath = p.join(Paths.superdeckAssetsPath, filename); + + try { + // Build prompt with style (if present) and always wrap with + // ImageGeneratorService.buildPrompt for presentation constraints + final basePrompt = style != null + ? style.buildPrompt(req.subject) + : req.subject; + final prompt = ImageGeneratorService.buildPrompt( + basePrompt, + backgroundColor: backgroundColor, + ); + debugLog.log( + 'IMG', + '[${req.slideKey}] Generating with prompt ' + '(${prompt.length} chars):\n$prompt', + ); + + final imgStart = DateTime.now(); + final result = await imageService.generateImage(prompt); + final imgMs = DateTime.now().difference(imgStart).inMilliseconds; + + if (result.success && result.bytes != null) { + final bytes = result.bytes as Uint8List; + await File(outputPath).writeAsBytes(bytes, flush: true); + successes[req.slideKey] = '.superdeck/assets/$filename'; + completed++; + debugLog.log( + 'IMG', + '[${req.slideKey}] OK in ${imgMs}ms - ' + '${bytes.length} bytes → $outputPath', + ); + } else { + failures[req.slideKey] = result.error ?? 'Unknown error'; + failed++; + debugLog.log( + 'IMG', + '[${req.slideKey}] FAILED in ${imgMs}ms: ${result.error}', + ); + } + } catch (e, stack) { + failures[req.slideKey] = e.toString(); + failed++; + debugLog.error( + 'IMG', + '[${req.slideKey}] EXCEPTION: ${e.runtimeType}', + stack, + ); + } + + onProgress?.call(completed, failed); + }), + ); + } + + imageService.dispose(); + return _ImageGenerationResults(successes: successes, failures: failures); + } + + // =========================================================================== + // PHASE 3: Generate Final Deck + // =========================================================================== + + /// Generates the final deck with available images context. + Future?> _generateFinalDeck( + google_ai.GenerativeService service, + String prompt, + Map outline, + Map availableImages, + ) async { + final adapter = GoogleSchemaAdapter(); + final adaptResult = adapter.adapt( + slideGenerationSchema.toJsonSchemaBuilder(), + ); + + if (adaptResult.schema == null) { + debugLog.error( + 'DECK_GEN', + 'Failed to adapt deck schema: ${adaptResult.errors}', + ); + return null; + } + + final systemPrompt = _buildFinalDeckPrompt(outline, availableImages); + debugLog.log( + 'DECK_GEN', + 'Final deck system prompt (${systemPrompt.length} chars)', + ); + debugLog.log( + 'DECK_GEN', + 'Final deck thinking budget: ${thinkingBudget > 0 ? thinkingBudget : 'disabled'}', + ); + + final request = google_ai.GenerateContentRequest( + model: modelName, + contents: [ + google_ai.Content( + role: 'user', + parts: [google_ai.Part(text: prompt)], + ), + ], + generationConfig: google_ai.GenerationConfig( + responseMimeType: 'application/json', + responseSchema: adaptResult.schema, + thinkingConfig: thinkingBudget > 0 + ? google_ai.ThinkingConfig(thinkingBudget: thinkingBudget) + : null, + ), + systemInstruction: google_ai.Content( + parts: [google_ai.Part(text: systemPrompt)], + ), + ); + + debugLog.log('DECK_GEN', 'Sending final deck request to $modelName...'); + final response = await retryPolicy.run( + () => service + .generateContent(request) + .timeout( + const Duration(minutes: 3), + onTimeout: () { + throw TimeoutException('Final deck generation timed out'); + }, + ), + ); + debugLog.log( + 'DECK_GEN', + 'Final deck response: ${response.candidates.length} candidates', + ); + + return _parseJsonResponse(response, 'deck'); + } + + /// Builds the system prompt for Phase 3 with outline and available images. + String _buildFinalDeckPrompt( + Map outline, + Map availableImages, + ) { + final basePrompt = PromptRegistry.instance.render( + 'deck_system', + input: { + 'examples': ExamplesLoader.instance.formatForPrompt(), + 'availableImages': buildAvailableImagesContext(availableImages), + }, + ); + final fieldGuidance = getSlideGenerationGuidance(); + final outlineContext = _formatOutlineForPrompt(outline); + + return ''' +$basePrompt + +$fieldGuidance + +## Presentation Outline (follow this structure) + +$outlineContext +'''; + } + + /// Formats the outline as human-readable context for the final deck prompt. + String _formatOutlineForPrompt(Map outline) { + final slides = outline['slides'] as List?; + if (slides == null || slides.isEmpty) { + return 'No outline available.'; + } + + final buffer = StringBuffer(); + buffer.writeln('Topic: ${outline['topic'] ?? 'Unknown'}'); + buffer.writeln(''); + + for (final slide in slides) { + if (slide is! Map) continue; + buffer.writeln('- **${slide['key']}**: ${slide['title']}'); + buffer.writeln(' Layout: ${slide['layoutHint']}'); + buffer.writeln(' Purpose: ${slide['purpose']}'); + if (slide['imageRequirement'] != null) { + final subject = (slide['imageRequirement'] as Map)['subject']; + buffer.writeln(' Image: $subject'); + } + buffer.writeln(''); + } + + return buffer.toString(); + } + + // =========================================================================== + // HELPERS + // =========================================================================== + + /// Parses JSON from a Gemini response. + Map? _parseJsonResponse( + google_ai.GenerateContentResponse response, + String context, + ) { + if (response.candidates.isEmpty) { + debugLog.error('DECK_GEN', 'No candidates in $context response'); + return null; + } + + final candidate = response.candidates.first; + final textParts = + candidate.content?.parts + .where((p) => p.text != null) + .map((p) => p.text!) + .toList() ?? + []; + + if (textParts.isEmpty) { + debugLog.error('DECK_GEN', 'No text content in $context response'); + return null; + } + + final jsonText = textParts.join(''); + + try { + return jsonDecode(jsonText) as Map; + } catch (e) { + debugLog.error('DECK_GEN', 'JSON parse failed for $context: $e'); + return null; + } + } + + /// Ensures required directories exist. + Future _ensureDirectoriesExist() async { + await Directory(Paths.superdeckDir).create(recursive: true); + await Directory(Paths.superdeckAssetsPath).create(recursive: true); + } +} diff --git a/packages/genui/lib/src/ai/services/deck_generator_pipeline_helpers.dart b/packages/genui/lib/src/ai/services/deck_generator_pipeline_helpers.dart new file mode 100644 index 00000000..17cb707e --- /dev/null +++ b/packages/genui/lib/src/ai/services/deck_generator_pipeline_helpers.dart @@ -0,0 +1,173 @@ +part of 'deck_generator_service.dart'; + +/// Sanitizes a slide key for safe filesystem use. +/// +/// Removes or replaces characters that are invalid in filenames across +/// common filesystems (Windows, macOS, Linux). +String _fileSafeKey(String key, int index) { + final cleaned = key + .trim() + .replaceAll(RegExp(r'[\\/:*?"<>|\x00]'), '-') // Invalid on Windows + .replaceAll(RegExp(r'\s+'), '-') // Spaces to dashes + .replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '-') // Keep only safe chars + .replaceAll(RegExp(r'-{2,}'), '-') // Collapse multiple dashes + .replaceAll(RegExp(r'^[-.]+|[-.]+$'), ''); // Trim leading/trailing + return cleaned.isEmpty ? 'slide-$index' : cleaned; +} + +/// Removes stale asset files that don't match current slide keys. +Future _cleanupStaleAssets(List> slides) async { + final assetsDir = Directory(Paths.superdeckAssetsPath); + if (!await assetsDir.exists()) return; + + // Build set of valid filenames using sanitized keys + final validThumbnails = {}; + final validIllustrations = {}; + + for (var i = 0; i < slides.length; i++) { + final key = slides[i]['key']?.toString(); + if (key == null) continue; + final safeKey = _fileSafeKey(key, i); + validThumbnails.add('thumbnail_$safeKey.png'); + validIllustrations.add('slide-$safeKey-illustration.png'); + } + + try { + await for (final entity in assetsDir.list()) { + if (entity is! File) continue; + + final filename = p.basename(entity.path); + if (filename == '.gitkeep') continue; + + var shouldDelete = false; + + if (filename.startsWith('thumbnail_')) { + shouldDelete = !validThumbnails.contains(filename); + } else if (filename.startsWith('slide-') && + filename.endsWith('-illustration.png')) { + shouldDelete = !validIllustrations.contains(filename); + } else if (filename.startsWith('slide-') && + filename.endsWith('-bg.png')) { + shouldDelete = true; + } + + if (shouldDelete) { + try { + await entity.delete(); + } catch (e) { + debugLog.log('CLEANUP', 'Could not delete $filename: $e'); + } + } + } + } catch (e) { + debugLog.log('CLEANUP', 'Could not list assets directory: $e'); + } +} + +/// Image requirement extracted from outline. +class _ImageRequirement { + const _ImageRequirement({required this.slideKey, required this.subject}); + + final String slideKey; + final String subject; +} + +/// Results from parallel image generation. +class _ImageGenerationResults { + const _ImageGenerationResults({ + required this.successes, + required this.failures, + }); + + final Map successes; + final Map failures; +} + +List> _sanitizeSlides(List> slides) { + return slides.map(_sanitizeSlide).nonNulls.toList(); +} + +Map? _sanitizeSlide(Map slide) { + final sections = >[]; + final rawSections = slide['sections']; + if (rawSections is List) { + for (final rawSection in rawSections) { + final cleaned = _sanitizeSection(rawSection); + if (cleaned != null) { + sections.add(cleaned); + } + } + } + + if (sections.isEmpty) { + return null; + } + + slide['sections'] = sections; + return slide; +} + +Map? _sanitizeSection(dynamic rawSection) { + if (rawSection is! Map) { + return null; + } + + final section = Map.from(rawSection); + section['type'] = 'section'; + final rawBlocks = rawSection['blocks']; + final blocks = >[]; + + if (rawBlocks is List) { + for (final rawBlock in rawBlocks) { + final cleaned = _sanitizeBlock(rawBlock); + if (cleaned != null) { + blocks.add(cleaned); + } + } + } + + if (blocks.isEmpty) { + return null; + } + + section['blocks'] = blocks; + return section; +} + +Map? _sanitizeBlock(dynamic rawBlock) { + if (rawBlock is! Map) { + return null; + } + + final block = Map.from(rawBlock); + final rawType = block['type']?.toString().trim() ?? ''; + final type = rawType.isEmpty ? 'block' : rawType; + + if (type == 'block') { + final content = block['content']?.toString().trim() ?? ''; + if (content.isEmpty) { + return null; + } + block['type'] = 'block'; + block.remove('name'); + return block; + } + + if (type == 'widget') { + final name = block['name']?.toString().trim() ?? ''; + if (name.isEmpty) { + return null; + } + block['type'] = 'widget'; + block.remove('content'); + return block; + } + + final content = block['content']?.toString().trim() ?? ''; + if (content.isEmpty) { + return null; + } + block['type'] = 'block'; + block.remove('name'); + return block; +} diff --git a/packages/genui/lib/src/ai/services/deck_generator_service.dart b/packages/genui/lib/src/ai/services/deck_generator_service.dart new file mode 100644 index 00000000..a1ac2894 --- /dev/null +++ b/packages/genui/lib/src/ai/services/deck_generator_service.dart @@ -0,0 +1,256 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:genui_google_generative_ai/genui_google_generative_ai.dart'; +import 'package:google_cloud_ai_generativelanguage_v1beta/generativelanguage.dart' + as google_ai; +import 'package:path/path.dart' as p; +import '../prompts/examples_loader.dart'; +import '../prompts/image_style_prompts.dart'; +import '../prompts/prompt_registry.dart'; +import '../schemas/deck_schemas.dart'; +import '../schemas/outline_schema.dart'; +import './error_classifier.dart'; +import './generation_progress.dart'; +import './image_generator_service.dart'; +import './retry_policy.dart'; +import './slide_key_utils.dart'; +import './style_json_serializer.dart'; +import '../../constants/gemini_models.dart'; +import '../../constants/paths.dart'; +import '../../debug_logger.dart'; + +part 'deck_generator_pipeline.dart'; +part 'deck_generator_pipeline_helpers.dart'; +part 'deck_generator_workflow.dart'; + +/// Result of deck generation. +class DeckGenerationResult { + const DeckGenerationResult._({ + required this.success, + this.message, + this.path, + this.error, + this.slideCount, + this.style, + this.imageFailures, + }); + + DeckGenerationResult.success({ + required String path, + required int slideCount, + DeckStyleType? style, + Map? imageFailures, + }) : this._( + success: true, + message: + 'Successfully generated presentation with $slideCount slides.', + path: path, + slideCount: slideCount, + style: style, + imageFailures: imageFailures, + ); + + DeckGenerationResult.failure(String error) + : this._(success: false, error: error); + + final bool success; + final String? message; + final String? path; + final String? error; + final int? slideCount; + + /// Style configuration extracted from the generated deck. + final DeckStyleType? style; + + /// Images that failed to generate, keyed by slide key. + /// Null if no images were requested or all succeeded. + final Map? imageFailures; + + /// Whether all requested images were generated successfully. + bool get allImagesGenerated => + imageFailures == null || imageFailures!.isEmpty; +} + +class _ImagePhaseData { + const _ImagePhaseData({ + required this.availableImages, + required this.imageFailures, + }); + + final Map availableImages; + final Map? imageFailures; +} + +class _StyleData { + const _StyleData({required this.style, required this.styleJson}); + + final DeckStyleType? style; + final Map? styleJson; +} + +/// Service that generates SuperDeck presentations using Google Generative AI. +/// +/// Uses a 3-phase pipeline: +/// 1. Generate outline (structure + image requirements) +/// 2. Generate images based on outline +/// 3. Generate final deck with available images context +class DeckGeneratorService { + DeckGeneratorService({ + required this.apiKey, + this.modelName = GeminiModelNames.gemini25Pro, + this.outlineModelName = GeminiModelNames.gemini3FlashPreview, + this.thinkingBudget = 3072, + RetryPolicy? retryPolicy, + }) : retryPolicy = retryPolicy ?? RetryPolicy(); + + final String apiKey; + + /// Model used for the final deck generation (Phase 3). + final String modelName; + + /// Model used for the outline generation (Phase 1). + final String outlineModelName; + + /// Token budget for thinking. Set to 0 to disable thinking. + final int thinkingBudget; + + /// Retry policy for transient generation failures. + final RetryPolicy retryPolicy; + + /// Generates a presentation deck from a natural language prompt. + /// + /// The [prompt] should describe the presentation requirements including: + /// - Topic and content + /// - Target audience + /// - Presentation approach/style + /// - Number of slides + /// - Visual style preferences + /// + /// Uses a 3-phase pipeline: + /// 1. Generate outline with image requirements + /// 2. Generate images for slides that need them + /// 3. Generate final deck with available images context + /// + /// Progress updates are reported via [onProgress] if provided. + Future generate( + String prompt, { + String? imageStyleId, + String? backgroundColor, + GenerationProgressCallback? onProgress, + }) async { + _logPipelineConfig( + this, + prompt: prompt, + imageStyleId: imageStyleId, + backgroundColor: backgroundColor, + ); + final pipelineStart = DateTime.now(); + final service = google_ai.GenerativeService.fromApiKey(apiKey); + + try { + final outline = await _runOutlinePhase( + this, + service: service, + prompt: prompt, + onProgress: onProgress, + ); + if (outline == null) { + return DeckGenerationResult.failure( + 'Failed to generate presentation outline. Please try again.', + ); + } + + final imagePhase = await _runImagePhase( + this, + outline: outline, + imageStyleId: imageStyleId, + backgroundColor: backgroundColor, + onProgress: onProgress, + ); + + final deckJson = await _runFinalDeckPhase( + this, + service: service, + prompt: prompt, + outline: outline, + availableImages: imagePhase.availableImages, + onProgress: onProgress, + ); + + if (deckJson == null) { + return DeckGenerationResult.failure( + 'Failed to generate final presentation. Please try again.', + ); + } + + return _finalizeDeck( + this, + deckJson: deckJson, + availableImages: imagePhase.availableImages, + imageFailures: imagePhase.imageFailures, + pipelineStart: pipelineStart, + onProgress: onProgress, + ); + } catch (e, stack) { + final totalMs = DateTime.now().difference(pipelineStart).inMilliseconds; + debugLog.error( + 'DECK_GEN', + 'Pipeline FAILED after ${totalMs}ms: $e', + stack, + ); + final userMessage = const ErrorClassifier().getUserMessage(e); + return DeckGenerationResult.failure(userMessage); + } finally { + service.close(); + } + } + + /// Regenerates from the last saved generation metadata. + /// + /// Reads prompt and parameters (imageStyleId, backgroundColor) from + /// the metadata JSON saved during the previous generation. + /// Falls back to plain prompt file if metadata is unavailable. + Future regenerateFromLastPrompt({ + GenerationProgressCallback? onProgress, + }) async { + // Try metadata JSON first (has full parameters) + final metadataFile = File(Paths.lastGenerationPath); + if (await metadataFile.exists()) { + try { + final json = + jsonDecode(await metadataFile.readAsString()) + as Map; + final prompt = json['prompt'] as String?; + if (prompt != null && prompt.trim().isNotEmpty) { + return generate( + prompt, + imageStyleId: json['imageStyleId'] as String?, + backgroundColor: json['backgroundColor'] as String?, + onProgress: onProgress, + ); + } + } catch (e) { + debugLog.log('DECK_GEN', 'Failed to read metadata, falling back: $e'); + } + } + + // Fallback to plain prompt file + final promptFile = File(Paths.lastPromptPath); + if (!await promptFile.exists()) { + return DeckGenerationResult.failure( + 'No previous prompt found. Complete the wizard at least once.', + ); + } + + final prompt = await promptFile.readAsString(); + if (prompt.trim().isEmpty) { + return DeckGenerationResult.failure('Previous prompt file is empty.'); + } + + return generate(prompt, onProgress: onProgress); + } +} diff --git a/packages/genui/lib/src/ai/services/deck_generator_workflow.dart b/packages/genui/lib/src/ai/services/deck_generator_workflow.dart new file mode 100644 index 00000000..fe329954 --- /dev/null +++ b/packages/genui/lib/src/ai/services/deck_generator_workflow.dart @@ -0,0 +1,249 @@ +part of 'deck_generator_service.dart'; + +void _logPipelineConfig( + DeckGeneratorService owner, { + required String prompt, + required String? imageStyleId, + required String? backgroundColor, +}) { + debugLog.section('Deck Generation Pipeline'); + debugLog.log( + 'DECK_GEN', + 'Config: outlineModel=${owner.outlineModelName}, ' + 'deckModel=${owner.modelName}, ' + 'thinkingBudget=${owner.thinkingBudget}, ' + 'imageStyle=$imageStyleId, bgColor=$backgroundColor', + ); + debugLog.log('DECK_GEN', 'Prompt (${prompt.length} chars):\n$prompt'); +} + +Future?> _runOutlinePhase( + DeckGeneratorService owner, { + required google_ai.GenerativeService service, + required String prompt, + required GenerationProgressCallback? onProgress, +}) async { + debugLog.section('Phase 1: Generate Outline'); + onProgress?.call(GenerationPhase.generatingOutline, null); + final outlineStart = DateTime.now(); + final outline = await owner._generateOutline(service, prompt); + final outlineMs = DateTime.now().difference(outlineStart).inMilliseconds; + + if (outline == null) { + debugLog.error( + 'DECK_GEN', + 'Phase 1 FAILED after ${outlineMs}ms - no outline returned', + ); + return null; + } + + final outlineSlides = (outline['slides'] as List?)?.length ?? 0; + debugLog.log( + 'DECK_GEN', + 'Phase 1 COMPLETE in ${outlineMs}ms - ' + 'topic: "${outline['topic']}", slides: $outlineSlides', + ); + return outline; +} + +Future<_ImagePhaseData> _runImagePhase( + DeckGeneratorService owner, { + required Map outline, + required String? imageStyleId, + required String? backgroundColor, + required GenerationProgressCallback? onProgress, +}) async { + debugLog.section('Phase 2: Generate Images'); + final imageRequirements = owner._extractImageRequirements(outline); + debugLog.log( + 'DECK_GEN', + 'Extracted ${imageRequirements.length} image requirements: ' + '${imageRequirements.map((r) => '${r.slideKey} → "${r.subject}"').join(', ')}', + ); + + final availableImages = {}; + Map? imageFailures; + + if (imageRequirements.isNotEmpty && imageStyleId != null) { + debugLog.log( + 'DECK_GEN', + 'Starting image generation: style=$imageStyleId, ' + 'aspectRatio=3:4 (portrait), bgColor=$backgroundColor', + ); + onProgress?.call( + GenerationPhase.generatingImages, + ImageGenerationProgress(completed: 0, total: imageRequirements.length), + ); + + final imageStart = DateTime.now(); + final results = await owner._generateImagesFromRequirements( + imageRequirements, + imageStyleId, + backgroundColor: backgroundColor, + onProgress: (completed, failed) => onProgress?.call( + GenerationPhase.generatingImages, + ImageGenerationProgress( + completed: completed, + total: imageRequirements.length, + failed: failed, + ), + ), + ); + final imageMs = DateTime.now().difference(imageStart).inMilliseconds; + + availableImages.addAll(results.successes); + if (results.failures.isNotEmpty) { + imageFailures = results.failures; + } + debugLog.log( + 'DECK_GEN', + 'Phase 2 COMPLETE in ${imageMs}ms - ' + '${results.successes.length} succeeded, ' + '${results.failures.length} failed', + ); + for (final entry in results.successes.entries) { + debugLog.log('DECK_GEN', ' OK: ${entry.key} → ${entry.value}'); + } + for (final entry in results.failures.entries) { + debugLog.log('DECK_GEN', ' FAIL: ${entry.key} → ${entry.value}'); + } + } else { + debugLog.log( + 'DECK_GEN', + 'Skipping image generation: ' + 'requirements=${imageRequirements.length}, ' + 'imageStyleId=$imageStyleId', + ); + } + + return _ImagePhaseData( + availableImages: availableImages, + imageFailures: imageFailures, + ); +} + +Future?> _runFinalDeckPhase( + DeckGeneratorService owner, { + required google_ai.GenerativeService service, + required String prompt, + required Map outline, + required Map availableImages, + required GenerationProgressCallback? onProgress, +}) async { + debugLog.section('Phase 3: Generate Final Deck'); + debugLog.log('DECK_GEN', 'Available images for final deck: $availableImages'); + onProgress?.call(GenerationPhase.generatingFinalDeck, null); + final deckStart = DateTime.now(); + final deckJson = await owner._generateFinalDeck( + service, + prompt, + outline, + availableImages, + ); + final deckMs = DateTime.now().difference(deckStart).inMilliseconds; + + if (deckJson == null) { + debugLog.error( + 'DECK_GEN', + 'Phase 3 FAILED after ${deckMs}ms - no deck JSON returned', + ); + return null; + } + + final deckSlides = (deckJson['slides'] as List?)?.length ?? 0; + debugLog.log( + 'DECK_GEN', + 'Phase 3 COMPLETE in ${deckMs}ms - $deckSlides raw slides', + ); + return deckJson; +} + +Future _finalizeDeck( + DeckGeneratorService owner, { + required Map deckJson, + required Map availableImages, + required Map? imageFailures, + required DateTime pipelineStart, + required GenerationProgressCallback? onProgress, +}) async { + debugLog.section('Finalize'); + onProgress?.call(GenerationPhase.finalizing, null); + + final slides = _extractSlidesWithKeys(deckJson); + + debugLog.log('DECK_GEN', 'Pre-sanitize: ${slides.length} slides'); + final sanitizedSlides = _sanitizeSlides(slides); + debugLog.log( + 'DECK_GEN', + 'Post-sanitize: ${sanitizedSlides.length} slides ' + '(${slides.length - sanitizedSlides.length} removed)', + ); + + if (sanitizedSlides.isEmpty) { + debugLog.error('DECK_GEN', 'No slides survived sanitization'); + return DeckGenerationResult.failure('No slides generated'); + } + + final styleData = _extractStyleData(deckJson); + final deck = { + 'slides': sanitizedSlides, + if (styleData.styleJson case final styleJson?) 'style': styleJson, + }; + + await owner._ensureDirectoriesExist(); + + final file = File(Paths.deckJsonPath); + final jsonString = const JsonEncoder.withIndent(' ').convert(deck); + await file.writeAsString(jsonString); + debugLog.log( + 'DECK_GEN', + 'Wrote deck to ${file.path} (${jsonString.length} chars)', + ); + + await _cleanupStaleAssets(sanitizedSlides); + + final totalMs = DateTime.now().difference(pipelineStart).inMilliseconds; + debugLog.log( + 'DECK_GEN', + 'Pipeline COMPLETE in ${totalMs}ms - ' + '${sanitizedSlides.length} slides, ' + '${availableImages.length} images', + ); + + return DeckGenerationResult.success( + path: file.absolute.path, + slideCount: sanitizedSlides.length, + style: styleData.style, + imageFailures: imageFailures, + ); +} + +List> _extractSlidesWithKeys( + Map deckJson, +) { + return (deckJson['slides'] as List?)?.asMap().entries.map((entry) { + final index = entry.key; + final slide = Map.from(entry.value as Map); + final existingKey = slide['key']?.toString(); + if (existingKey == null || existingKey.isEmpty) { + slide['key'] = generateSlideKey(slide, index); + debugLog.log( + 'DECK_GEN', + 'Generated key for slide $index: ${slide['key']}', + ); + } + return slide; + }).toList() ?? + []; +} + +_StyleData _extractStyleData(Map deckJson) { + final rawStyle = deckJson['style']; + final style = DeckStyleType.safeParse(rawStyle).getOrNull(); + final styleJson = style != null ? serializeDeckStyleForJson(style) : null; + debugLog.log( + 'DECK_GEN', + 'Style: ${style != null ? 'parsed OK' : 'invalid/omitted'} → $styleJson', + ); + return _StyleData(style: style, styleJson: styleJson); +} diff --git a/packages/genui/lib/src/ai/services/error_classifier.dart b/packages/genui/lib/src/ai/services/error_classifier.dart new file mode 100644 index 00000000..5be6c7e4 --- /dev/null +++ b/packages/genui/lib/src/ai/services/error_classifier.dart @@ -0,0 +1,106 @@ +/// Classified error categories for user-facing messages. +enum ErrorCategory { + /// Rate limiting, quota exhaustion, or service overload. + rateLimit('The model is overloaded. Please wait a moment and try again.'), + + /// Invalid or expired API credentials. + authentication( + 'API key is invalid or expired. Please check your configuration.', + ), + + /// Network connectivity issues. + network('Connection issue. Please check your internet and try again.'), + + /// Content blocked by safety filters. + safetyFilter( + 'The request was blocked by safety filters. Please try rephrasing.', + ), + + /// Unclassified or unknown error. + unknown('Sorry, something went wrong. Please try again.'); + + const ErrorCategory(this.userMessage); + + /// User-friendly message for this error category. + final String userMessage; +} + +/// Classifies errors into user-friendly categories. +/// +/// Examines error messages and HTTP status codes to determine +/// the appropriate [ErrorCategory] for display to users. +/// +/// ## Usage +/// ```dart +/// final classifier = ErrorClassifier(); +/// final category = classifier.classify(error); +/// showMessage(category.userMessage); +/// ``` +/// +/// ## Pattern Matching +/// Use Dart's exhaustive switch for handling all categories: +/// ```dart +/// switch (classifier.classify(error)) { +/// case ErrorCategory.rateLimit: handleRateLimit(); +/// case ErrorCategory.authentication: handleAuth(); +/// case ErrorCategory.network: handleNetwork(); +/// case ErrorCategory.safetyFilter: handleSafety(); +/// case ErrorCategory.unknown: handleUnknown(); +/// } +/// ``` +class ErrorClassifier { + const ErrorClassifier(); + + /// Pattern definitions for each error category. + /// + /// Listed in priority order - first match wins. + /// Categories are checked in the order defined in this map. + static const _patterns = >{ + ErrorCategory.rateLimit: [ + 'quota', + 'rate limit', + '429', + 'resource_exhausted', + 'overloaded', + ], + ErrorCategory.authentication: [ + '401', + '403', + 'unauthorized', + 'forbidden', + 'invalid api key', + 'api key not valid', + ], + ErrorCategory.network: [ + 'socketexception', + 'connection', + 'network', + 'timeout', + 'failed host lookup', + ], + ErrorCategory.safetyFilter: ['blocked', 'safety', 'harmful'], + }; + + /// Classifies an error into a user-friendly category. + /// + /// Examines the error's string representation (case-insensitive) + /// for known patterns. Returns [ErrorCategory.unknown] if no patterns match. + ErrorCategory classify(Object error) { + final errorString = error.toString().toLowerCase(); + + for (final entry in _patterns.entries) { + for (final pattern in entry.value) { + if (errorString.contains(pattern)) { + return entry.key; + } + } + } + + return ErrorCategory.unknown; + } + + /// Returns the user-friendly message for the given error. + /// + /// Equivalent to `classify(error).userMessage`. + String getUserMessage(Object error) => classify(error).userMessage; +} diff --git a/packages/genui/lib/src/ai/services/generation_progress.dart b/packages/genui/lib/src/ai/services/generation_progress.dart new file mode 100644 index 00000000..2944349e --- /dev/null +++ b/packages/genui/lib/src/ai/services/generation_progress.dart @@ -0,0 +1,54 @@ +/// Phases of deck generation for progress tracking. +enum GenerationPhase { + /// No generation in progress. + idle, + + /// Phase 1: Generating presentation outline (structure + image requirements). + generatingOutline, + + /// Phase 2: Generating illustrations for slides with image requirements. + generatingImages, + + /// Phase 3: Generating final deck with available images context. + generatingFinalDeck, + + /// Writing final JSON and cleaning up assets. + finalizing, + + /// Generating thumbnail previews from the finalized deck. + generatingThumbnails, +} + +/// Progress information for image generation phase. +class ImageGenerationProgress { + /// Number of images successfully generated. + final int completed; + + /// Total number of images to generate. + final int total; + + /// Number of images that failed to generate. + final int failed; + + const ImageGenerationProgress({ + required this.completed, + required this.total, + this.failed = 0, + }); + + /// Remaining images to process. + int get remaining => total - completed - failed; + + /// Whether all images have been processed (success or failure). + bool get isComplete => completed + failed >= total; + + /// Progress as a fraction (0.0 to 1.0). + double get fraction => total > 0 ? (completed + failed) / total : 0.0; +} + +/// Callback for generation progress updates. +typedef GenerationProgressCallback = + void Function( + GenerationPhase phase, + ImageGenerationProgress? imageProgress, + ); diff --git a/packages/genui/lib/src/ai/services/image_generator_service.dart b/packages/genui/lib/src/ai/services/image_generator_service.dart new file mode 100644 index 00000000..505cb41f --- /dev/null +++ b/packages/genui/lib/src/ai/services/image_generator_service.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:google_cloud_ai_generativelanguage_v1beta/generativelanguage.dart' + as google_ai; +import '../prompts/prompt_registry.dart'; +import './retry_policy.dart'; +import '../../constants/gemini_models.dart'; +import '../../debug_logger.dart'; + +/// Result of image generation. +class ImageGenerationResult { + const ImageGenerationResult._({ + required this.success, + this.bytes, + this.error, + }); + + /// Creates a successful result with image bytes. + ImageGenerationResult.success(Uint8List bytes) + : this._(success: true, bytes: bytes); + + /// Creates a failure result with an error message. + ImageGenerationResult.failure(String error) + : this._(success: false, error: error); + + /// Whether the image generation succeeded. + final bool success; + + /// The generated image bytes (only present on success). + final Uint8List? bytes; + + /// Error message (only present on failure). + final String? error; +} + +/// Generates slide-safe background images via Gemini image models. +/// +/// Converts provider/model/safety failures into user-safe error results. +/// See `docs/ai/image-generation.md` for prompt-writing guidance. +class ImageGeneratorService { + ImageGeneratorService({ + required this.apiKey, + this.modelName = GeminiModelNames.gemini25FlashImage, + this.aspectRatio = '16:9', + RetryPolicy? retryPolicy, + }) : retryPolicy = retryPolicy ?? RetryPolicy(); + + final String apiKey; + final String modelName; + + /// Aspect ratio for generated images. + /// Supported: 1:1, 2:3, 3:2, 3:4, 4:3, 9:16, 16:9, 21:9 + final String aspectRatio; + + /// Retry policy for transient generation failures. + final RetryPolicy retryPolicy; + + google_ai.GenerativeService? _service; + + // Simple in-memory LRU cache to avoid regenerating identical prompts during a + // single app/session (e.g., navigating back to the image style selector). + // + // Network/model inference dominates latency; caching is the highest-leverage + // local speedup without changing model behavior. + static final LinkedHashMap _memoryCache = + LinkedHashMap(); + static const int _maxCacheEntries = 32; + + int _cacheKey(String prompt) => Object.hash(modelName, aspectRatio, prompt); + + /// Generates an image from a text prompt. + /// + /// Returns [ImageGenerationResult.success] with image bytes on success, + /// or [ImageGenerationResult.failure] with an error message on failure. + Future generateImage(String prompt) async { + try { + final key = _cacheKey(prompt); + final cached = _memoryCache.remove(key); + if (cached != null) { + // Re-insert to mark as most-recently-used. + _memoryCache[key] = cached; + debugLog.log( + 'IMG', + 'Cache hit (${cached.length} bytes) for model: $modelName', + ); + return ImageGenerationResult.success(cached); + } + + _service ??= google_ai.GenerativeService.fromApiKey(apiKey); + + debugLog.log('IMG', 'Starting image generation with model: $modelName'); + + final request = google_ai.GenerateContentRequest( + model: modelName, + contents: [ + google_ai.Content( + role: 'user', + parts: [google_ai.Part(text: prompt)], + ), + ], + generationConfig: google_ai.GenerationConfig( + // Request image-only output to avoid text-only responses + responseModalities: [google_ai.GenerationConfig_Modality.image], + imageConfig: google_ai.ImageConfig(aspectRatio: aspectRatio), + ), + ); + + final response = await retryPolicy.run( + () => _service! + .generateContent(request) + .timeout( + const Duration(seconds: 60), + onTimeout: () { + throw TimeoutException('Image generation timed out'); + }, + ), + ); + + debugLog.log( + 'IMG', + 'Response received - candidates: ${response.candidates.length}', + ); + + // Check for blocked prompts + final blockReason = response.promptFeedback?.blockReason; + if (blockReason != null && blockReason.isNotDefault) { + debugLog.log('IMG', 'Prompt blocked: $blockReason'); + return ImageGenerationResult.failure( + 'Content blocked by safety filter', + ); + } + + final candidate = response.candidates.firstOrNull; + + // Check finish reason for image-specific failures + final finishReason = candidate?.finishReason; + if (finishReason != null) { + final imageFailReasons = { + google_ai.Candidate_FinishReason.safety, + google_ai.Candidate_FinishReason.recitation, + google_ai.Candidate_FinishReason.blocklist, + google_ai.Candidate_FinishReason.prohibitedContent, + google_ai.Candidate_FinishReason.imageSafety, + google_ai.Candidate_FinishReason.imageProhibitedContent, + google_ai.Candidate_FinishReason.imageRecitation, + google_ai.Candidate_FinishReason.noImage, + google_ai.Candidate_FinishReason.imageOther, + }; + if (imageFailReasons.contains(finishReason)) { + debugLog.log('IMG', 'Image blocked by finish reason: $finishReason'); + return ImageGenerationResult.failure( + 'Content blocked by safety filter', + ); + } + } + + final parts = candidate?.content?.parts ?? []; + debugLog.log('IMG', 'Parts in response: ${parts.length}'); + + for (final part in parts) { + if (part.inlineData != null && part.inlineData!.data.isNotEmpty) { + debugLog.log( + 'IMG', + 'Image generated: ${part.inlineData!.data.length} bytes, ' + 'mime: ${part.inlineData!.mimeType}', + ); + final bytes = part.inlineData!.data; + + _memoryCache[key] = bytes; + while (_memoryCache.length > _maxCacheEntries) { + _memoryCache.remove(_memoryCache.keys.first); + } + + return ImageGenerationResult.success(bytes); + } + if (part.text != null) { + debugLog.log('IMG', 'Text response: ${part.text}'); + } + } + + debugLog.log('IMG', 'No image data in response'); + return ImageGenerationResult.failure('No image data in response'); + } on TimeoutException { + debugLog.error('IMG', 'Image generation timed out after 60s'); + return ImageGenerationResult.failure('Image generation timed out'); + } catch (e) { + // Log error type only - avoid exposing API keys in stack traces + debugLog.error('IMG', 'Image generation failed: ${e.runtimeType}'); + return ImageGenerationResult.failure( + 'Image generation failed: ${e.runtimeType}', + ); + } + } + + /// Builds an image prompt with presentation-specific constraints. + /// + /// [stylePrompt] should describe visual intent as narrative prose. + /// See `docs/ai/image-generation.md` for examples and prompting guidance. + static String buildPrompt(String stylePrompt, {String? backgroundColor}) { + final base = PromptRegistry.instance.render( + 'image_generation', + input: {'stylePrompt': stylePrompt}, + ); + if (backgroundColor != null && backgroundColor.isNotEmpty) { + return '$base\n' + 'Use $backgroundColor as the dominant background color for the image. ' + 'The subject should complement this background.'; + } + return base; + } + + void dispose() { + _service?.close(); + _service = null; + } +} diff --git a/packages/genui/lib/src/ai/services/prompt_builder.dart b/packages/genui/lib/src/ai/services/prompt_builder.dart new file mode 100644 index 00000000..11ab241d --- /dev/null +++ b/packages/genui/lib/src/ai/services/prompt_builder.dart @@ -0,0 +1,115 @@ +import '../wizard_context.dart'; + +/// Builds a deck generation prompt from wizard context data. +/// +/// Extracts user selections from the 8-step wizard workflow and formats +/// them into a structured prompt for the AI generation service. +String buildPromptFromWizardContext(WizardContext context) { + final buffer = StringBuffer(); + + buffer.writeln('Generate a presentation with the following specifications:'); + buffer.writeln(); + + _writeBasicFields(buffer, context); + _writeColorPalette(buffer, context); + _writeFontConfiguration(buffer, context); + _writeImageStyleDirection(buffer, context); + _writeLayoutGuidance(buffer); + + return buffer.toString(); +} + +/// Maximum length for user-provided text fields. +const int _maxFieldLength = 500; + +/// Sanitizes user input to prevent prompt injection. +/// +/// - Truncates to max length +/// - Removes control characters (except newlines/tabs) +String _sanitize(Object? value) { + if (value == null) return ''; + var text = value.toString(); + + // Truncate to prevent excessive input + if (text.length > _maxFieldLength) { + text = '${text.substring(0, _maxFieldLength)}...'; + } + + // Remove control characters (except newlines/tabs) + text = text.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]'), ''); + + return text; +} + +void _writeBasicFields(StringBuffer buffer, WizardContext context) { + if (context.topic != null) { + buffer.writeln('Topic: ${_sanitize(context.topic)}'); + } + if (context.audience != null) { + buffer.writeln('Target Audience: ${_sanitize(context.audience)}'); + } + if (context.approach != null) { + buffer.writeln('Presentation Approach: ${_sanitize(context.approach)}'); + } + if (context.emphasis != null && context.emphasis!.isNotEmpty) { + final sanitized = context.emphasis!.map((e) => _sanitize(e)).join(', '); + buffer.writeln('Key Areas to Emphasize: $sanitized'); + } + if (context.slideCount != null) { + final count = context.slideCount!; + if (count > 0 && count <= 50) { + buffer.writeln('Number of Slides: $count'); + } + } + if (context.style != null) { + buffer.writeln('Visual Style: ${_sanitize(context.style)}'); + } +} + +void _writeColorPalette(StringBuffer buffer, WizardContext context) { + final colors = context.colors; + if (colors != null && colors.isNotEmpty) { + buffer.writeln(); + buffer.writeln('Color Palette:'); + buffer.writeln(' - Background: ${_sanitize(colors[0])}'); + if (colors.length > 1) { + buffer.writeln(' - Heading text: ${_sanitize(colors[1])}'); + } + if (colors.length > 2) { + buffer.writeln(' - Body text: ${_sanitize(colors[2])}'); + } + } +} + +void _writeFontConfiguration(StringBuffer buffer, WizardContext context) { + if (context.headlineFont != null) { + buffer.writeln('Headline Font: ${_sanitize(context.headlineFont)}'); + } + if (context.bodyFont != null) { + buffer.writeln('Body Font: ${_sanitize(context.bodyFont)}'); + } +} + +void _writeImageStyleDirection(StringBuffer buffer, WizardContext context) { + if (context.imageStyleName != null) { + buffer.writeln(); + buffer.writeln('Visual Direction: ${_sanitize(context.imageStyleName)}'); + if (context.imageStyleDescription != null) { + buffer.writeln( + 'Visual Style Description: ${_sanitize(context.imageStyleDescription)}', + ); + } + } +} + +void _writeLayoutGuidance(StringBuffer buffer) { + buffer.writeln(); + buffer.writeln( + 'Layout Guidance: Use sections as rows and blocks as columns. Use 1-2 ' + 'blocks per section (never 4+). Put all bullets in a single block. Use ' + 'two sections (title + body) for most slides. Center-align only titles; ' + 'left-align body content. Do not use widget blocks or empty blocks. ' + 'Keep slide titles short (3-5 words max) to prevent text cutoff; use ' + 'smaller heading sizes (### or ####) for longer titles.', + ); +} diff --git a/packages/genui/lib/src/ai/services/retry_policy.dart b/packages/genui/lib/src/ai/services/retry_policy.dart new file mode 100644 index 00000000..7a5d1480 --- /dev/null +++ b/packages/genui/lib/src/ai/services/retry_policy.dart @@ -0,0 +1,88 @@ +import 'dart:math'; + +/// Retry helper with exponential backoff and optional jitter. +class RetryPolicy { + RetryPolicy({ + this.maxAttempts = 5, + this.baseDelay = const Duration(milliseconds: 500), + this.maxDelay = const Duration(seconds: 8), + this.jitterFactor = 0.2, + this.shouldRetry, + Random? random, + Future Function(Duration)? delayFn, + }) : _random = random ?? Random(), + _delayFn = delayFn ?? Future.delayed; + + /// Maximum total attempts (initial try + retries). + final int maxAttempts; + + /// Base delay for the first retry attempt. + final Duration baseDelay; + + /// Maximum backoff delay between attempts. + final Duration maxDelay; + + /// Jitter percentage applied to the delay (0.2 = +/-20%). + final double jitterFactor; + + /// Optional custom retry predicate. If null, uses [defaultRetryDecider]. + final bool Function(Object error)? shouldRetry; + + final Random _random; + final Future Function(Duration) _delayFn; + + /// Runs [action] with retry/backoff. + Future run(Future Function() action) async { + var attempt = 0; + while (true) { + attempt++; + try { + return await action(); + } catch (error) { + final retryDecider = shouldRetry ?? defaultRetryDecider; + final canRetry = attempt < maxAttempts && retryDecider(error); + if (!canRetry) { + rethrow; + } + final delay = _computeDelay(attempt); + if (delay > Duration.zero) { + await _delayFn(delay); + } + } + } + } + + Duration _computeDelay(int attempt) { + if (baseDelay <= Duration.zero) { + return Duration.zero; + } + + final baseMs = baseDelay.inMilliseconds; + final maxMs = maxDelay.inMilliseconds; + final exponent = attempt - 1; + final scaled = baseMs * (1 << exponent); + var delayMs = scaled > maxMs ? maxMs : scaled; + var delay = delayMs.toDouble(); + + if (jitterFactor > 0) { + final jitter = delay * jitterFactor; + final min = delay - jitter; + final max = delay + jitter; + delay = min + (_random.nextDouble() * (max - min)); + } + + if (delay < 0) { + delay = 0; + } else if (delay > maxMs) { + delay = maxMs.toDouble(); + } + + return Duration(milliseconds: delay.round()); + } + + /// Default retry predicate for service-unavailable (HTTP 503) errors. + static bool defaultRetryDecider(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('503'); + } +} diff --git a/packages/genui/lib/src/ai/services/services.dart b/packages/genui/lib/src/ai/services/services.dart new file mode 100644 index 00000000..006c7cff --- /dev/null +++ b/packages/genui/lib/src/ai/services/services.dart @@ -0,0 +1,15 @@ +/// Barrel file for AI generation services. +/// +/// Provides centralized access to all AI-related services: +/// - [DeckGeneratorService] - Generates presentations from prompts +/// - [ImageGeneratorService] - Generates images using Gemini +/// - [ErrorClassifier] - Classifies AI errors for user messages +/// - Generation progress types for UI updates +library; + +export 'deck_generator_service.dart'; +export 'error_classifier.dart'; +export 'generation_progress.dart'; +export 'image_generator_service.dart'; +export 'prompt_builder.dart'; +export 'retry_policy.dart'; diff --git a/packages/genui/lib/src/ai/services/slide_key_utils.dart b/packages/genui/lib/src/ai/services/slide_key_utils.dart new file mode 100644 index 00000000..989d1bdb --- /dev/null +++ b/packages/genui/lib/src/ai/services/slide_key_utils.dart @@ -0,0 +1,36 @@ +import '../../utils/hash_utils.dart'; + +/// Generates a deterministic slide key based on slide content. +String generateSlideKey(Map slide, int index) { + final options = slide['options'] as Map?; + final title = options?['title'] as String?; + + String contentForHash; + if (title != null && title.isNotEmpty) { + contentForHash = title; + } else { + contentForHash = _extractFirstContent(slide) ?? 'slide'; + } + + final hashInput = '$index:$contentForHash'; + return generateValueHash(hashInput); +} + +String? _extractFirstContent(Map slide) { + final sections = slide['sections'] as List?; + if (sections == null || sections.isEmpty) return null; + + for (final section in sections) { + final blocks = (section as Map?)?['blocks'] as List?; + if (blocks == null) continue; + + for (final block in blocks) { + final content = (block as Map?)?['content'] as String?; + if (content != null && content.isNotEmpty) { + return content; + } + } + } + + return null; +} diff --git a/packages/genui/lib/src/ai/services/style_json_serializer.dart b/packages/genui/lib/src/ai/services/style_json_serializer.dart new file mode 100644 index 00000000..00e14004 --- /dev/null +++ b/packages/genui/lib/src/ai/services/style_json_serializer.dart @@ -0,0 +1,19 @@ +import '../schemas/deck_schemas.dart'; + +/// Serializes a parsed deck style to a JSON-encodable map. +/// +/// ACK enum fields are converted to their wire-format string IDs. +Map serializeDeckStyleForJson(DeckStyleType style) { + final colors = style.colors; + final fonts = style.fonts; + + return { + 'name': style.name, + 'colors': { + 'background': colors.background, + 'heading': colors.heading, + 'body': colors.body, + }, + 'fonts': {'headline': fonts.headline.name, 'body': fonts.body.name}, + }; +} diff --git a/packages/genui/lib/src/ai/wizard_context.dart b/packages/genui/lib/src/ai/wizard_context.dart new file mode 100644 index 00000000..2c4b6ce1 --- /dev/null +++ b/packages/genui/lib/src/ai/wizard_context.dart @@ -0,0 +1,165 @@ +import './schemas/wizard_context_keys.dart'; + +/// Typed representation of wizard context data used for generation. +/// +/// Keeps dynamic maps at the AI boundary while providing type safety +/// in domain/business logic. +class WizardContext { + final String? topic; + final String? audience; + final String? approach; + final List? emphasis; + final int? slideCount; + final String? style; + final List? colors; + final String? headlineFont; + final String? bodyFont; + final String? imageStyleId; + final String? imageStyleName; + final String? imageStyleDescription; + + const WizardContext({ + this.topic, + this.audience, + this.approach, + this.emphasis, + this.slideCount, + this.style, + this.colors, + this.headlineFont, + this.bodyFont, + this.imageStyleId, + this.imageStyleName, + this.imageStyleDescription, + }); + + WizardContext copyWith({ + String? topic, + String? audience, + String? approach, + List? emphasis, + int? slideCount, + String? style, + List? colors, + String? headlineFont, + String? bodyFont, + String? imageStyleId, + String? imageStyleName, + String? imageStyleDescription, + }) { + return WizardContext( + topic: topic ?? this.topic, + audience: audience ?? this.audience, + approach: approach ?? this.approach, + emphasis: emphasis ?? this.emphasis, + slideCount: slideCount ?? this.slideCount, + style: style ?? this.style, + colors: colors ?? this.colors, + headlineFont: headlineFont ?? this.headlineFont, + bodyFont: bodyFont ?? this.bodyFont, + imageStyleId: imageStyleId ?? this.imageStyleId, + imageStyleName: imageStyleName ?? this.imageStyleName, + imageStyleDescription: + imageStyleDescription ?? this.imageStyleDescription, + ); + } + + /// Merge [other] into this context, preferring non-null values from [other]. + WizardContext merge(WizardContext other) { + return WizardContext( + topic: other.topic ?? topic, + audience: other.audience ?? audience, + approach: other.approach ?? approach, + emphasis: other.emphasis ?? emphasis, + slideCount: other.slideCount ?? slideCount, + style: other.style ?? style, + colors: other.colors ?? colors, + headlineFont: other.headlineFont ?? headlineFont, + bodyFont: other.bodyFont ?? bodyFont, + imageStyleId: other.imageStyleId ?? imageStyleId, + imageStyleName: other.imageStyleName ?? imageStyleName, + imageStyleDescription: + other.imageStyleDescription ?? imageStyleDescription, + ); + } + + /// Convert to a map using [WizardContextKeys]. + Map toMap() { + final map = {}; + + void setIfNonNull(String key, Object? value) { + if (value != null) map[key] = value; + } + + setIfNonNull(WizardContextKeys.topic, topic); + setIfNonNull(WizardContextKeys.audience, audience); + setIfNonNull(WizardContextKeys.approach, approach); + setIfNonNull(WizardContextKeys.emphasis, emphasis); + setIfNonNull(WizardContextKeys.slideCount, slideCount); + setIfNonNull(WizardContextKeys.style, style); + setIfNonNull(WizardContextKeys.colors, colors); + setIfNonNull(WizardContextKeys.headlineFont, headlineFont); + setIfNonNull(WizardContextKeys.bodyFont, bodyFont); + setIfNonNull(WizardContextKeys.imageStyleId, imageStyleId); + setIfNonNull(WizardContextKeys.imageStyleName, imageStyleName); + setIfNonNull( + WizardContextKeys.imageStyleDescription, + imageStyleDescription, + ); + + return map; + } + + /// Parse a [WizardContext] from a loose map. + factory WizardContext.fromMap(Map map) { + return WizardContext( + topic: _stringOrNull(map[WizardContextKeys.topic]), + audience: _stringOrNull(map[WizardContextKeys.audience]), + approach: _stringOrNull(map[WizardContextKeys.approach]), + emphasis: _stringListOrNull(map[WizardContextKeys.emphasis]), + slideCount: _intOrNull(map[WizardContextKeys.slideCount]), + style: _stringOrNull(map[WizardContextKeys.style]), + colors: _stringListOrNull(map[WizardContextKeys.colors]), + headlineFont: _stringOrNull(map[WizardContextKeys.headlineFont]), + bodyFont: _stringOrNull(map[WizardContextKeys.bodyFont]), + imageStyleId: _stringOrNull(map[WizardContextKeys.imageStyleId]), + imageStyleName: _stringOrNull(map[WizardContextKeys.imageStyleName]), + imageStyleDescription: _stringOrNull( + map[WizardContextKeys.imageStyleDescription], + ), + ); + } +} + +String? _stringOrNull(Object? value) { + if (value == null) return null; + final text = value.toString().trim(); + return text.isEmpty ? null : text; +} + +List? _stringListOrNull(Object? value) { + if (value == null) return null; + if (value is List) { + final items = value + .map((item) => _stringOrNull(item)) + .whereType() + .toList(); + return items.isEmpty ? null : items; + } + final text = _stringOrNull(value); + if (text == null) return null; + return [text]; +} + +int? _intOrNull(Object? value) { + if (value is int) return value > 0 ? value : null; + if (value is num) { + final i = value.toInt(); + return i > 0 ? i : null; + } + if (value is String) { + final parsed = int.tryParse(value.trim()); + if (parsed != null && parsed > 0) return parsed; + } + return null; +} diff --git a/packages/genui/lib/src/chat/chat_message.dart b/packages/genui/lib/src/chat/chat_message.dart new file mode 100644 index 00000000..0a4eb330 --- /dev/null +++ b/packages/genui/lib/src/chat/chat_message.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +/// Sealed class hierarchy for chat messages in the SuperDeck AI application. +/// +/// Uses sealed classes for exhaustive pattern matching on message types. +sealed class SuperdeckChatMessage { + const SuperdeckChatMessage(); +} + +/// A message sent by the user. +final class SuperdeckUserMessage extends SuperdeckChatMessage { + const SuperdeckUserMessage(this.text); + + final String text; +} + +/// A response from the AI assistant. +final class SuperdeckAiMessage extends SuperdeckChatMessage { + const SuperdeckAiMessage(this.text); + + final String text; +} + +/// A debug message for development/debugging purposes. +/// +/// Only displayed when debug mode is enabled. +final class SuperdeckDebugMessage extends SuperdeckChatMessage { + const SuperdeckDebugMessage(this.text); + + final String text; +} + +/// A debug message containing formatted JSON. +/// +/// Automatically formats JSON with pretty-printing and markdown code blocks. +/// Falls back to raw text if JSON parsing fails. +final class SuperdeckJsonDebugMessage extends SuperdeckChatMessage { + SuperdeckJsonDebugMessage(String json) { + String jsonMD(String content) => '```json\n$content```'; + + try { + final map = jsonDecode(json) as Map; + final prettyJson = JsonEncoder.withIndent(' ').convert(map); + text = jsonMD(prettyJson); + } catch (e) { + text = json; + } + } + + late final String text; +} + +/// Typed parser for user action payloads from GenUI. +/// +/// Safely extracts and validates the action structure from JSON. +/// Expected format: +/// ```json +/// { +/// "userAction": { +/// "name": "action_name", +/// "context": { "message": "Display text", ... } +/// } +/// } +/// ``` +class UserActionPayload { + final String actionName; + final Map context; + + const UserActionPayload({required this.actionName, required this.context}); + + /// The message to display in chat, extracted from context. + String get displayMessage => context['message'] as String? ?? actionName; + + /// Attempts to parse a JSON string into a [UserActionPayload]. + /// + /// Returns null if parsing fails or the structure is unexpected. + static UserActionPayload? tryParse(String jsonString) { + try { + final json = jsonDecode(jsonString); + if (json is! Map) return null; + + final userAction = json['userAction']; + if (userAction is! Map) return null; + + final name = userAction['name']; + if (name is! String) return null; + + final context = userAction['context']; + final contextMap = context is Map + ? context + : {}; + + return UserActionPayload(actionName: name, context: contextMap); + } catch (_) { + return null; + } + } +} diff --git a/packages/genui/lib/src/chat/chat_viewmodel.dart b/packages/genui/lib/src/chat/chat_viewmodel.dart new file mode 100644 index 00000000..d5c22ec2 --- /dev/null +++ b/packages/genui/lib/src/chat/chat_viewmodel.dart @@ -0,0 +1,387 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:genui/genui.dart'; +import 'package:genui_google_generative_ai/genui_google_generative_ai.dart'; +import 'package:signals/signals_flutter.dart'; +import './chat_message.dart'; +import './view/widgets/model_select.dart'; +import '../ai/catalog/catalog.dart'; +import '../ai/wizard_context.dart'; +import '../ai/prompts/prompt_registry.dart'; +import '../ai/services/deck_generator_service.dart'; +import '../ai/services/error_classifier.dart'; +import '../ai/services/generation_progress.dart'; +import '../ai/services/prompt_builder.dart'; +import '../env_config.dart'; +import '../constants/paths.dart'; +import '../viewmodel_scope.dart'; +import '../debug_logger.dart'; + +/// Builder for creating GenUI conversations. +/// +/// Allows tests to inject a mock conversation without extra abstractions. +typedef ConversationBuilder = + GenUiConversation Function({ + required ContentGenerator contentGenerator, + required A2uiMessageProcessor a2uiMessageProcessor, + required ValueChanged? onTextResponse, + required ValueChanged? onError, + required ValueChanged? onSurfaceAdded, + required ValueChanged? onSurfaceUpdated, + required ValueChanged? onSurfaceDeleted, + }); + +class ChatViewModel implements Disposable { + /// Creates a ChatViewModel with optional dependency injection for testing. + /// + /// [conversationBuilder] - Builder for creating conversations. Defaults to + /// [GenUiConversation.new] which creates real GenUI conversations. + ChatViewModel({@visibleForTesting ConversationBuilder? conversationBuilder}) + : _conversationBuilder = conversationBuilder ?? GenUiConversation.new; + + final ConversationBuilder _conversationBuilder; + final model = Signal(GeminiModels.defaultValue); + final surfaceIds = Signal>([]); + final _conversation = Signal(null); + final debugMode = Signal(false); + final showChat = Signal(true); + final Signal> _messages = signal([]); + + /// Signal holding the isProcessing bridge from the current conversation. + final _isProcessingBridge = Signal?>(null); + + /// Subscription for user action events from the message processor. + StreamSubscription? _onSubmitSubscription; + + GenUiHost? get host => _conversation.value?.host; + A2uiMessageProcessor? get processor => + _conversation.value?.a2uiMessageProcessor; + + /// Whether the AI is currently processing a response. + late final Computed isThinking = computed(() { + return _isProcessingBridge.value?.value ?? false; + }); + + /// Filtered messages based on debug mode. + late final Computed> messages = computed(() { + if (debugMode.value) { + return _messages.value; + } + + return _messages.value.where((e) { + return e is SuperdeckUserMessage || e is SuperdeckAiMessage; + }).toList(); + }); + + /// Whether a conversation has been successfully initialized. + /// + /// Derived from conversation existence, not message history, so that + /// error messages (e.g., missing API key) don't lock model selection. + late final Computed hasConversationStarted = computed( + () => _conversation.value != null, + ); + + /// Initializes a new GenUI conversation. + /// + /// Returns true if conversation was successfully created, false otherwise. + bool buildConversation() { + // Reset bridge when building new conversation + _isProcessingBridge.value = null; + + final apiKey = _readApiKey(); + if (apiKey == null) return false; + + try { + final processor = A2uiMessageProcessor(catalogs: [chatCatalog]); + final systemInstruction = _loadSystemInstruction(); + if (systemInstruction == null) return false; + + final contentGenerator = GoogleGenerativeAiContentGenerator( + catalog: chatCatalog, + systemInstruction: systemInstruction, + apiKey: apiKey, + modelName: model.value.modelPath, + additionalTools: [], + ); + + _bindOnSubmit(processor); + _conversation.value = _createConversation( + processor: processor, + contentGenerator: contentGenerator, + ); + + // Set the signal bridge for isProcessing + _isProcessingBridge.value = _conversation.value!.isProcessing.toSignal(); + return true; + } catch (e, stack) { + debugLog.error('CONV', _sanitizeError(e), stack); + _messages.add( + SuperdeckAiMessage( + 'Failed to initialize conversation. Please try again.', + ), + ); + return false; + } + } + + String? _readApiKey() { + try { + return EnvConfig.geminiApiKey; + } on StateError { + _messages.add( + SuperdeckAiMessage( + 'Unable to start conversation. Please check your API key configuration.', + ), + ); + return null; + } + } + + String? _loadSystemInstruction() { + try { + return PromptRegistry.instance.render('wizard_system'); + } on StateError catch (e) { + debugLog.error('PROMPT', e.message); + _messages.add( + SuperdeckAiMessage( + 'Unable to load conversation prompts. Please restart the app.', + ), + ); + return null; + } + } + + void _bindOnSubmit(A2uiMessageProcessor processor) { + _onSubmitSubscription = processor.onSubmit.listen((message) { + final parsed = UserActionPayload.tryParse(message.text); + if (parsed == null) { + debugLog.log('USER', 'Failed to parse user action: ${message.text}'); + _addDebugMessage('Received unexpected action format'); + return; + } + _messages.add(SuperdeckUserMessage(parsed.displayMessage)); + _addJsonDebugMessage(message.text); + }); + } + + GenUiConversation _createConversation({ + required ContentGenerator contentGenerator, + required A2uiMessageProcessor processor, + }) { + return _conversationBuilder( + contentGenerator: contentGenerator, + a2uiMessageProcessor: processor, + onTextResponse: _handleTextResponse, + onError: _handleConversationError, + onSurfaceAdded: _handleSurfaceAdded, + onSurfaceUpdated: _handleSurfaceUpdated, + onSurfaceDeleted: _handleSurfaceDeleted, + ); + } + + void _handleTextResponse(String value) { + _logElapsed('TEXT_RESPONSE received'); + debugLog.aiResponse('TEXT', value); + _messages.add(SuperdeckAiMessage(value)); + } + + void _handleConversationError(ContentGeneratorError value) { + _logElapsed('ERROR received'); + // Log sanitized error to avoid exposing API keys + debugLog.error('GenUI', _sanitizeError(value.error)); + _messages.add(SuperdeckAiMessage(_getErrorMessage(value.error))); + } + + void _handleSurfaceAdded(SurfaceAdded value) { + _logElapsed('SURFACE_ADDED: ${value.surfaceId}'); + debugLog.surface('ADDED', value.surfaceId); + // Only add if not already present (prevent duplicate keys) + if (!surfaceIds.value.contains(value.surfaceId)) { + surfaceIds.value = [...surfaceIds.value, value.surfaceId]; + _addDebugMessage('Surface added: ${value.surfaceId}'); + } + } + + void _handleSurfaceUpdated(SurfaceUpdated value) { + _logElapsed('SURFACE_UPDATED: ${value.surfaceId}'); + debugLog.surface('UPDATED', value.surfaceId); + // Add surface if missing (handles race conditions or reused IDs) + if (!surfaceIds.value.contains(value.surfaceId)) { + surfaceIds.value = [...surfaceIds.value, value.surfaceId]; + _addDebugMessage('Surface added via update: ${value.surfaceId}'); + } + _addDebugMessage('Surface updated: ${value.surfaceId}'); + // GenUiSurface handles per-surface updates via internal ValueNotifier + } + + void _handleSurfaceDeleted(SurfaceRemoved value) { + _logElapsed('SURFACE_DELETED: ${value.surfaceId}'); + debugLog.surface('DELETED', value.surfaceId); + // Use value assignment for reactivity (not .remove() which mutates) + surfaceIds.value = surfaceIds.value + .where((id) => id != value.surfaceId) + .toList(); + _addDebugMessage('Surface deleted: ${value.surfaceId}'); + } + + /// Tracks when the last request was sent for latency measurement. + DateTime? _lastRequestTime; + + void sendMessage(String raw) { + final message = raw.trim(); + if (message.isEmpty) return; + + _lastRequestTime = DateTime.now(); + debugLog.userAction('SEND_MESSAGE', {'message': message}); + debugLog.section('New Message'); + debugLog.log( + 'TIMING', + 'Request started at ${_lastRequestTime!.toIso8601String()}', + ); + + // Build conversation if not started; abort if initialization fails + if (!hasConversationStarted.value) { + debugLog.log('CONV', 'Building new conversation'); + final ok = buildConversation(); + if (!ok) return; + } + + _messages.add(SuperdeckUserMessage(message)); + _conversation.value?.sendRequest(UserMessage.text(message)); + } + + /// Logs elapsed time since request started. + void _logElapsed(String event) { + if (_lastRequestTime == null) return; + final elapsed = DateTime.now().difference(_lastRequestTime!); + debugLog.log('TIMING', '$event at +${elapsed.inMilliseconds}ms'); + } + + void restartConversation() { + debugLog.section('Conversation Restarted'); + _onSubmitSubscription?.cancel(); + _onSubmitSubscription = null; + _conversation.value?.dispose(); + _conversation.value = null; + _isProcessingBridge.value = null; + + _messages.clear(); + surfaceIds.clear(); + } + + @override + void dispose() { + _onSubmitSubscription?.cancel(); + _onSubmitSubscription = null; + _conversation.value?.dispose(); + + // Dispose signals and computeds + model.dispose(); + surfaceIds.dispose(); + _conversation.dispose(); + debugMode.dispose(); + showChat.dispose(); + _messages.dispose(); + _isProcessingBridge.dispose(); + isThinking.dispose(); + messages.dispose(); + hasConversationStarted.dispose(); + } + + /// Generates presentation directly from wizard context. + /// + /// This bypasses the GenUI AI tool flow and calls DeckGeneratorService directly. + /// Progress updates are reported via [onProgress] if provided. + Future generateFromContext( + WizardContext context, + GenerationProgressCallback? onProgress, + ) async { + debugLog.log( + 'GEN', + 'generateFromContext called with context: ${context.toMap()}', + ); + final prompt = buildPromptFromWizardContext(context); + debugLog.log('GEN', 'Built prompt:\n$prompt'); + // Uses Pro with thinking by default for better quality generation + final service = DeckGeneratorService(apiKey: EnvConfig.geminiApiKey); + debugLog.log('GEN', 'Calling DeckGeneratorService.generate()...'); + final imageStyleId = context.imageStyleId; + final backgroundColor = context.colors?.firstOrNull; + + // Save generation metadata before starting pipeline so regeneration + // works even if this attempt fails. + await _saveGenerationMetadata(prompt, imageStyleId, backgroundColor); + + final result = await service.generate( + prompt, + imageStyleId: imageStyleId, + backgroundColor: backgroundColor, + onProgress: onProgress, + ); + debugLog.log('GEN', 'Generation result - success: ${result.success}'); + return result; + } + + /// Regenerates presentation from the last saved prompt and parameters. + Future regenerateFromLastPrompt( + GenerationProgressCallback? onProgress, + ) async { + final service = DeckGeneratorService(apiKey: EnvConfig.geminiApiKey); + return service.regenerateFromLastPrompt(onProgress: onProgress); + } + + /// Persists generation parameters so regeneration works even after failure. + Future _saveGenerationMetadata( + String prompt, + String? imageStyleId, + String? backgroundColor, + ) async { + try { + final metadata = { + 'prompt': prompt, + if (imageStyleId case final styleId?) 'imageStyleId': styleId, + if (backgroundColor case final bgColor?) 'backgroundColor': bgColor, + }; + await Directory(Paths.superdeckDir).create(recursive: true); + final metadataFile = File(Paths.lastGenerationPath); + await metadataFile.writeAsString( + const JsonEncoder.withIndent(' ').convert(metadata), + ); + // Also save plain prompt for human readability + await File(Paths.lastPromptPath).writeAsString(prompt); + } catch (e) { + debugLog.log( + 'GEN', + 'Failed to save generation metadata ' + '(${Paths.lastGenerationPath}, ${Paths.lastPromptPath}): $e', + ); + } + } + + void _addDebugMessage(String message) { + _messages.add(SuperdeckDebugMessage(message)); + } + + void _addJsonDebugMessage(String json) { + _messages.add(SuperdeckJsonDebugMessage(json)); + } + + /// Error classifier for converting errors to user-friendly messages. + static const _errorClassifier = ErrorClassifier(); + + /// Maps error objects to user-friendly messages based on error type/content. + String _getErrorMessage(Object error) => + _errorClassifier.getUserMessage(error); + + /// Sanitizes error messages to avoid exposing API keys in logs. + /// + /// Redacts any string that looks like an API key (long alphanumeric tokens). + String _sanitizeError(Object error) { + final str = error.toString(); + // Redact long alphanumeric tokens (potential API keys) + return str.replaceAll(RegExp(r'[A-Za-z0-9_-]{20,}'), '[REDACTED]'); + } +} diff --git a/packages/genui/lib/src/chat/view/chat_screen.dart b/packages/genui/lib/src/chat/view/chat_screen.dart new file mode 100644 index 00000000..c3b4042d --- /dev/null +++ b/packages/genui/lib/src/chat/view/chat_screen.dart @@ -0,0 +1,379 @@ +import 'package:flutter/material.dart'; +import 'package:genui/genui.dart'; +import 'package:go_router/go_router.dart'; +import 'package:remix/remix.dart'; +import 'package:signals/signals_flutter.dart'; +import './widgets/chat_bubble.dart'; +import './widgets/chat_genui_panels.dart'; +import './widgets/chat_input.dart'; +import './widgets/chat_scaffold.dart'; +import './widgets/empty_state.dart'; +import './widgets/model_select.dart'; +import '../chat_message.dart'; +import '../chat_viewmodel.dart'; +import '../../viewmodel_scope.dart'; +import '../../routes.dart'; +import '../../ai/wizard_context.dart'; +import '../../ui/ui.dart'; +import '../../presentation/presentation_viewmodel.dart'; + +/// Main chat screen for the wizard-based presentation builder. +/// +/// Provides a two-panel interface with GenUI surfaces on the left +/// and chat messages on the right. Manages the [ChatViewModel] lifecycle. +class ChatScreen extends StatelessWidget { + const ChatScreen({super.key}); + + @override + Widget build(BuildContext context) { + return ViewModelScope( + create: () => ChatViewModel(), + child: const _ChatScreenScaffold(), + ); + } +} + +class _ChatScreenScaffold extends StatefulWidget { + const _ChatScreenScaffold(); + + @override + State<_ChatScreenScaffold> createState() => _ChatScreenScaffoldState(); +} + +class _ChatScreenScaffoldState extends State<_ChatScreenScaffold> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleSubmit(String value) { + final viewModel = context.read(); + viewModel.sendMessage(value); + _controller.clear(); + } + + @override + Widget build(BuildContext context) { + final viewModel = context.read(); + + return Watch((context) { + final showChat = viewModel.showChat.value; + final isThinking = viewModel.isThinking.value; + + // Single input widget, positioned based on showChat + final inputWidget = ChatInput( + controller: _controller, + enabled: !isThinking, + onSubmitted: _handleSubmit, + ); + + return _buildScaffold( + context: context, + viewModel: viewModel, + inputWidget: inputWidget, + showChat: showChat, + ); + }); + } + + Widget _buildScaffold({ + required BuildContext context, + required ChatViewModel viewModel, + required Widget inputWidget, + required bool showChat, + }) { + return ChatScaffold( + showChat: showChat, + appBar: _buildHeader( + context: context, + viewModel: viewModel, + showChat: showChat, + ), + leadingWidget: _AiSurfaces(inputWidget: showChat ? null : inputWidget), + trailingWidget: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: _ChatBody( + inputWidget: showChat ? inputWidget : null, + onSuggestionTap: _handleSubmit, + ), + ), + ); + } + + PreferredSizeWidget _buildHeader({ + required BuildContext context, + required ChatViewModel viewModel, + required bool showChat, + }) { + return SdHeader( + leading: Row( + spacing: 16, + children: [ + Watch((context) { + final hasConversationStarted = + viewModel.hasConversationStarted.value; + return ModelsSelect( + enabled: !hasConversationStarted, + selectedValue: viewModel.model.value, + onChanged: (value) { + viewModel.model.set(value); + }, + ); + }), + ], + ), + trailing: _buildHeaderActions( + context: context, + viewModel: viewModel, + showChat: showChat, + ), + ); + } + + Widget _buildHeaderActions({ + required BuildContext context, + required ChatViewModel viewModel, + required bool showChat, + }) { + return Row( + spacing: 8, + children: [ + SdIconButton( + icon: Icons.refresh, + semanticLabel: 'Regenerate presentation', + onPressed: () { + final presentationVM = context.read(); + presentationVM.generate( + context: const WizardContext(), + callback: (context, onProgress) => + viewModel.regenerateFromLastPrompt(onProgress), + ); + context.go(GenUiRoutes.presentationCreating); + }, + ), + SdIconButton( + icon: Icons.slideshow, + semanticLabel: 'View presentation', + onPressed: () { + context.go(GenUiRoutes.presentation); + }, + ), + Watch((context) { + final debugMode = viewModel.debugMode.value; + return Row( + spacing: 8, + children: [ + GestureDetector( + onTap: () => viewModel.debugMode.set(!debugMode), + child: SdCaption( + 'Show logs', + style: TextStyler().color(FortalTokens.gray11()).fontSize(13), + ), + ), + SdSwitch( + selected: debugMode, + semanticLabel: 'Show debug logs', + onChanged: (value) { + viewModel.debugMode.set(value); + }, + ), + ], + ); + }), + SdIconButton( + icon: showChat ? Icons.chat : Icons.chat_outlined, + semanticLabel: showChat ? 'Hide chat panel' : 'Show chat panel', + onPressed: () { + viewModel.showChat.set(!showChat); + }, + ), + SdButton( + label: 'Restart', + icon: Icons.replay_rounded, + semanticLabel: 'Restart conversation', + onPressed: () { + viewModel.restartConversation(); + }, + ), + ], + ); + } +} + +class _AiSurfaces extends StatelessWidget { + const _AiSurfaces({this.inputWidget}); + + /// Optional input widget to display at bottom when chat panel is hidden. + final Widget? inputWidget; + + @override + Widget build(BuildContext context) { + final viewModel = context.read(); + final surfaceIds = viewModel.surfaceIds.watch(context); + final isThinking = viewModel.isThinking.watch(context); + + final flex = FlexBoxStyler() + .spacing(16) + .column() + .mainAxisSize(MainAxisSize.min) + .marginAll(24); + + // Build surfaces widget if conversation exists + Widget? surfacesWidget; + if (viewModel.host case final host?) { + if (surfaceIds.isNotEmpty) { + surfacesWidget = Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: AnimatedSwitcher( + duration: SdTokens.motionMedium, + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + child: AnimatedOpacity( + duration: SdTokens.motionFast, + opacity: isThinking ? 0.5 : 1.0, + child: WizardLoadingState( + isLoading: isThinking, + child: flex( + key: ValueKey(surfaceIds.last), + children: surfaceIds.map((e) { + return IgnorePointer( + key: ValueKey('ignore_$e'), + ignoring: isThinking, + child: GenUiSurface( + key: ValueKey('surface_$e'), + host: host, + surfaceId: e, + ), + ); + }).toList(), + ), + ), + ), + ), + ), + ); + } + } + + // When no input widget provided, just show surfaces + if (inputWidget == null) { + return surfacesWidget ?? const SizedBox.shrink(); + } + + // When input widget provided (chat hidden), show full layout with input at bottom + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: Column( + children: [ + Expanded( + child: Center( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const TypingBubble(), + const GenUiMessageBubble(), + ?surfacesWidget, + ], + ), + ), + ), + ), + Center( + child: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: SizedBox(width: 500, child: inputWidget), + ), + ), + ], + ), + ); + } +} + +class _ChatBody extends StatelessWidget { + const _ChatBody({this.inputWidget, this.onSuggestionTap}); + + /// Optional input widget to display at bottom when chat panel is visible. + final Widget? inputWidget; + + final ValueChanged? onSuggestionTap; + + @override + Widget build(BuildContext context) { + final viewModel = context.read(); + + return Column( + children: [ + Expanded(child: _MessageList(onSuggestionTap: onSuggestionTap)), + Padding( + padding: const EdgeInsets.only(bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Watch((context) { + final isThinking = viewModel.isThinking.value; + return isThinking + ? const LoadingResponse() + : const SizedBox.shrink(); + }), + ?inputWidget, + ], + ), + ), + ], + ); + } +} + +class _MessageList extends StatelessWidget { + const _MessageList({this.onSuggestionTap}); + + final ValueChanged? onSuggestionTap; + + @override + Widget build(BuildContext context) { + final viewModel = context.read(); + + final messages = viewModel.messages.watch(context); + final reversedMessages = messages.reversed.toList(); + + if (messages.isEmpty) { + return EmptyState(onSuggestionTap: onSuggestionTap); + } + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 24), + itemCount: reversedMessages.length * 2, + itemBuilder: (context, index) { + if (index.isEven) { + return const SizedBox(height: 16); + } + final message = reversedMessages[index ~/ 2]; + + switch (message) { + case SuperdeckUserMessage(): + return TextBubble(text: message.text, type: .user); + case SuperdeckAiMessage(): + return TextBubble(text: message.text, type: .ai); + case SuperdeckDebugMessage(): + return TextBubble(text: message.text, type: .debug); + case SuperdeckJsonDebugMessage(): + return TextBubble(text: message.text, type: .debug); + } + }, + ); + } +} diff --git a/packages/genui/lib/src/chat/view/widgets/chat_bubble.dart b/packages/genui/lib/src/chat/view/widgets/chat_bubble.dart new file mode 100644 index 00000000..63e59349 --- /dev/null +++ b/packages/genui/lib/src/chat/view/widgets/chat_bubble.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:remix/remix.dart'; +import '../../../ui/ui.dart'; + +/// Type of chat message bubble for styling differentiation. +/// +/// - [user]: Right-aligned with accent color background +/// - [ai]: Left-aligned with transparent background and border +/// - [debug]: Left-aligned with muted styling for debug output +enum TextBubbleType { user, ai, debug } + +/// Renders a chat message bubble with markdown support. +/// +/// Uses [TextBubbleType] to determine bubble alignment and color: +/// - [TextBubbleType.user] - Right-aligned, accent color background +/// - [TextBubbleType.ai] - Left-aligned, transparent with border +/// - [TextBubbleType.debug] - Left-aligned, muted gray styling +/// +/// Supports rendering markdown content via [GptMarkdown] with custom +/// code block styling. +class TextBubble extends StatelessWidget { + final String text; + final TextBubbleType type; + + const TextBubble({super.key, required this.text, required this.type}); + + BoxStyler get _userStyle => .new() + .borderRadiusAll(Radius.circular(SdTokens.cardRadius)) + .paddingAll(12) + .color(FortalTokens.accentA3()) + .wrap( + WidgetModifierConfig // + .align(alignment: .centerRight) + .defaultTextStyle(style: FortalTokens.text3.mix()) + .defaultTextStyle( + style: TextStyleMix(color: FortalTokens.gray11()), + ), + ); + + BoxStyler get _aiStyle => _userStyle + .color(Colors.transparent) + .borderAll(color: FortalTokens.gray3()) + .wrap( + WidgetModifierConfig // + .align(alignment: .centerLeft) + .defaultTextStyle( + style: FortalTokens.text3.mix(), + textHeightBehavior: TextHeightBehaviorMix() + .applyHeightToLastDescent(false) + .applyHeightToFirstAscent(false), + ), + ); + + BoxStyler get _debugStyle => _aiStyle + .color(FortalTokens.gray3()) + .borderAll(color: FortalTokens.gray4()) + .wrap( + WidgetModifierConfig // + .defaultTextStyle(style: FortalTokens.text2.mix()) + .defaultTextStyle( + style: TextStyleMix(color: FortalTokens.gray10()), + ), + ); + + @override + Widget build(BuildContext context) { + final container = switch (type) { + TextBubbleType.user => _userStyle, + TextBubbleType.ai => _aiStyle, + TextBubbleType.debug => _debugStyle, + }; + + return container( + child: GptMarkdown( + text, + codeBuilder: (context, _, code, _) { + return _CodeBlock(code: code); + }, + ), + ); + } +} + +/// Code block widget with monospace font, padding, and horizontal scroll. +class _CodeBlock extends StatelessWidget { + final String code; + + const _CodeBlock({required this.code}); + + @override + Widget build(BuildContext context) { + final codeContainer = BoxStyler() + .color(FortalTokens.gray2()) + .borderRadiusAll(Radius.circular(8)) + .borderAll(color: FortalTokens.gray4()) + .paddingAll(12); + + return codeContainer( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SelectableText( + code, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: FortalTokens.gray11.resolve(context), + height: 1.5, + ), + ), + ), + ); + } +} diff --git a/packages/genui/lib/src/chat/view/widgets/chat_genui_panels.dart b/packages/genui/lib/src/chat/view/widgets/chat_genui_panels.dart new file mode 100644 index 00000000..8e9fc5e5 --- /dev/null +++ b/packages/genui/lib/src/chat/view/widgets/chat_genui_panels.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; +import 'package:signals/signals_flutter.dart'; +import '../../chat_message.dart'; +import '../../chat_viewmodel.dart'; +import './typing_indicator.dart'; +import '../../../viewmodel_scope.dart'; +import '../../../ui/ui.dart'; + +/// Shared bubble border radius for iOS-style chat bubbles. +const _bubbleBorderRadius = BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomRight: Radius.circular(20), + bottomLeft: Radius.circular(6), +); + +/// Typing indicator bubble - shows only when AI is thinking. +class TypingBubble extends StatelessWidget { + const TypingBubble({super.key}); + + @override + Widget build(BuildContext context) { + final viewModel = context.read(); + final isThinking = viewModel.isThinking.watch(context); + + if (!isThinking) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(left: 48, bottom: 16), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: FortalTokens.gray1.resolve(context), + borderRadius: _bubbleBorderRadius, + boxShadow: [ + BoxShadow( + color: FortalTokens.gray12 + .resolve(context) + .withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const TypingIndicator(), + ), + ), + ); + } +} + +/// Message bubble - shows the last AI message (hidden when thinking). +class GenUiMessageBubble extends StatelessWidget { + const GenUiMessageBubble({super.key}); + + @override + Widget build(BuildContext context) { + final viewModel = context.read(); + final messages = viewModel.messages.watch(context); + final isThinking = viewModel.isThinking.watch(context); + + final lastAiMessage = messages.reversed + .whereType() + .firstOrNull; + + if (isThinking || lastAiMessage == null) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(left: 48, bottom: 16), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 600), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: _bubbleBorderRadius, + boxShadow: [ + BoxShadow( + color: FortalTokens.gray12 + .resolve(context) + .withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: SdBody( + lastAiMessage.text, + style: TextStyler() + .color(Colors.black87) + .style(TextStyleMix(fontSize: 18, height: 1.4)), + ), + ), + ), + ); + } +} + +class LoadingResponse extends StatelessWidget { + const LoadingResponse({super.key}); + + @override + Widget build(BuildContext context) { + final text = TextStyler() + .color(FortalTokens.gray10()) + .style(FortalTokens.text1.mix()); + + final row = FlexBoxStyler().spacing(6); + + return row( + children: [ + SdSpinner(size: FortalSpinnerSize.size1), + text('Thinking...'), + ], + ); + } +} diff --git a/packages/genui/lib/src/chat/view/widgets/chat_input.dart b/packages/genui/lib/src/chat/view/widgets/chat_input.dart new file mode 100644 index 00000000..64d7f758 --- /dev/null +++ b/packages/genui/lib/src/chat/view/widgets/chat_input.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:remix/remix.dart'; +import '../../../ui/ui.dart'; + +/// Shared chat input widget used by both sidebar and inline views. +/// +/// Provides consistent behavior: disabled while thinking, unified hint text, +/// and proper TextInputAction.send for enter-to-submit. +/// Uses Focus.onKeyEvent for explicit Enter key handling on web. +class ChatInput extends StatelessWidget { + final TextEditingController controller; + final bool enabled; + final ValueChanged onSubmitted; + + const ChatInput({ + super.key, + required this.controller, + required this.enabled, + required this.onSubmitted, + }); + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (!enabled) return KeyEventResult.ignored; + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) { + final text = controller.text.trim(); + if (text.isNotEmpty) { + onSubmitted(text); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final trailingStyle = TextStyler() + .color(FortalTokens.gray7()) + .fontSize(14) + .fontWeight(FontWeight.w600); + + return Focus( + onKeyEvent: _handleKeyEvent, + child: SdTextField( + hintText: 'Type a message...', + trailing: trailingStyle('Press Enter'), + textInputAction: TextInputAction.send, + controller: controller, + enabled: enabled, + semanticLabel: 'Chat message input', + onSubmitted: enabled ? onSubmitted : null, + ), + ); + } +} diff --git a/packages/genui/lib/src/chat/view/widgets/chat_scaffold.dart b/packages/genui/lib/src/chat/view/widgets/chat_scaffold.dart new file mode 100644 index 00000000..e2b0a68c --- /dev/null +++ b/packages/genui/lib/src/chat/view/widgets/chat_scaffold.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +/// Two-panel scaffold for chat interface with collapsible trailing panel. +/// +/// Displays a leading widget (typically AI surfaces) and an optional trailing +/// widget (typically chat messages) separated by a divider. The trailing panel +/// can be hidden via [showChat] for fullscreen mode. +class ChatScaffold extends StatelessWidget { + final PreferredSizeWidget? appBar; + final Widget leadingWidget; + final Widget trailingWidget; + final bool showChat; + + const ChatScaffold({ + super.key, + this.appBar, + required this.leadingWidget, + required this.trailingWidget, + this.showChat = true, + }); + + @override + Widget build(BuildContext context) { + final divider = BoxStyler().width(1).color(FortalTokens.gray3()); + + return Scaffold( + appBar: appBar, + backgroundColor: FortalTokens.gray1.resolve(context), + body: FlexBox( + children: [ + Expanded(child: leadingWidget), + if (showChat) ...[divider(), Expanded(child: trailingWidget)], + ], + ), + ); + } +} diff --git a/packages/genui/lib/src/chat/view/widgets/empty_state.dart b/packages/genui/lib/src/chat/view/widgets/empty_state.dart new file mode 100644 index 00000000..0ebbc04c --- /dev/null +++ b/packages/genui/lib/src/chat/view/widgets/empty_state.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; +import '../../../ui/ui.dart'; +import '../../../presentation/view/loading.dart'; + +/// Initial state displayed before conversation starts. +/// +/// Shows a welcome message with the app logo and example prompts +/// to help users get started with presentation generation. +class EmptyState extends StatelessWidget { + const EmptyState({super.key, this.onSuggestionTap}); + + /// Called when a suggestion chip is tapped, with the suggestion text. + final ValueChanged? onSuggestionTap; + + @override + Widget build(BuildContext context) { + // Unique: text3 + gray10 (slightly lighter than SdBody) + final subtitleStyle = TextStyler() + .style(FortalTokens.text3.mix()) + .color(FortalTokens.gray10()); + + final suggestionsContainer = FlexBoxStyler() + .column() + .crossAxisAlignment(.start) + .spacing(12); + + final suggestionChip = BoxStyler() + .padding(.symmetric(horizontal: 16, vertical: 10)) + .color(FortalTokens.gray2()) + .borderAll(color: FortalTokens.grayA3()) + .borderRadiusAll(.circular(12)); + + final suggestionText = TextStyler() + .style(FortalTokens.text2.mix()) + .color(FortalTokens.gray11()); + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 48), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CustomPaint( + size: const Size(80, 80), + painter: IsometricLogoPainter( + colors: [ + FortalTokens.gray11.resolve(context), + FortalTokens.gray11.resolve(context).withValues(alpha: 0.7), + FortalTokens.gray11.resolve(context).withValues(alpha: 0.4), + FortalTokens.gray11.resolve(context).withValues(alpha: 0.2), + ], + ), + ), + const SizedBox(height: 24), + SdHeadline('Create stunning presentations'), + const SizedBox(height: 8), + subtitleStyle( + 'Describe your idea and I\'ll design the slides for you', + ), + const SizedBox(height: 40), + suggestionsContainer( + children: [ + SdHint('Try asking something like:'), + const SizedBox(height: 4), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final suggestion in [ + 'Startup pitch deck', + 'Sales report Q4', + 'Team onboarding', + ]) + GestureDetector( + onTap: () => onSuggestionTap?.call(suggestion), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: suggestionChip( + child: suggestionText('"$suggestion"'), + ), + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/genui/lib/src/chat/view/widgets/model_select.dart b/packages/genui/lib/src/chat/view/widgets/model_select.dart new file mode 100644 index 00000000..ed4eed30 --- /dev/null +++ b/packages/genui/lib/src/chat/view/widgets/model_select.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import '../../../constants/gemini_models.dart'; +import '../../../ui/ui.dart'; + +/// Chat models available for user selection in the UI. +/// +/// Each value maps to a [GeminiModelNames] constant. Only chat-capable models +/// are included here; specialized models (e.g., image generation) are not +/// user-selectable and are used internally by services. +enum GeminiModels { + gemini25Pro(GeminiModelNames.gemini25Pro), + gemini25Flash(GeminiModelNames.gemini25Flash), + gemini25FlashLite(GeminiModelNames.gemini25FlashLite), + gemini3FlashPreview(GeminiModelNames.gemini3FlashPreview); + + const GeminiModels(this.modelPath); + + /// The API model path string (e.g., 'models/gemini-2.5-pro'). + final String modelPath; + + /// The default model for the app. + static const defaultValue = GeminiModels.gemini3FlashPreview; + + String get formattedName => switch (this) { + GeminiModels.gemini25Pro => 'Gemini 2.5 Pro', + GeminiModels.gemini25Flash => 'Gemini 2.5 Flash', + GeminiModels.gemini25FlashLite => 'Gemini 2.5 Lite', + GeminiModels.gemini3FlashPreview => 'Gemini 3 Flash', + }; +} + +/// Dropdown selector for choosing the Gemini model. +/// +/// Displays available models with formatted names and allows selection. +/// Can be disabled during active conversations. +class ModelsSelect extends StatelessWidget { + final GeminiModels selectedValue; + final Function(GeminiModels) onChanged; + final bool enabled; + + const ModelsSelect({ + super.key, + required this.selectedValue, + required this.onChanged, + required this.enabled, + }); + + @override + Widget build(BuildContext context) { + return SdSelect( + enabled: enabled, + selectedValue: selectedValue, + onChanged: (value) => onChanged(value!), + placeholder: 'Select a model', + icon: enabled ? null : Icons.lock_outline, + items: GeminiModels.values + .map((e) => SdSelectItem(label: e.formattedName, value: e)) + .toList(), + ); + } +} diff --git a/packages/genui/lib/src/chat/view/widgets/typing_indicator.dart b/packages/genui/lib/src/chat/view/widgets/typing_indicator.dart new file mode 100644 index 00000000..1f35bea6 --- /dev/null +++ b/packages/genui/lib/src/chat/view/widgets/typing_indicator.dart @@ -0,0 +1,64 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +/// Animated typing indicator with three bouncing dots. +class TypingIndicator extends StatefulWidget { + const TypingIndicator({super.key}); + + @override + State createState() => _TypingIndicatorState(); +} + +class _TypingIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final dotColor = FortalTokens.gray9.resolve(context); + + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (index) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + // Stagger each dot's animation + final delay = index * 0.15; + final progress = (_controller.value + delay) % 1.0; + // Use sine wave for smooth bounce + final bounce = math.sin(progress * math.pi); + + return Transform.translate( + offset: Offset(0, -bounce * 5), + child: Opacity(opacity: 0.4 + (bounce * 0.6), child: child), + ); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + width: 10, + height: 10, + decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), + ), + ); + }), + ); + } +} diff --git a/packages/genui/lib/src/constants/error_messages.dart b/packages/genui/lib/src/constants/error_messages.dart new file mode 100644 index 00000000..310d7c7e --- /dev/null +++ b/packages/genui/lib/src/constants/error_messages.dart @@ -0,0 +1,76 @@ +/// Centralized user-facing error messages. +/// +/// All user-visible error messages should be defined here to ensure: +/// - Consistent messaging across the application +/// - Easy localization in the future +/// - Single source of truth for error text +abstract final class ErrorMessages { + // --------------------------------------------------------------------------- + // Configuration Errors + // --------------------------------------------------------------------------- + + /// API key is missing or not configured. + static const apiKeyMissing = + 'Unable to start conversation. Please check your API key configuration.'; + + /// API key configuration help message (for developers). + static const apiKeyHelp = + 'GOOGLE_AI_API_KEY not configured. Use --dart-define=GOOGLE_AI_API_KEY=xxx or create .env file.'; + + /// API key not configured (short form). + static const apiKeyNotConfigured = 'API key not configured'; + + // --------------------------------------------------------------------------- + // Initialization Errors + // --------------------------------------------------------------------------- + + /// Prompt assets failed to load. + static const promptLoadFailed = + 'Unable to load conversation prompts. Please restart the app.'; + + /// Generic conversation initialization failure. + static const conversationInitFailed = + 'Failed to initialize conversation. Please try again.'; + + // --------------------------------------------------------------------------- + // Generation Errors + // --------------------------------------------------------------------------- + + /// Outline generation failed. + static const outlineGenerationFailed = + 'Failed to generate presentation outline. Please try again.'; + + /// Final deck generation failed. + static const deckGenerationFailed = + 'Failed to generate final presentation. Please try again.'; + + /// No slides were generated. + static const noSlidesGenerated = 'No slides generated'; + + /// Generic generation failure. + static const generationFailed = 'Generation failed. Please try again.'; + + /// Image preview generation failed. + static const imagePreviewFailed = 'Failed to generate previews'; + + // --------------------------------------------------------------------------- + // Regeneration Errors + // --------------------------------------------------------------------------- + + /// No previous prompt exists for regeneration. + static const noPreviousPrompt = + 'No previous prompt found. Complete the wizard at least once.'; + + /// Previous prompt file is empty. + static const emptyPromptFile = 'Previous prompt file is empty.'; + + // --------------------------------------------------------------------------- + // Fallback Messages + // --------------------------------------------------------------------------- + + /// Generic unexpected error fallback. + static const unexpectedError = 'An unexpected error occurred'; + + /// Unknown error fallback. + static const unknownError = 'Unknown error'; +} diff --git a/packages/genui/lib/src/constants/gemini_models.dart b/packages/genui/lib/src/constants/gemini_models.dart new file mode 100644 index 00000000..a39d592b --- /dev/null +++ b/packages/genui/lib/src/constants/gemini_models.dart @@ -0,0 +1,19 @@ +/// All Gemini model API path strings. +/// +/// Contains both chat models (used in [GeminiModels] enum for UI selection) +/// and specialized models (e.g., image generation) for internal service use. +/// +/// Chat models displayed in selector: [gemini25Pro], [gemini25Flash], +/// [gemini25FlashLite], [gemini3FlashPreview] +/// +/// Specialized models (internal use only): [gemini25FlashImage] +abstract final class GeminiModelNames { + // -- Chat models (appear in GeminiModels enum / UI selector) -- + static const gemini25Pro = 'models/gemini-2.5-pro'; + static const gemini25Flash = 'models/gemini-2.5-flash'; + static const gemini25FlashLite = 'models/gemini-2.5-flash-lite'; + static const gemini3FlashPreview = 'models/gemini-3-flash-preview'; + + // -- Specialized models (internal service use only) -- + static const gemini25FlashImage = 'models/gemini-2.5-flash-image'; +} diff --git a/packages/genui/lib/src/constants/paths.dart b/packages/genui/lib/src/constants/paths.dart new file mode 100644 index 00000000..bfffa37d --- /dev/null +++ b/packages/genui/lib/src/constants/paths.dart @@ -0,0 +1,64 @@ +import '../path_service.dart'; + +/// Centralized file and directory path constants. +/// +/// Runtime paths (for file I/O) delegate to [PathService] for platform-aware +/// resolution. Asset paths (bundled in Flutter) remain as static constants. +abstract final class Paths { + // --------------------------------------------------------------------------- + // Runtime paths - delegate to PathService for platform-aware resolution + // --------------------------------------------------------------------------- + + /// Root directory for SuperDeck output files. + static String get superdeckDir => PathService.instance.superdeckDir; + + /// Full path to assets directory. + static String get superdeckAssetsPath => PathService.instance.assetsPath; + + /// Full path to deck JSON file. + static String get deckJsonPath => PathService.instance.deckJsonPath; + + /// Full path to last prompt file. + static String get lastPromptPath => PathService.instance.lastPromptPath; + + /// Full path to last generation metadata (prompt + parameters). + static String get lastGenerationPath => + PathService.instance.lastGenerationPath; + + /// Full path to debug log. + static String get debugLogPath => PathService.instance.debugLogPath; + + /// Runtime directory for example prompt/result pairs (filesystem I/O). + /// For asset bundle lookups, use [examplesAssetsDir] instead. + static String get examplesDir => PathService.instance.examplesDir; + + // --------------------------------------------------------------------------- + // File names - for reference only + // --------------------------------------------------------------------------- + + /// Assets subdirectory name. + static const assetsDir = 'assets'; + + /// Main deck JSON output file name. + static const deckJsonFile = 'superdeck.json'; + + /// Debug file for the last prompt sent to the AI. + static const lastPromptFile = 'last_prompt.txt'; + + /// Debug log file name. + static const debugLogFile = 'debug.log'; + + // --------------------------------------------------------------------------- + // Asset paths - bundled in Flutter, remain static constants + // These are used for AssetManifest lookups and must match pubspec.yaml entries + // --------------------------------------------------------------------------- + + /// Directory for prompt templates (Flutter assets). + /// Uses the `packages/` prefix required for assets bundled in Flutter packages. + static const promptsDir = 'packages/superdeck_genui/assets/prompts/'; + + /// Directory for example prompt/result pairs (Flutter assets). + /// Used for AssetManifest lookups - NOT for filesystem I/O. + /// Uses the `packages/` prefix required for assets bundled in Flutter packages. + static const examplesAssetsDir = 'packages/superdeck_genui/assets/examples/'; +} diff --git a/packages/genui/lib/src/debug_logger.dart b/packages/genui/lib/src/debug_logger.dart new file mode 100644 index 00000000..fbf2aaa0 --- /dev/null +++ b/packages/genui/lib/src/debug_logger.dart @@ -0,0 +1,158 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import './constants/paths.dart'; + +/// Debug file logger for tracking GenUI workflow events. +/// +/// Writes logs to `.superdeck/debug.log` when in debug mode. +/// Uses buffered async writing to avoid blocking the UI thread. +/// Creates a new file on each app run (clears previous logs). +class DebugLogger { + static final _instance = DebugLogger._internal(); + static DebugLogger get instance => _instance; + + DebugLogger._internal(); + + File? _logFile; + bool _initialized = false; + bool _fileLoggingEnabled = false; + + /// Buffer for pending log lines to reduce file I/O. + final _buffer = StringBuffer(); + + /// Timer for periodic buffer flushing. + Timer? _flushTimer; + + /// Guard to prevent concurrent flush operations. + bool _flushing = false; + + /// Maximum buffer size before forcing a flush. + static const _maxBufferSize = 4096; + + /// Flush interval in milliseconds. + static const _flushInterval = Duration(milliseconds: 500); + + /// Initialize the logger. Clears existing log file. + /// Disabled on web platforms (dart:io not supported). + Future init() async { + if (!kDebugMode || kIsWeb) return; + if (_initialized) return; + + try { + final dir = Directory(Paths.superdeckDir); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + _logFile = File(Paths.debugLogPath); + + // Clear file on each run + await _logFile!.writeAsString( + '=== SuperDeck AI Debug Log ===\n' + 'Started: ${DateTime.now().toIso8601String()}\n' + '${'=' * 40}\n\n', + ); + + _fileLoggingEnabled = true; + + // Start periodic flush timer + _flushTimer = Timer.periodic(_flushInterval, (_) => _flushBuffer()); + } catch (e) { + _logFile = null; + _fileLoggingEnabled = false; + debugPrint('[DebugLogger] File logging disabled: $e'); + } finally { + _initialized = true; + } + } + + /// Flush the buffer to file asynchronously. + Future _flushBuffer() async { + // Prevent concurrent flush operations + if (_flushing || _buffer.isEmpty || _logFile == null) return; + + _flushing = true; + final content = _buffer.toString(); + _buffer.clear(); + + try { + await _logFile!.writeAsString(content, mode: FileMode.append); + } catch (_) { + // Ignore file write errors in debug logging + } finally { + _flushing = false; + } + } + + /// Add a line to the buffer and flush if needed. + void _appendToBuffer(String line) { + _buffer.write(line); + + // Flush immediately if buffer is too large + if (_buffer.length > _maxBufferSize) { + _flushBuffer(); + } + } + + /// Log a message with timestamp and category. + void log(String category, String message) { + if (!kDebugMode || !_initialized) return; + + final timestamp = DateTime.now().toIso8601String().substring(11, 23); + final line = '[$timestamp] [$category] $message\n'; + + if (_fileLoggingEnabled) { + _appendToBuffer(line); + } + debugPrint(line.trimRight()); + } + + /// Log a surface event + void surface(String event, String surfaceId) { + log('SURFACE', '$event: $surfaceId'); + } + + /// Log user action + void userAction(String action, [Map? context]) { + final contextStr = context != null ? ' | $context' : ''; + log('USER', '$action$contextStr'); + } + + /// Log AI response + void aiResponse(String type, String content) { + final truncated = content.length > 100 + ? '${content.substring(0, 100)}...' + : content; + log('AI', '$type: $truncated'); + } + + /// Log error + void error(String source, dynamic error, [StackTrace? stack]) { + log('ERROR', '$source: $error'); + if (stack != null) { + log('STACK', stack.toString().split('\n').take(5).join('\n')); + } + } + + /// Add a section separator + void section(String title) { + if (!kDebugMode || !_initialized) return; + final line = '\n--- $title ---\n'; + if (_fileLoggingEnabled) { + _appendToBuffer(line); + } + debugPrint(line.trimRight()); + } + + /// Dispose of the logger, flushing any remaining buffer. + Future dispose() async { + _flushTimer?.cancel(); + _flushTimer = null; + await _flushBuffer(); + } +} + +/// Global shortcut for logging +DebugLogger get debugLog => DebugLogger.instance; diff --git a/packages/genui/lib/src/env_config.dart b/packages/genui/lib/src/env_config.dart new file mode 100644 index 00000000..cc66fb18 --- /dev/null +++ b/packages/genui/lib/src/env_config.dart @@ -0,0 +1,50 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +/// Centralized access to environment configuration. +/// +/// Supports two configuration methods: +/// 1. Build-time: `--dart-define=GOOGLE_AI_API_KEY=xxx` (recommended for production) +/// 2. Runtime: `.env` file via flutter_dotenv (for development only) +/// +/// Build-time configuration takes precedence over .env file. +abstract final class EnvConfig { + /// API key from --dart-define (compile-time injection). + static const _dartDefineKey = String.fromEnvironment('GOOGLE_AI_API_KEY'); + + /// Google AI API key for Gemini models. + /// + /// Checks --dart-define first, then falls back to .env file. + /// Throws if the key is not defined in either location. + static String get geminiApiKey { + // Prefer build-time injection (--dart-define) + if (_dartDefineKey.isNotEmpty) { + return _dartDefineKey; + } + + // Fall back to .env file (development) + try { + final key = dotenv.env['GOOGLE_AI_API_KEY']; + if (key != null && key.isNotEmpty) { + return key; + } + } catch (_) { + // dotenv not initialized - fall through to error + } + + throw StateError( + 'GOOGLE_AI_API_KEY not configured. ' + 'Use --dart-define=GOOGLE_AI_API_KEY=xxx or create .env file.', + ); + } + + /// Check if the API key is configured. + static bool get hasGeminiApiKey { + if (_dartDefineKey.isNotEmpty) return true; + try { + final key = dotenv.env['GOOGLE_AI_API_KEY']; + return key != null && key.isNotEmpty; + } catch (_) { + return false; + } + } +} diff --git a/packages/genui/lib/src/navigation/app_navigator_key.dart b/packages/genui/lib/src/navigation/app_navigator_key.dart new file mode 100644 index 00000000..94528e1f --- /dev/null +++ b/packages/genui/lib/src/navigation/app_navigator_key.dart @@ -0,0 +1,3 @@ +import 'package:flutter/widgets.dart'; + +final appNavigatorKey = GlobalKey(); diff --git a/packages/genui/lib/src/path_service.dart b/packages/genui/lib/src/path_service.dart new file mode 100644 index 00000000..fd808b92 --- /dev/null +++ b/packages/genui/lib/src/path_service.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Platform-aware path resolution service. +/// +/// Resolves the SuperDeck storage directory to: +/// - Desktop: cwd/.superdeck (matches SuperDeck viewer's DeckConfiguration) +/// - Mobile/sandboxed: getApplicationSupportDirectory()/.superdeck +/// +/// Must be initialized at app startup via [initialize()]. +class PathService { + PathService._(); + + static final instance = PathService._(); + + String? _baseDir; + bool _initialized = false; + + bool get isInitialized => _initialized; + + /// Initializes the service. Must be called at app startup. + /// + /// On desktop (macOS/Linux/Windows), uses CWD-relative `.superdeck/` to + /// match the SuperDeck viewer's path resolution. On mobile/sandboxed + /// platforms, uses Application Support directory. + Future initialize() async { + if (_initialized) return; + + if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { + _baseDir = '.superdeck'; + } else { + try { + final appSupportDir = await getApplicationSupportDirectory(); + _baseDir = p.join(appSupportDir.path, '.superdeck'); + } catch (_) { + _baseDir = '.superdeck'; + } + } + + _initialized = true; + } + + /// Root directory for SuperDeck output files. + String get superdeckDir { + _ensureInitialized(); + return _baseDir!; + } + + /// Full path to assets directory. + String get assetsPath => p.join(superdeckDir, 'assets'); + + /// Full path to deck JSON file. + String get deckJsonPath => p.join(superdeckDir, 'superdeck.json'); + + /// Full path to last prompt file. + String get lastPromptPath => p.join(superdeckDir, 'last_prompt.txt'); + + /// Full path to last generation metadata (prompt + parameters). + String get lastGenerationPath => p.join(superdeckDir, 'last_generation.json'); + + /// Full path to debug log. + String get debugLogPath => p.join(superdeckDir, 'debug.log'); + + /// Directory for example prompt/result pairs. + String get examplesDir => p.join(superdeckDir, 'examples'); + + void _ensureInitialized() { + if (!_initialized) { + throw StateError( + 'PathService not initialized. Call PathService.instance.initialize() at app startup.', + ); + } + } + + /// Resets for testing. + void resetForTest() { + _baseDir = null; + _initialized = false; + } + + /// Sets a custom base directory for testing. + void setBaseDirForTest(String dir) { + _baseDir = dir; + _initialized = true; + } +} diff --git a/packages/genui/lib/src/presentation/presentation_viewmodel.dart b/packages/genui/lib/src/presentation/presentation_viewmodel.dart new file mode 100644 index 00000000..f85f1f44 --- /dev/null +++ b/packages/genui/lib/src/presentation/presentation_viewmodel.dart @@ -0,0 +1,223 @@ +import 'dart:typed_data'; + +import 'package:signals/signals_flutter.dart'; +import '../ai/wizard_context.dart'; +import '../ai/services/deck_generator_service.dart'; +import '../ai/services/generation_progress.dart'; +import '../viewmodel_scope.dart'; +import '../debug_logger.dart'; + +/// The status of presentation generation. +/// +/// - [idle]: No generation in progress +/// - [generating]: Actively generating presentation +/// - [preview]: Generation complete, showing thumbnail previews +/// - [success]: Preview accepted, ready to view presentation +/// - [error]: Generation failed with error +enum GenerationStatus { idle, generating, preview, success, error } + +/// Callback for presentation generation with progress support. +typedef GenerationCallback = + Future Function( + WizardContext context, + GenerationProgressCallback? onProgress, + ); + +/// ViewModel managing the state of presentation generation. +/// +/// This follows the same pattern as [ChatViewModel], using Signals for +/// reactive state management. It survives route changes and handles +/// edge cases like refresh and deep-linking. +class PresentationViewModel implements Disposable { + final _status = Signal(GenerationStatus.idle); + final _result = Signal(null); + final _error = Signal(null); + final _phase = Signal(GenerationPhase.idle); + final _imageProgress = Signal(null); + final _thumbnailPreviews = Signal>(const []); + + /// Mutable backing list — signal publishes an unmodifiable view on each add. + final _thumbnailList = <(int, Uint8List)>[]; + + /// Epoch counter for thumbnail generation cancellation. + /// + /// Incremented when a new generation starts or the ViewModel is reset, + /// causing in-flight thumbnail captures from a previous epoch to be ignored. + int _thumbnailEpoch = 0; + + // Store context for retry + WizardContext? _lastContext; + GenerationCallback? _callback; + + /// Current generation status. + late final Computed status = computed(() => _status.value); + + /// The result of the last generation attempt. + late final Computed result = computed( + () => _result.value, + ); + + /// Error message if generation failed. + late final Computed error = computed(() => _error.value); + + /// Current generation phase for progress display. + late final Computed phase = computed(() => _phase.value); + + /// Image generation progress (when in generatingImages phase). + late final Computed imageProgress = computed( + () => _imageProgress.value, + ); + + /// Thumbnail preview images generated after deck creation. + late final Computed> thumbnailPreviews = computed( + () => _thumbnailPreviews.value, + ); + + /// Current thumbnail generation epoch. + /// + /// Used by the UI layer to detect cancellation: if the epoch has changed + /// since thumbnail generation started, in-flight results should be discarded. + int get thumbnailEpoch => _thumbnailEpoch; + + /// User-friendly progress message based on current phase. + late final Computed progressMessage = computed(() { + final p = _imageProgress.value; + return switch (_phase.value) { + GenerationPhase.idle => '', + GenerationPhase.generatingOutline => 'Planning presentation structure...', + GenerationPhase.generatingImages => + p != null && p.total > 0 + ? 'Generating illustrations (${p.completed}/${p.total})...' + : 'Generating illustrations...', + GenerationPhase.generatingFinalDeck => 'Building your presentation...', + GenerationPhase.finalizing => 'Finalizing presentation...', + GenerationPhase.generatingThumbnails => 'Generating slide previews...', + }; + }); + + /// Starts presentation generation. + /// + /// Clears previous state and sets status to [GenerationStatus.generating], + /// then transitions to [GenerationStatus.preview] or [GenerationStatus.error] + /// based on the result. The preview state allows thumbnail generation before + /// navigating to the full presentation. + /// + /// The [callback] receives progress updates via [GenerationProgressCallback]. + Future generate({ + required WizardContext context, + required GenerationCallback callback, + }) async { + _lastContext = context; + _callback = callback; + _result.value = null; + _error.value = null; + _phase.value = GenerationPhase.idle; + _imageProgress.value = null; + _clearThumbnails(); + _status.value = GenerationStatus.generating; + + try { + final result = await callback(context, _onProgress); + _result.value = result; + _imageProgress.value = null; + if (result.success) { + // Enter preview state - thumbnails will be generated by the UI layer + _phase.value = GenerationPhase.generatingThumbnails; + _status.value = GenerationStatus.preview; + } else { + _phase.value = GenerationPhase.idle; + _status.value = GenerationStatus.error; + _error.value = result.error; + } + } catch (e, stack) { + debugLog.error('PRESENTATION', e, stack); + _phase.value = GenerationPhase.idle; + _imageProgress.value = null; + _status.value = GenerationStatus.error; + _error.value = e.toString(); + } + } + + /// Handles progress updates from DeckGeneratorService. + void _onProgress( + GenerationPhase phase, + ImageGenerationProgress? imageProgress, + ) { + _phase.value = phase; + _imageProgress.value = imageProgress; + } + + /// Adds a thumbnail preview image if the [epoch] still matches. + /// + /// Called by the UI layer as each slide thumbnail is captured. + /// Thumbnails from a cancelled (stale) epoch are silently ignored, + /// preventing race conditions when a new generation starts while + /// thumbnails from a previous generation are still in-flight. + void addThumbnailPreview( + int slideIndex, + Uint8List imageBytes, { + required int epoch, + }) { + if (epoch != _thumbnailEpoch) return; + _thumbnailList.add((slideIndex, imageBytes)); + _thumbnailPreviews.value = List.unmodifiable(_thumbnailList); + } + + /// Marks thumbnail generation as complete for the given [epoch]. + /// + /// Ignored if the epoch is stale (a new generation has started). + void finishThumbnailGeneration({required int epoch}) { + if (epoch != _thumbnailEpoch) return; + _phase.value = GenerationPhase.idle; + if (_thumbnailList.isEmpty) { + // All captures failed - skip preview and proceed automatically. + _status.value = GenerationStatus.success; + } + } + + /// Proceeds from preview to the full presentation view. + void proceedToPresentation() { + _phase.value = GenerationPhase.idle; + _status.value = GenerationStatus.success; + } + + /// Retries the last generation attempt. + /// + /// Does nothing if no previous generation context exists. + Future retry() async { + if (_lastContext != null && _callback != null) { + await generate(context: _lastContext!, callback: _callback!); + } + } + + /// Resets the ViewModel to idle state. + /// + /// Use this for cancellation (returning to idle without generating). + /// Not needed before [generate] - it clears state automatically. + void reset() { + _status.value = GenerationStatus.idle; + _result.value = null; + _error.value = null; + _phase.value = GenerationPhase.idle; + _imageProgress.value = null; + _clearThumbnails(); + } + + /// Clears thumbnails and increments epoch to cancel in-flight captures. + void _clearThumbnails() { + _thumbnailEpoch++; + _thumbnailList.clear(); + _thumbnailPreviews.value = const []; + } + + /// Disposes all Signals owned by this ViewModel. + @override + void dispose() { + _status.dispose(); + _result.dispose(); + _error.dispose(); + _phase.dispose(); + _imageProgress.dispose(); + _thumbnailPreviews.dispose(); + } +} diff --git a/packages/genui/lib/src/presentation/thumbnail_preview_service.dart b/packages/genui/lib/src/presentation/thumbnail_preview_service.dart new file mode 100644 index 00000000..2c4ff9a8 --- /dev/null +++ b/packages/genui/lib/src/presentation/thumbnail_preview_service.dart @@ -0,0 +1,121 @@ +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:superdeck/superdeck.dart'; +// ignore: implementation_imports +import 'package:superdeck/src/deck/slide_configuration_builder.dart'; +// ignore: implementation_imports +import 'package:superdeck/src/export/slide_capture_service.dart'; +import '../ai/schemas/deck_schemas.dart'; +import '../debug_logger.dart'; +import '../utils/style_builder.dart'; + +/// Callback invoked as each slide thumbnail is captured. +typedef ThumbnailCapturedCallback = + void Function(int slideIndex, Uint8List imageBytes); + +/// Function signature for capturing a single slide as a PNG image. +/// +/// Abstracted to allow test injection without depending on +/// [SlideCaptureService] (which is not part of SuperDeck's public API). +typedef SlideCaptureFn = + Future Function(SlideConfiguration slide, BuildContext context); + +/// Service for generating slide thumbnail previews from slide data. +/// +/// Accepts slides directly (no disk I/O), builds [SlideConfiguration]s, +/// and uses [SlideCaptureService] to render each slide offscreen at +/// thumbnail quality. +/// +/// The capture function can be injected via [captureSlide] for testing. +class ThumbnailPreviewService { + ThumbnailPreviewService({SlideCaptureFn? captureSlide}) + : _captureSlide = captureSlide; + + final SlideCaptureFn? _captureSlide; + + /// Lazily created default capture service instance. + SlideCaptureService? _defaultService; + + Future _capture(SlideConfiguration slide, BuildContext context) { + if (_captureSlide case final fn?) return fn(slide, context); + _defaultService ??= SlideCaptureService(); + return _defaultService!.capture( + slide: slide, + context: context, + quality: SlideCaptureQuality.thumbnail, + ); + } + + /// Generates thumbnail previews for the given [slides]. + /// + /// Builds slide configurations with the provided [style], then captures + /// each slide as a thumbnail-quality PNG. + /// + /// Calls [onThumbnailCaptured] as each slide is captured, allowing the UI + /// to display thumbnails incrementally. + /// + /// Checks [isCancelled] before each capture to support cancellation. + /// Stops early if the [context] is unmounted. + /// + /// Returns the list of captured thumbnails with their original slide index. + Future> generatePreviews({ + required BuildContext context, + required List slides, + DeckStyleType? style, + ThumbnailCapturedCallback? onThumbnailCaptured, + bool Function()? isCancelled, + }) async { + debugLog.section('Thumbnail Preview Generation'); + + if (slides.isEmpty) return []; + + final configuration = DeckConfiguration(); + final options = buildDeckOptionsFromStyle(style); + final slideBuilder = SlideConfigurationBuilder( + configuration: configuration, + ); + final slideConfigs = slideBuilder.buildConfigurations(slides, options); + + debugLog.log( + 'THUMBNAIL', + 'Generating previews for ${slideConfigs.length} slides', + ); + + final thumbnails = <(int, Uint8List)>[]; + + for (var i = 0; i < slideConfigs.length; i++) { + if (isCancelled?.call() ?? false) { + debugLog.log('THUMBNAIL', 'Generation cancelled at slide $i'); + break; + } + + if (!context.mounted) { + debugLog.log('THUMBNAIL', 'Context unmounted, stopping at slide $i'); + break; + } + + try { + final imageBytes = await _capture(slideConfigs[i], context); + thumbnails.add((i, imageBytes)); + onThumbnailCaptured?.call(i, imageBytes); + + debugLog.log( + 'THUMBNAIL', + 'Captured slide $i/${slideConfigs.length} ' + '(${imageBytes.length} bytes)', + ); + } catch (e) { + debugLog.log('THUMBNAIL', 'Failed to capture slide $i: $e'); + } + } + + debugLog.log( + 'THUMBNAIL', + 'Preview generation complete: ' + '${thumbnails.length}/${slideConfigs.length} slides captured', + ); + + return thumbnails; + } +} diff --git a/packages/genui/lib/src/presentation/view/creating_presentation_screen.dart b/packages/genui/lib/src/presentation/view/creating_presentation_screen.dart new file mode 100644 index 00000000..08ed13e7 --- /dev/null +++ b/packages/genui/lib/src/presentation/view/creating_presentation_screen.dart @@ -0,0 +1,458 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:remix/remix.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:superdeck/superdeck.dart'; +import '../../debug_logger.dart'; +import '../../viewmodel_scope.dart'; +import '../../routes.dart'; +import '../../ui/ui.dart'; +import '../../utils/deck_style_service.dart'; +import './loading.dart'; +import '../../ai/services/generation_progress.dart'; +import '../presentation_viewmodel.dart'; +import '../thumbnail_preview_service.dart'; + +/// Rotating phrases shown while slides are being generated. +const _loadingPhrases = [ + 'Building the content...', + 'Designing your slides...', + 'Crafting your presentation...', + 'Bringing your ideas to life...', + 'Generating visuals...', + 'Composing your message...', + 'Arranging elements...', + 'Preparing your deck...', + 'Making it presentable...', + 'Polishing your slides...', +]; + +/// Loading screen displayed during presentation generation. +/// +/// Shows an animated loading indicator with rotating status messages +/// while the AI generates the presentation. After generation succeeds, +/// shows a thumbnail preview grid before navigating to the full viewer. +class CreatingPresentationScreen extends StatefulWidget { + const CreatingPresentationScreen({super.key}); + + @override + State createState() => + _CreatingPresentationScreenState(); +} + +class _CreatingPresentationScreenState + extends State { + int _currentPhraseIndex = 0; + Timer? _timer; + + /// Guards against multiple navigation callbacks being scheduled. + bool _navigated = false; + + /// The epoch at which thumbnail generation was started, or null if not started. + /// Compared against viewModel.thumbnailEpoch to detect cancellation. + int? _thumbnailEpoch; + + @override + void initState() { + super.initState(); + _startPhraseTimer(); + } + + void _startPhraseTimer() { + _timer = Timer.periodic(const Duration(seconds: 3), (_) { + setState(() { + _currentPhraseIndex++; + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + _timer = null; + super.dispose(); + } + + void _startThumbnailGeneration(PresentationViewModel viewModel) { + final epoch = viewModel.thumbnailEpoch; + if (_thumbnailEpoch == epoch) return; + _thumbnailEpoch = epoch; + + final style = viewModel.result.value?.style; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + final generationContext = context; + if (!generationContext.mounted) return; + try { + // Load the deck that was just written by the generation pipeline + final configuration = DeckConfiguration(); + final deckService = DeckService(configuration: configuration); + final deck = await deckService.loadDeck(); + + if (!generationContext.mounted || viewModel.thumbnailEpoch != epoch) { + return; + } + + final service = ThumbnailPreviewService(); + await service.generatePreviews( + context: generationContext, + slides: deck.slides, + style: style, + onThumbnailCaptured: (slideIndex, imageBytes) { + viewModel.addThumbnailPreview(slideIndex, imageBytes, epoch: epoch); + }, + isCancelled: () => + !generationContext.mounted || viewModel.thumbnailEpoch != epoch, + ); + if (generationContext.mounted && viewModel.thumbnailEpoch == epoch) { + viewModel.finishThumbnailGeneration(epoch: epoch); + } + } catch (e, stack) { + debugLog.error('THUMBNAIL', e, stack); + if (generationContext.mounted && viewModel.thumbnailEpoch == epoch) { + viewModel.finishThumbnailGeneration(epoch: epoch); + } + } + }); + } + + @override + Widget build(BuildContext context) { + final viewModel = context.read(); + + return Watch((context) { + final status = viewModel.status.value; + + switch (status) { + case GenerationStatus.success: + // Navigate on next frame to avoid build-during-build + // Use _navigated guard to prevent multiple callbacks being queued + if (!_navigated) { + _navigated = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final style = viewModel.result.value?.style; + DeckStyleService.setStyle(style); + context.go(GenUiRoutes.presentation, extra: {'style': style}); + } + }); + } + return _buildLoadingUI(context); + + case GenerationStatus.preview: + _startThumbnailGeneration(viewModel); + return _buildPreviewUI(context, viewModel); + + case GenerationStatus.error: + return _buildErrorUI( + context, + error: viewModel.error.value, + onRetry: () { + _navigated = false; + _thumbnailEpoch = null; + viewModel.retry(); + }, + onCancel: () { + viewModel.reset(); + context.go(GenUiRoutes.chat); + }, + ); + + case GenerationStatus.generating: + return _buildLoadingUI(context); + + case GenerationStatus.idle: + // If idle, redirect to chat - reset is handled before generation starts + // Use _navigated guard to prevent multiple callbacks being queued + if (!_navigated) { + _navigated = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.go(GenUiRoutes.chat); + } + }); + } + return _buildLoadingUI(context); + } + }); + } + + Widget _buildPreviewUI( + BuildContext context, + PresentationViewModel viewModel, + ) { + return Scaffold( + backgroundColor: FortalTokens.gray1.resolve(context), + body: Column( + children: [ + // Header with title and action button + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: Row( + children: [ + Expanded( + child: Watch((context) { + final thumbnails = viewModel.thumbnailPreviews.value; + final phase = viewModel.phase.value; + final isGenerating = + phase == GenerationPhase.generatingThumbnails; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SdHeadline('Presentation Preview'), + const SizedBox(height: 4), + Text( + isGenerating + ? 'Generating previews (${thumbnails.length})...' + : '${thumbnails.length} slides ready', + style: TextStyle( + color: FortalTokens.gray9.resolve(context), + fontSize: 14, + ), + ), + ], + ); + }), + ), + Watch((context) { + final thumbnails = viewModel.thumbnailPreviews.value; + return SdButton( + label: 'View Presentation', + icon: Icons.slideshow, + onPressed: thumbnails.isNotEmpty + ? () => viewModel.proceedToPresentation() + : null, + ); + }), + ], + ), + ), + + // Thumbnail grid + Expanded( + child: Watch((context) { + final thumbnails = viewModel.thumbnailPreviews.value; + final phase = viewModel.phase.value; + final isGenerating = + phase == GenerationPhase.generatingThumbnails; + + if (thumbnails.isEmpty && isGenerating) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IsometricLoading( + color: FortalTokens.gray8.resolve(context), + ), + const SizedBox(height: 16), + Text( + 'Rendering slide previews...', + style: TextStyle( + color: FortalTokens.gray9.resolve(context), + fontSize: 14, + ), + ), + ], + ), + ); + } + + return GridView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 8, + ), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 400, + childAspectRatio: 16 / 9, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: thumbnails.length + (isGenerating ? 1 : 0), + itemBuilder: (context, index) { + // Loading placeholder for the next slide being generated + if (index >= thumbnails.length) { + return Container( + decoration: BoxDecoration( + color: FortalTokens.gray3.resolve(context), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: SizedBox( + width: 40, + height: 40, + child: IsometricLoading( + color: FortalTokens.gray6.resolve(context), + ), + ), + ), + ); + } + + final (slideIndex, imageBytes) = thumbnails[index]; + return _ThumbnailPreviewCard( + imageBytes: imageBytes, + slideIndex: slideIndex, + ); + }, + ); + }), + ), + ], + ), + ); + } + + Widget _buildLoadingUI(BuildContext context) { + final viewModel = context.read(); + final flexBoxStyler = StackBoxStyler().alignment(.center).paddingAll(24); + + final sentence = TextStyler() + .style(FortalTokens.text2.mix()) + .color(FortalTokens.gray9()) + .wrap( + WidgetModifierConfig.box( + BoxStyler() + .padding(.horizontal(12).vertical(6)) + .borderRadius(.circular(6)) + .color(FortalTokens.gray3()), + ), + ); + + return Scaffold( + backgroundColor: FortalTokens.gray1.resolve(context), + body: flexBoxStyler( + children: [ + Center( + child: IsometricLoading(color: FortalTokens.gray8.resolve(context)), + ), + Align( + alignment: .bottomCenter, + child: Watch((context) { + // Use progress message from ViewModel, fall back to rotating phrases + final progressMsg = viewModel.progressMessage.value; + final displayText = progressMsg.isNotEmpty + ? progressMsg + : _loadingPhrases[_currentPhraseIndex % + _loadingPhrases.length]; + return sentence(displayText); + }), + ), + ], + ), + ); + } + + Widget _buildErrorUI( + BuildContext context, { + String? error, + VoidCallback? onRetry, + VoidCallback? onCancel, + }) { + final container = FlexBoxStyler() + .column() + .mainAxisAlignment(.center) + .crossAxisAlignment(.center) + .spacing(24) + .paddingAll(48); + + final message = TextStyler() + .style(FortalTokens.text3.mix()) + .color(FortalTokens.gray11()) + .textAlign(.center); + + final buttonRow = FlexBoxStyler().row().spacing(16); + + return Scaffold( + backgroundColor: FortalTokens.gray1.resolve(context), + body: Center( + child: container( + children: [ + Icon( + Icons.error_outline, + size: 64, + color: FortalTokens.gray8.resolve(context), + ), + SdHeadline('Generation Failed'), + message(error ?? 'An unexpected error occurred'), + buttonRow( + children: [ + SdButton( + label: 'Go Back', + icon: Icons.arrow_back, + onPressed: onCancel, + ), + SdButton( + label: 'Retry', + icon: Icons.refresh, + onPressed: onRetry, + ), + ], + ), + ], + ), + ), + ); + } +} + +/// A card displaying a single slide thumbnail preview. +class _ThumbnailPreviewCard extends StatelessWidget { + const _ThumbnailPreviewCard({ + required this.imageBytes, + required this.slideIndex, + }); + + final Uint8List imageBytes; + final int slideIndex; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: FortalTokens.gray4.resolve(context)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Stack( + fit: StackFit.expand, + children: [ + Image.memory(imageBytes, fit: BoxFit.cover, gaplessPlayback: true), + // Slide number badge + Positioned( + left: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${slideIndex + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/genui/lib/src/presentation/view/loading.dart b/packages/genui/lib/src/presentation/view/loading.dart new file mode 100644 index 00000000..cda926e4 --- /dev/null +++ b/packages/genui/lib/src/presentation/view/loading.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; + +/// Animated isometric cube loading indicator. +/// +/// Displays a rotating isometric cube animation used during +/// presentation generation to indicate progress. +class IsometricLoading extends StatefulWidget { + const IsometricLoading({super.key, this.color = Colors.white}); + + final Color color; + + @override + State createState() => _IsometricLoadingState(); +} + +class _IsometricLoadingState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + late final List _colors = [ + widget.color, + widget.color.withValues(alpha: 0.7), + widget.color.withValues(alpha: 0.4), + widget.color.withValues(alpha: 0.2), + ]; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + )..repeat(reverse: true); + _animation = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return CustomPaint( + painter: IsometricLogoPainter( + colors: List.generate(4, (index) { + final startColorIndex = + ((_animation.value * _colors.length).floor() + index) % + _colors.length; + final endColorIndex = (startColorIndex == _colors.length - 1) + ? 0 + : startColorIndex + 1; + final startColor = _colors[startColorIndex]; + final endColor = _colors[endColorIndex]; + final colorProgress = (_animation.value * _colors.length) % 1.0; + return Color.lerp(startColor, endColor, colorProgress)!; + }), + ), + child: const SizedBox(width: 100, height: 100), + ); + }, + ); + } +} + +/// Custom painter for rendering the isometric cube logo. +/// +/// Draws a 3D isometric cube with customizable face colors. +/// Used by [IsometricLoading] and [EmptyState] for branding. +class IsometricLogoPainter extends CustomPainter { + final List colors; + + // Original design dimensions for normalization + static const _originalWidth = 200; + static const _originalHeight = 226; + + IsometricLogoPainter({required this.colors}); + + @override + void paint(Canvas canvas, Size size) { + final w = size.width * 0.9; + final h = size.height; + + // Offset to center the drawing horizontally + final offsetX = (size.width - w) / 2; + + // Helper to scale x and y coordinates relative to canvas size + double x(double val) => (val / _originalWidth) * w + offsetX; + double y(double val) => (val / _originalHeight) * h; + + final path1 = Path() + ..moveTo(x(92), y(119)) + ..lineTo(x(0), y(66)) + ..lineTo(x(0), y(132)) + ..lineTo(x(71), y(173)) + ..lineTo(x(71), y(189)) + ..lineTo(x(0), y(148)) + ..lineTo(x(0), y(173)) + ..lineTo(x(92), y(226)) + ..lineTo(x(92), y(161)) + ..lineTo(x(21), y(119)) + ..lineTo(x(21), y(103)) + ..lineTo(x(92), y(144)) + ..close(); + + final path2 = Path() + ..moveTo(x(29), y(41)) + ..lineTo(x(8), y(53)) + ..lineTo(x(108), y(111)) + ..lineTo(x(108), y(202)) + ..lineTo(x(129), y(214)) + ..lineTo(x(129), y(99)) + ..close(); + + final path3 = Path() + ..moveTo(x(64), y(21)) + ..lineTo(x(43), y(33)) + ..lineTo(x(143), y(90)) + ..lineTo(x(143), y(182)) + ..lineTo(x(164), y(194)) + ..lineTo(x(164), y(78)) + ..close(); + + final path4 = Path() + ..moveTo(x(79), y(12)) + ..lineTo(x(179), y(70)) + ..lineTo(x(179), y(161)) + ..lineTo(x(200), y(173)) + ..lineTo(x(200), y(58)) + ..lineTo(x(169), y(40)) + ..lineTo(x(100), y(0)) + ..close(); + + final paint1 = Paint() + ..color = colors[0] + ..style = PaintingStyle.fill; + final paint2 = Paint() + ..color = colors[1] + ..style = PaintingStyle.fill; + final paint3 = Paint() + ..color = colors[2] + ..style = PaintingStyle.fill; + final paint4 = Paint() + ..color = colors[3] + ..style = PaintingStyle.fill; + + canvas.drawPath(path1, paint1); + canvas.drawPath(path2, paint2); + canvas.drawPath(path3, paint3); + canvas.drawPath(path4, paint4); + } + + @override + bool shouldRepaint(IsometricLogoPainter oldDelegate) => + oldDelegate.colors != colors; +} diff --git a/packages/genui/lib/src/presentation/view/presentation_deck_host.dart b/packages/genui/lib/src/presentation/view/presentation_deck_host.dart new file mode 100644 index 00000000..1d0ca469 --- /dev/null +++ b/packages/genui/lib/src/presentation/view/presentation_deck_host.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:superdeck/superdeck.dart'; +import '../../utils/deck_style_service.dart'; +import '../../utils/style_builder.dart'; + +typedef DeckAppBuilder = Widget Function(DeckOptions options); + +class PresentationDeckHost extends StatelessWidget { + const PresentationDeckHost({super.key, DeckAppBuilder? deckAppBuilder}) + : _deckAppBuilder = deckAppBuilder ?? _defaultDeckAppBuilder; + + final DeckAppBuilder _deckAppBuilder; + + @override + Widget build(BuildContext context) { + return Watch((context) { + final style = DeckStyleService.style.value; + final options = buildDeckOptionsFromStyle(style); + return _deckAppBuilder(options); + }); + } + + static Widget _defaultDeckAppBuilder(DeckOptions options) { + return SuperDeckApp(options: options); + } +} diff --git a/packages/genui/lib/src/routes.dart b/packages/genui/lib/src/routes.dart new file mode 100644 index 00000000..703c081f --- /dev/null +++ b/packages/genui/lib/src/routes.dart @@ -0,0 +1,51 @@ +import 'package:go_router/go_router.dart'; + +import 'chat/view/chat_screen.dart'; +import 'presentation/view/creating_presentation_screen.dart'; +import 'presentation/view/presentation_deck_host.dart'; +import 'utils/deck_style_service.dart'; + +void _applyStyleFromExtra(Object? extra) { + if (extra case {'style': final rawStyle}) { + final parsedStyle = DeckStyleService.setStyleFromJson(rawStyle); + if (parsedStyle == null && rawStyle != null) { + DeckStyleService.setStyle(null); + } + } +} + +/// Route path constants for the GenUI wizard navigation. +abstract final class GenUiRoutes { + static const chat = '/chat'; + static const presentation = '/presentation'; + static const presentationCreating = '/presentation/creating'; +} + +/// Returns the list of GoRoutes for the GenUI wizard. +/// +/// Consumers integrate these into their own GoRouter configuration: +/// ```dart +/// final router = GoRouter( +/// routes: [ +/// ...genUiRoutes(), +/// ], +/// ); +/// ``` +List genUiRoutes() => [ + GoRoute( + path: GenUiRoutes.chat, + builder: (context, state) => const ChatScreen(), + ), + GoRoute( + path: GenUiRoutes.presentationCreating, + builder: (context, state) => const CreatingPresentationScreen(), + ), + GoRoute( + path: GenUiRoutes.presentation, + builder: (context, state) { + _applyStyleFromExtra(state.extra); + + return const PresentationDeckHost(); + }, + ), +]; diff --git a/packages/genui/lib/src/tools/deck_document_store.dart b/packages/genui/lib/src/tools/deck_document_store.dart new file mode 100644 index 00000000..57c560a2 --- /dev/null +++ b/packages/genui/lib/src/tools/deck_document_store.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:superdeck/superdeck.dart'; +import '../ai/schemas/deck_schemas.dart'; +import '../ai/services/style_json_serializer.dart'; +import './errors.dart'; + +class DeckDocument { + const DeckDocument({required this.slides, required this.style}); + + final List slides; + final DeckStyleType? style; +} + +class DeckDocumentStore { + DeckDocumentStore({DeckConfiguration? configuration}) + : configuration = configuration ?? DeckConfiguration(); + + final DeckConfiguration configuration; + + Future readRequired() async { + final file = configuration.deckJson; + if (!await file.exists()) { + throw DeckToolException.deckFileNotFound(file.path); + } + + final content = await file.readAsString(); + + dynamic decoded; + try { + decoded = jsonDecode(content); + } on FormatException catch (error) { + throw DeckToolException.deckJsonInvalid(error.message); + } + + if (decoded is! Map) { + throw DeckToolException.deckSchemaInvalid('Root must be a JSON object'); + } + + final map = Map.from(decoded); + final rawSlides = map['slides']; + if (rawSlides is! List) { + throw DeckToolException.deckSchemaInvalid( + 'Expected "slides" as a JSON array', + ); + } + + final slides = []; + for (var index = 0; index < rawSlides.length; index++) { + final rawSlide = rawSlides[index]; + if (rawSlide is! Map) { + throw DeckToolException.deckSchemaInvalid( + 'Slide at index $index must be a JSON object', + ); + } + + try { + final slideMap = Map.from(rawSlide); + slides.add(Slide.parse(slideMap)); + } catch (error) { + throw DeckToolException.deckSchemaInvalid( + 'Invalid slide at index $index: $error', + ); + } + } + + DeckStyleType? style; + if (map.containsKey('style') && map['style'] != null) { + style = DeckStyleType.safeParse(map['style']).getOrNull(); + if (style == null) { + throw DeckToolException.deckSchemaInvalid('Invalid "style" object'); + } + } + + return DeckDocument(slides: slides, style: style); + } + + Future writeCanonical({ + required List slides, + DeckStyleType? style, + }) async { + final file = configuration.deckJson; + final payload = { + 'slides': slides.map((slide) => slide.toMap()).toList(), + if (style != null) 'style': serializeDeckStyleForJson(style), + }; + + final encoded = const JsonEncoder.withIndent(' ').convert(payload); + + try { + await file.parent.create(recursive: true); + await file.writeAsString(encoded); + } on FileSystemException catch (error) { + throw DeckToolException.deckWriteFailed( + path: file.path, + details: error.message, + ); + } + } +} diff --git a/packages/genui/lib/src/tools/deck_mutation_helpers.dart b/packages/genui/lib/src/tools/deck_mutation_helpers.dart new file mode 100644 index 00000000..37a04f95 --- /dev/null +++ b/packages/genui/lib/src/tools/deck_mutation_helpers.dart @@ -0,0 +1,90 @@ +import 'package:superdeck/superdeck.dart'; +import '../ai/schemas/deck_schemas.dart'; +import './deck_tools_schemas.dart'; +import './errors.dart'; + +void validateReadIndex(int index, int slideCount) { + if (index < 0 || index >= slideCount) { + throw DeckToolException.slideIndexOutOfRange( + index: index, + slideCount: slideCount, + ); + } +} + +void validateInsertIndex(int index, int slideCount) { + if (index < 0 || index > slideCount) { + throw DeckToolException.slideInsertIndexInvalid( + index: index, + slideCount: slideCount, + ); + } +} + +void ensureUniqueSlideKeyForCreate(List slides, String key) { + final exists = slides.any((slide) => slide.key == key); + if (exists) { + throw DeckToolException.slideKeyConflict(key); + } +} + +void ensureUniqueSlideKeyForUpdate(List slides, int index, String key) { + final exists = slides.asMap().entries.any( + (entry) => entry.key != index && entry.value.key == key, + ); + if (exists) { + throw DeckToolException.slideKeyConflict(key); + } +} + +List insertSlideAt(List slides, Slide slide, int index) { + validateInsertIndex(index, slides.length); + final next = List.from(slides); + next.insert(index, slide); + return next; +} + +List replaceSlideAt(List slides, int index, Slide slide) { + validateReadIndex(index, slides.length); + final next = List.from(slides); + next[index] = slide; + return next; +} + +List removeSlideAt(List slides, int index) { + validateReadIndex(index, slides.length); + final next = List.from(slides); + next.removeAt(index); + return next; +} + +List moveSlide(List slides, int fromIndex, int toIndex) { + validateReadIndex(fromIndex, slides.length); + validateReadIndex(toIndex, slides.length); + + if (fromIndex == toIndex) { + return List.from(slides); + } + + final next = List.from(slides); + final item = next.removeAt(fromIndex); + next.insert(toIndex, item); + return next; +} + +DeckSnapshotType buildDeckSnapshot(List slides, {DeckStyleType? style}) { + final summaries = List.generate(slides.length, (index) { + final slide = slides[index]; + return SlideSummaryType.parse({ + 'index': index, + 'key': slide.key, + if (slide.options?.title case final title?) 'title': title, + }); + }); + + return DeckSnapshotType.parse({ + 'totalSlides': slides.length, + 'slides': summaries.map((summary) => summary.toJson()).toList(), + if (style case final currentStyle?) 'style': currentStyle.toJson(), + }); +} diff --git a/packages/genui/lib/src/tools/deck_tools_locator.dart b/packages/genui/lib/src/tools/deck_tools_locator.dart new file mode 100644 index 00000000..66d29252 --- /dev/null +++ b/packages/genui/lib/src/tools/deck_tools_locator.dart @@ -0,0 +1,6 @@ +import '../navigation/app_navigator_key.dart'; +import './deck_tools_service.dart'; + +final deckToolsService = DeckToolsService( + contextProvider: () => appNavigatorKey.currentContext, +); diff --git a/packages/genui/lib/src/tools/deck_tools_schemas.dart b/packages/genui/lib/src/tools/deck_tools_schemas.dart new file mode 100644 index 00000000..7e615a0d --- /dev/null +++ b/packages/genui/lib/src/tools/deck_tools_schemas.dart @@ -0,0 +1,101 @@ +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import '../ai/schemas/deck_schemas.dart'; + +part 'deck_tools_schemas.g.dart'; + +@AckType(name: 'ReadSlideRequest') +final _readSlideRequestSchema = Ack.object({ + 'index': Ack.integer().min(0).describe('Zero-based slide index'), +}).describe('Request payload for readSlide'); +final readSlideRequestSchema = _readSlideRequestSchema; + +@AckType(name: 'CreateSlideRequest') +final _createSlideRequestSchema = Ack.object({ + 'schema': createSlideSchema.describe('Slide schema payload'), + 'atIndex': Ack.integer() + .min(0) + .optional() + .describe('Optional insertion index. Defaults to appending at the end'), +}).describe('Request payload for createSlide'); +final createSlideRequestSchema = _createSlideRequestSchema; + +@AckType(name: 'UpdateSlideRequest') +final _updateSlideRequestSchema = Ack.object({ + 'index': Ack.integer().min(0).describe('Zero-based slide index'), + 'schema': createSlideSchema.describe('Replacement slide schema payload'), +}).describe('Request payload for updateSlide'); +final updateSlideRequestSchema = _updateSlideRequestSchema; + +@AckType(name: 'DeleteSlideRequest') +final _deleteSlideRequestSchema = Ack.object({ + 'index': Ack.integer().min(0).describe('Zero-based slide index'), +}).describe('Request payload for deleteSlide'); +final deleteSlideRequestSchema = _deleteSlideRequestSchema; + +@AckType(name: 'MoveSlideRequest') +final _moveSlideRequestSchema = Ack.object({ + 'fromIndex': Ack.integer().min(0).describe('Current slide index'), + 'toIndex': Ack.integer().min(0).describe('Target slide index'), +}).describe('Request payload for moveSlide'); +final moveSlideRequestSchema = _moveSlideRequestSchema; + +@AckType(name: 'UpdateStyleRequest') +final _updateStyleRequestSchema = Ack.object({ + 'style': styleSchema.describe('Complete deck style payload'), +}).describe('Request payload for updateStyle'); +final updateStyleRequestSchema = _updateStyleRequestSchema; + +@AckType(name: 'SlideSummary') +final _slideSummarySchema = Ack.object({ + 'index': Ack.integer().min(0).describe('Zero-based slide index'), + 'key': Ack.string().describe('Stable slide key'), + 'title': Ack.string().optional().describe('Optional slide title'), +}).describe('Deck slide summary'); +final slideSummarySchema = _slideSummarySchema; + +@AckType(name: 'DeckSnapshot') +final _deckSnapshotSchema = Ack.object({ + 'totalSlides': Ack.integer().min(0).describe('Total number of slides'), + 'slides': Ack.list( + _slideSummarySchema, + ).describe('Ordered slide summaries for the deck'), + 'style': styleSchema.optional().describe('Current deck style when available'), +}).describe('Current deck snapshot'); +final deckSnapshotSchema = _deckSnapshotSchema; + +@AckType(name: 'ReadSlidePayload') +final _readSlidePayloadSchema = Ack.object({ + 'index': Ack.integer().describe('Zero-based slide index'), + 'key': Ack.string().describe('Slide key'), + 'schema': slideSchema.describe('Serialized slide schema'), + 'thumbnail': Ack.string().describe('Base64-encoded PNG thumbnail'), +}).describe('Detailed slide read payload'); + +@AckType(name: 'ReadSlideResult') +final _readSlideResultSchema = Ack.object({ + 'slide': _readSlidePayloadSchema, + 'deck': _deckSnapshotSchema, +}).describe('Result payload for readSlide'); +final readSlideResultSchema = _readSlideResultSchema; + +@AckType(name: 'SlideMutationResult') +final _slideMutationResultSchema = Ack.object({ + 'slide': _slideSummarySchema, + 'deck': _deckSnapshotSchema, +}).describe('Result payload for createSlide/updateSlide'); +final slideMutationResultSchema = _slideMutationResultSchema; + +@AckType(name: 'SlideMoveResult') +final _slideMoveResultSchema = Ack.object({ + 'slide': _slideSummarySchema, + 'deck': _deckSnapshotSchema, +}).describe('Result payload for moveSlide'); +final slideMoveResultSchema = _slideMoveResultSchema; + +@AckType(name: 'StyleUpdateResult') +final _styleUpdateResultSchema = Ack.object({ + 'style': styleSchema.describe('Applied deck style'), + 'deck': _deckSnapshotSchema, +}).describe('Result payload for updateStyle'); +final styleUpdateResultSchema = _styleUpdateResultSchema; diff --git a/packages/genui/lib/src/tools/deck_tools_schemas.g.dart b/packages/genui/lib/src/tools/deck_tools_schemas.g.dart new file mode 100644 index 00000000..cecc6df5 --- /dev/null +++ b/packages/genui/lib/src/tools/deck_tools_schemas.g.dart @@ -0,0 +1,447 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'deck_tools_schemas.dart'; + +/// Extension type for ReadSlideRequest +extension type ReadSlideRequestType(Map _data) + implements Map { + static ReadSlideRequestType parse(Object? data) { + return _readSlideRequestSchema.parseAs( + data, + (validated) => ReadSlideRequestType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _readSlideRequestSchema.safeParseAs( + data, + (validated) => ReadSlideRequestType(validated as Map), + ); + } + + Map toJson() => _data; + + int get index => _data['index'] as int; + + ReadSlideRequestType copyWith({int? index}) { + return ReadSlideRequestType.parse({'index': index ?? this.index}); + } +} + +/// Extension type for CreateSlideRequest +extension type CreateSlideRequestType(Map _data) + implements Map { + static CreateSlideRequestType parse(Object? data) { + return _createSlideRequestSchema.parseAs( + data, + (validated) => CreateSlideRequestType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _createSlideRequestSchema.safeParseAs( + data, + (validated) => CreateSlideRequestType(validated as Map), + ); + } + + Map toJson() => _data; + + CreateSlideType get schema => + CreateSlideType(_data['schema'] as Map); + + int? get atIndex => _data['atIndex'] as int?; + + CreateSlideRequestType copyWith({ + Map? schema, + int? atIndex, + }) { + return CreateSlideRequestType.parse({ + 'schema': schema ?? this.schema, + 'atIndex': atIndex ?? this.atIndex, + }); + } +} + +/// Extension type for UpdateSlideRequest +extension type UpdateSlideRequestType(Map _data) + implements Map { + static UpdateSlideRequestType parse(Object? data) { + return _updateSlideRequestSchema.parseAs( + data, + (validated) => UpdateSlideRequestType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _updateSlideRequestSchema.safeParseAs( + data, + (validated) => UpdateSlideRequestType(validated as Map), + ); + } + + Map toJson() => _data; + + int get index => _data['index'] as int; + + CreateSlideType get schema => + CreateSlideType(_data['schema'] as Map); + + UpdateSlideRequestType copyWith({int? index, Map? schema}) { + return UpdateSlideRequestType.parse({ + 'index': index ?? this.index, + 'schema': schema ?? this.schema, + }); + } +} + +/// Extension type for DeleteSlideRequest +extension type DeleteSlideRequestType(Map _data) + implements Map { + static DeleteSlideRequestType parse(Object? data) { + return _deleteSlideRequestSchema.parseAs( + data, + (validated) => DeleteSlideRequestType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _deleteSlideRequestSchema.safeParseAs( + data, + (validated) => DeleteSlideRequestType(validated as Map), + ); + } + + Map toJson() => _data; + + int get index => _data['index'] as int; + + DeleteSlideRequestType copyWith({int? index}) { + return DeleteSlideRequestType.parse({'index': index ?? this.index}); + } +} + +/// Extension type for MoveSlideRequest +extension type MoveSlideRequestType(Map _data) + implements Map { + static MoveSlideRequestType parse(Object? data) { + return _moveSlideRequestSchema.parseAs( + data, + (validated) => MoveSlideRequestType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _moveSlideRequestSchema.safeParseAs( + data, + (validated) => MoveSlideRequestType(validated as Map), + ); + } + + Map toJson() => _data; + + int get fromIndex => _data['fromIndex'] as int; + + int get toIndex => _data['toIndex'] as int; + + MoveSlideRequestType copyWith({int? fromIndex, int? toIndex}) { + return MoveSlideRequestType.parse({ + 'fromIndex': fromIndex ?? this.fromIndex, + 'toIndex': toIndex ?? this.toIndex, + }); + } +} + +/// Extension type for UpdateStyleRequest +extension type UpdateStyleRequestType(Map _data) + implements Map { + static UpdateStyleRequestType parse(Object? data) { + return _updateStyleRequestSchema.parseAs( + data, + (validated) => UpdateStyleRequestType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _updateStyleRequestSchema.safeParseAs( + data, + (validated) => UpdateStyleRequestType(validated as Map), + ); + } + + Map toJson() => _data; + + DeckStyleType get style => + DeckStyleType(_data['style'] as Map); + + UpdateStyleRequestType copyWith({Map? style}) { + return UpdateStyleRequestType.parse({'style': style ?? this.style}); + } +} + +/// Extension type for SlideSummary +extension type SlideSummaryType(Map _data) + implements Map { + static SlideSummaryType parse(Object? data) { + return _slideSummarySchema.parseAs( + data, + (validated) => SlideSummaryType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _slideSummarySchema.safeParseAs( + data, + (validated) => SlideSummaryType(validated as Map), + ); + } + + Map toJson() => _data; + + int get index => _data['index'] as int; + + String get key => _data['key'] as String; + + String? get title => _data['title'] as String?; + + SlideSummaryType copyWith({int? index, String? key, String? title}) { + return SlideSummaryType.parse({ + 'index': index ?? this.index, + 'key': key ?? this.key, + 'title': title ?? this.title, + }); + } +} + +/// Extension type for DeckSnapshot +extension type DeckSnapshotType(Map _data) + implements Map { + static DeckSnapshotType parse(Object? data) { + return _deckSnapshotSchema.parseAs( + data, + (validated) => DeckSnapshotType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _deckSnapshotSchema.safeParseAs( + data, + (validated) => DeckSnapshotType(validated as Map), + ); + } + + Map toJson() => _data; + + int get totalSlides => _data['totalSlides'] as int; + + List get slides => (_data['slides'] as List) + .map((e) => SlideSummaryType(e as Map)) + .toList(); + + DeckStyleType? get style => _data['style'] != null + ? DeckStyleType(_data['style'] as Map) + : null; + + DeckSnapshotType copyWith({ + int? totalSlides, + List? slides, + Map? style, + }) { + return DeckSnapshotType.parse({ + 'totalSlides': totalSlides ?? this.totalSlides, + 'slides': slides ?? this.slides, + 'style': style ?? this.style, + }); + } +} + +/// Extension type for ReadSlidePayload +extension type ReadSlidePayloadType(Map _data) + implements Map { + static ReadSlidePayloadType parse(Object? data) { + return _readSlidePayloadSchema.parseAs( + data, + (validated) => ReadSlidePayloadType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _readSlidePayloadSchema.safeParseAs( + data, + (validated) => ReadSlidePayloadType(validated as Map), + ); + } + + Map toJson() => _data; + + int get index => _data['index'] as int; + + String get key => _data['key'] as String; + + SlideType get schema => SlideType(_data['schema'] as Map); + + String get thumbnail => _data['thumbnail'] as String; + + ReadSlidePayloadType copyWith({ + int? index, + String? key, + Map? schema, + String? thumbnail, + }) { + return ReadSlidePayloadType.parse({ + 'index': index ?? this.index, + 'key': key ?? this.key, + 'schema': schema ?? this.schema, + 'thumbnail': thumbnail ?? this.thumbnail, + }); + } +} + +/// Extension type for ReadSlideResult +extension type ReadSlideResultType(Map _data) + implements Map { + static ReadSlideResultType parse(Object? data) { + return _readSlideResultSchema.parseAs( + data, + (validated) => ReadSlideResultType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _readSlideResultSchema.safeParseAs( + data, + (validated) => ReadSlideResultType(validated as Map), + ); + } + + Map toJson() => _data; + + ReadSlidePayloadType get slide => + ReadSlidePayloadType(_data['slide'] as Map); + + DeckSnapshotType get deck => + DeckSnapshotType(_data['deck'] as Map); + + ReadSlideResultType copyWith({ + Map? slide, + Map? deck, + }) { + return ReadSlideResultType.parse({ + 'slide': slide ?? this.slide, + 'deck': deck ?? this.deck, + }); + } +} + +/// Extension type for SlideMutationResult +extension type SlideMutationResultType(Map _data) + implements Map { + static SlideMutationResultType parse(Object? data) { + return _slideMutationResultSchema.parseAs( + data, + (validated) => SlideMutationResultType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _slideMutationResultSchema.safeParseAs( + data, + (validated) => SlideMutationResultType(validated as Map), + ); + } + + Map toJson() => _data; + + SlideSummaryType get slide => + SlideSummaryType(_data['slide'] as Map); + + DeckSnapshotType get deck => + DeckSnapshotType(_data['deck'] as Map); + + SlideMutationResultType copyWith({ + Map? slide, + Map? deck, + }) { + return SlideMutationResultType.parse({ + 'slide': slide ?? this.slide, + 'deck': deck ?? this.deck, + }); + } +} + +/// Extension type for SlideMoveResult +extension type SlideMoveResultType(Map _data) + implements Map { + static SlideMoveResultType parse(Object? data) { + return _slideMoveResultSchema.parseAs( + data, + (validated) => SlideMoveResultType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _slideMoveResultSchema.safeParseAs( + data, + (validated) => SlideMoveResultType(validated as Map), + ); + } + + Map toJson() => _data; + + SlideSummaryType get slide => + SlideSummaryType(_data['slide'] as Map); + + DeckSnapshotType get deck => + DeckSnapshotType(_data['deck'] as Map); + + SlideMoveResultType copyWith({ + Map? slide, + Map? deck, + }) { + return SlideMoveResultType.parse({ + 'slide': slide ?? this.slide, + 'deck': deck ?? this.deck, + }); + } +} + +/// Extension type for StyleUpdateResult +extension type StyleUpdateResultType(Map _data) + implements Map { + static StyleUpdateResultType parse(Object? data) { + return _styleUpdateResultSchema.parseAs( + data, + (validated) => StyleUpdateResultType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _styleUpdateResultSchema.safeParseAs( + data, + (validated) => StyleUpdateResultType(validated as Map), + ); + } + + Map toJson() => _data; + + DeckStyleType get style => + DeckStyleType(_data['style'] as Map); + + DeckSnapshotType get deck => + DeckSnapshotType(_data['deck'] as Map); + + StyleUpdateResultType copyWith({ + Map? style, + Map? deck, + }) { + return StyleUpdateResultType.parse({ + 'style': style ?? this.style, + 'deck': deck ?? this.deck, + }); + } +} diff --git a/packages/genui/lib/src/tools/deck_tools_service.dart b/packages/genui/lib/src/tools/deck_tools_service.dart new file mode 100644 index 00000000..c22225a4 --- /dev/null +++ b/packages/genui/lib/src/tools/deck_tools_service.dart @@ -0,0 +1,363 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:superdeck/superdeck.dart'; +// ignore: implementation_imports +import 'package:superdeck/src/deck/slide_configuration_builder.dart'; +// ignore: implementation_imports +import 'package:superdeck/src/export/slide_capture_service.dart'; +import '../ai/schemas/deck_schemas.dart'; +import '../ai/services/slide_key_utils.dart'; +import './deck_document_store.dart'; +import './deck_mutation_helpers.dart' as mutation; +import './deck_tools_schemas.dart'; +import './errors.dart'; +import '../utils/deck_style_service.dart'; +import '../utils/style_builder.dart'; +import '../presentation/thumbnail_preview_service.dart'; + +typedef BuildContextProvider = BuildContext? Function(); + +/// Builds the slide configuration used by `readSlide`. +/// +/// The default implementation uses `SlideConfigurationBuilder`. Tests can +/// inject a lightweight builder to avoid font/asset side effects. +typedef ReadSlideConfigurationBuilder = + SlideConfiguration Function({ + required Slide slide, + required DeckConfiguration configuration, + required DeckStyleType? style, + required int index, + }); + +/// In-app deck tooling service for slide CRUD and style updates. +class DeckToolsService { + DeckToolsService({ + DeckDocumentStore? documentStore, + BuildContextProvider? contextProvider, + SlideCaptureFn? captureSlide, + ReadSlideConfigurationBuilder? buildReadSlideConfiguration, + }) : _documentStore = documentStore ?? DeckDocumentStore(), + _contextProvider = contextProvider ?? _defaultContextProvider, + _captureSlide = captureSlide, + _buildReadSlideConfiguration = + buildReadSlideConfiguration ?? _defaultReadSlideConfigurationBuilder; + + final DeckDocumentStore _documentStore; + final BuildContextProvider _contextProvider; + final SlideCaptureFn? _captureSlide; + final ReadSlideConfigurationBuilder _buildReadSlideConfiguration; + + SlideCaptureService? _captureService; + Future _mutationQueue = Future.value(); + + Future getDeck() async { + final document = await _documentStore.readRequired(); + return mutation.buildDeckSnapshot(document.slides, style: document.style); + } + + Future readSlide(ReadSlideRequestType request) async { + final document = await _documentStore.readRequired(); + final index = request.index; + mutation.validateReadIndex(index, document.slides.length); + + final context = _requireMountedContext(); + final slide = document.slides[index]; + final slideConfiguration = _buildReadSlideConfiguration( + slide: slide, + configuration: _documentStore.configuration, + style: document.style, + index: index, + ).copyWith(slideIndex: index); + + // ignore: use_build_context_synchronously + final imageBytes = await _captureSlideBytes(slideConfiguration, context); + final snapshot = mutation.buildDeckSnapshot( + document.slides, + style: document.style, + ); + + return ReadSlideResultType.parse({ + 'slide': { + 'index': index, + 'key': slide.key, + 'schema': slide.toMap(), + 'thumbnail': base64Encode(imageBytes), + }, + 'deck': snapshot.toJson(), + }); + } + + Future createSlide( + CreateSlideRequestType request, + ) async { + return _runSerializedMutation(() async { + final document = await _documentStore.readRequired(); + final insertIndex = request.atIndex ?? document.slides.length; + mutation.validateInsertIndex(insertIndex, document.slides.length); + + final slideMap = _validatedSlideSchemaMap( + request.schema, + allowMissingKey: true, + ); + final existingKey = slideMap['key']?.toString().trim(); + if (existingKey == null || existingKey.isEmpty) { + slideMap['key'] = _generateUniqueSlideKey( + slideMap, + document.slides, + insertIndex, + ); + } else { + slideMap['key'] = existingKey; + } + + final slide = _parseSlideOrThrow(slideMap); + mutation.ensureUniqueSlideKeyForCreate(document.slides, slide.key); + + final updatedSlides = mutation.insertSlideAt( + document.slides, + slide, + insertIndex, + ); + await _documentStore.writeCanonical( + slides: updatedSlides, + style: document.style, + ); + + final snapshot = mutation.buildDeckSnapshot( + updatedSlides, + style: document.style, + ); + return SlideMutationResultType.parse({ + 'slide': snapshot.slides[insertIndex].toJson(), + 'deck': snapshot.toJson(), + }); + }); + } + + Future updateSlide( + UpdateSlideRequestType request, + ) async { + return _runSerializedMutation(() async { + final document = await _documentStore.readRequired(); + final index = request.index; + mutation.validateReadIndex(index, document.slides.length); + + final existingSlide = document.slides[index]; + final slideMap = _validatedSlideSchemaMap( + request.schema, + allowMissingKey: true, + ); + slideMap['key'] = existingSlide.key; + + final updatedSlide = _parseSlideOrThrow(slideMap); + mutation.ensureUniqueSlideKeyForUpdate( + document.slides, + index, + updatedSlide.key, + ); + + final updatedSlides = mutation.replaceSlideAt( + document.slides, + index, + updatedSlide, + ); + await _documentStore.writeCanonical( + slides: updatedSlides, + style: document.style, + ); + + final snapshot = mutation.buildDeckSnapshot( + updatedSlides, + style: document.style, + ); + return SlideMutationResultType.parse({ + 'slide': snapshot.slides[index].toJson(), + 'deck': snapshot.toJson(), + }); + }); + } + + Future deleteSlide(DeleteSlideRequestType request) async { + return _runSerializedMutation(() async { + final document = await _documentStore.readRequired(); + final index = request.index; + final updatedSlides = mutation.removeSlideAt(document.slides, index); + + await _documentStore.writeCanonical( + slides: updatedSlides, + style: document.style, + ); + + return mutation.buildDeckSnapshot(updatedSlides, style: document.style); + }); + } + + Future moveSlide(MoveSlideRequestType request) async { + return _runSerializedMutation(() async { + final document = await _documentStore.readRequired(); + final updatedSlides = mutation.moveSlide( + document.slides, + request.fromIndex, + request.toIndex, + ); + + await _documentStore.writeCanonical( + slides: updatedSlides, + style: document.style, + ); + + final snapshot = mutation.buildDeckSnapshot( + updatedSlides, + style: document.style, + ); + return SlideMoveResultType.parse({ + 'slide': snapshot.slides[request.toIndex].toJson(), + 'deck': snapshot.toJson(), + }); + }); + } + + Future updateStyle( + UpdateStyleRequestType request, + ) async { + return _runSerializedMutation(() async { + final parsedStyle = DeckStyleType.safeParse(request.style).getOrNull(); + if (parsedStyle == null) { + throw DeckToolException.styleInvalid( + 'Expected a valid DeckStyle payload', + ); + } + + final document = await _documentStore.readRequired(); + await _documentStore.writeCanonical( + slides: document.slides, + style: parsedStyle, + ); + + DeckStyleService.setStyle(parsedStyle); + + final snapshot = mutation.buildDeckSnapshot( + document.slides, + style: parsedStyle, + ); + return StyleUpdateResultType.parse({ + 'style': parsedStyle.toJson(), + 'deck': snapshot.toJson(), + }); + }); + } + + Map _validatedSlideSchemaMap( + Object? rawSchema, { + bool allowMissingKey = false, + }) { + if (rawSchema is! Map) { + throw DeckToolException.deckSchemaInvalid('Invalid slide schema payload'); + } + + final schemaMap = Map.from(rawSchema); + if (allowMissingKey) { + final typedSchema = CreateSlideType.safeParse(schemaMap).getOrNull(); + if (typedSchema == null) { + throw DeckToolException.deckSchemaInvalid( + 'Invalid slide schema payload', + ); + } + + return Map.from(typedSchema); + } + + final typedSchema = SlideType.safeParse(schemaMap).getOrNull(); + if (typedSchema == null) { + throw DeckToolException.deckSchemaInvalid('Invalid slide schema payload'); + } + + return Map.from(typedSchema); + } + + Slide _parseSlideOrThrow(Map slideMap) { + try { + return Slide.parse(slideMap); + } catch (error) { + throw DeckToolException.deckSchemaInvalid('Invalid slide schema: $error'); + } + } + + String _generateUniqueSlideKey( + Map slideMap, + List existingSlides, + int insertIndex, + ) { + final existingKeys = existingSlides.map((slide) => slide.key).toSet(); + const maxAttempts = 1024; + for (var attempt = 0; attempt < maxAttempts; attempt++) { + final candidate = generateSlideKey(slideMap, insertIndex + attempt); + if (!existingKeys.contains(candidate)) { + return candidate; + } + } + throw DeckToolException.slideKeyConflict( + 'Could not generate unique key after $maxAttempts attempts', + ); + } + + Future _runSerializedMutation(Future Function() operation) { + final completer = Completer(); + _mutationQueue = _mutationQueue + .catchError((Object ignoredError, StackTrace ignoredStackTrace) {}) + .then((_) async { + try { + completer.complete(await operation()); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + return completer.future; + } + + BuildContext _requireMountedContext() { + final context = _contextProvider(); + if (context == null || !context.mounted) { + throw DeckToolException.contextUnavailable(); + } + + return context; + } + + Future _captureSlideBytes( + SlideConfiguration slide, + BuildContext context, + ) { + if (_captureSlide case final capture?) { + return capture(slide, context); + } + + _captureService ??= SlideCaptureService(); + return _captureService!.capture( + quality: SlideCaptureQuality.good, + slide: slide, + context: context, + ); + } + + static BuildContext? _defaultContextProvider() => null; + + static SlideConfiguration _defaultReadSlideConfigurationBuilder({ + required Slide slide, + required DeckConfiguration configuration, + required DeckStyleType? style, + required int index, + }) { + final slideBuilder = SlideConfigurationBuilder( + configuration: configuration, + ); + final options = buildDeckOptionsFromStyle(style); + return slideBuilder + .buildConfigurations([slide], options) + .single + .copyWith(slideIndex: index); + } +} diff --git a/packages/genui/lib/src/tools/errors.dart b/packages/genui/lib/src/tools/errors.dart new file mode 100644 index 00000000..d6a5f752 --- /dev/null +++ b/packages/genui/lib/src/tools/errors.dart @@ -0,0 +1,93 @@ +abstract final class DeckToolErrorCode { + static const deckFileNotFound = 'deck_file_not_found'; + static const deckJsonInvalid = 'deck_json_invalid'; + static const deckWriteFailed = 'deck_write_failed'; + static const deckSchemaInvalid = 'deck_schema_invalid'; + static const slideIndexOutOfRange = 'slide_index_out_of_range'; + static const slideInsertIndexInvalid = 'slide_insert_index_invalid'; + static const styleInvalid = 'style_invalid'; + static const contextUnavailable = 'context_unavailable'; + static const slideKeyConflict = 'slide_key_conflict'; +} + +class DeckToolException implements Exception { + const DeckToolException(this.code, this.message); + + final String code; + final String message; + + factory DeckToolException.deckFileNotFound(String path) { + return DeckToolException( + DeckToolErrorCode.deckFileNotFound, + 'Deck file not found at $path', + ); + } + + factory DeckToolException.deckJsonInvalid(String details) { + return DeckToolException( + DeckToolErrorCode.deckJsonInvalid, + 'Deck JSON is invalid: $details', + ); + } + + factory DeckToolException.deckWriteFailed({ + required String path, + required String details, + }) { + return DeckToolException( + DeckToolErrorCode.deckWriteFailed, + 'Failed to write deck file at $path: $details', + ); + } + + factory DeckToolException.deckSchemaInvalid(String details) { + return DeckToolException( + DeckToolErrorCode.deckSchemaInvalid, + 'Deck schema is invalid: $details', + ); + } + + factory DeckToolException.slideIndexOutOfRange({ + required int index, + required int slideCount, + }) { + return DeckToolException( + DeckToolErrorCode.slideIndexOutOfRange, + 'Slide index $index is out of range for deck with $slideCount slides', + ); + } + + factory DeckToolException.slideInsertIndexInvalid({ + required int index, + required int slideCount, + }) { + return DeckToolException( + DeckToolErrorCode.slideInsertIndexInvalid, + 'Insert index $index is invalid for deck with $slideCount slides', + ); + } + + factory DeckToolException.styleInvalid(String details) { + return DeckToolException( + DeckToolErrorCode.styleInvalid, + 'Style payload is invalid: $details', + ); + } + + factory DeckToolException.contextUnavailable() { + return const DeckToolException( + DeckToolErrorCode.contextUnavailable, + 'A mounted BuildContext is required for slide rendering', + ); + } + + factory DeckToolException.slideKeyConflict(String key) { + return DeckToolException( + DeckToolErrorCode.slideKeyConflict, + 'Slide key "$key" already exists in the deck', + ); + } + + @override + String toString() => 'DeckToolException($code): $message'; +} diff --git a/packages/genui/lib/src/ui/components/sd_buttons.dart b/packages/genui/lib/src/ui/components/sd_buttons.dart new file mode 100644 index 00000000..12125797 --- /dev/null +++ b/packages/genui/lib/src/ui/components/sd_buttons.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +/// Pre-styled solid button. +class SdButton extends StatelessWidget { + const SdButton({ + super.key, + required this.label, + required this.onPressed, + this.icon, + this.enabled = true, + this.loading = false, + this.semanticLabel, + this.style, + }); + + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final bool enabled; + final bool loading; + + /// Semantic label for accessibility. Defaults to [label] if not provided. + final String? semanticLabel; + final RemixButtonStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalButtonStyle.solid().merge(style); + return Semantics( + button: true, + label: semanticLabel ?? label, + enabled: enabled && !loading, + child: RemixButton( + label: label, + onPressed: onPressed, + icon: icon, + enabled: enabled, + loading: loading, + style: finalStyle, + ), + ); + } +} + +/// Pre-styled surface icon button. +class SdIconButton extends StatelessWidget { + const SdIconButton({ + super.key, + required this.icon, + required this.onPressed, + this.loading = false, + this.semanticLabel, + this.style, + }); + + final IconData icon; + final VoidCallback? onPressed; + final bool loading; + final String? semanticLabel; + final RemixIconButtonStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalIconButtonStyle.surface().merge(style); + return RemixIconButton( + icon: icon, + onPressed: onPressed, + loading: loading, + semanticLabel: semanticLabel, + style: finalStyle, + ); + } +} diff --git a/packages/genui/lib/src/ui/components/sd_components.dart b/packages/genui/lib/src/ui/components/sd_components.dart new file mode 100644 index 00000000..59d0607e --- /dev/null +++ b/packages/genui/lib/src/ui/components/sd_components.dart @@ -0,0 +1,6 @@ +export 'sd_buttons.dart'; +export 'sd_custom.dart'; +export 'sd_feedback.dart'; +export 'sd_form.dart'; +export 'sd_tokens.dart'; +export 'sd_typography.dart'; diff --git a/packages/genui/lib/src/ui/components/sd_custom.dart b/packages/genui/lib/src/ui/components/sd_custom.dart new file mode 100644 index 00000000..9cf1bce6 --- /dev/null +++ b/packages/genui/lib/src/ui/components/sd_custom.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import 'sd_tokens.dart'; +import 'sd_typography.dart'; + +/// Non-interactive panel/container with standard surface styling. +/// +/// Use for static content containers. For selectable cards with hover +/// states, use [SdCard] instead. +class SdPanel extends StatelessWidget { + const SdPanel({super.key, required this.child, this.style}); + + final Widget child; + final BoxStyler? style; + + @override + Widget build(BuildContext context) { + final defaultStyle = BoxStyler() + .borderRadiusAll(Radius.circular(SdTokens.cardRadius)) + .color(FortalTokens.gray3()) + .borderAll(color: FortalTokens.gray5()) + .paddingAll(SdTokens.cardPadding); + return Box(style: defaultStyle.merge(style), child: child); + } +} + +/// Selectable card with hover and selection states. +/// +/// Use for interactive cards that can be selected. For static containers, +/// use [SdPanel] instead. +class SdCard extends StatelessWidget { + const SdCard({ + super.key, + required this.isSelected, + required this.child, + this.style, + }); + + final bool isSelected; + final Widget child; + final FlexBoxStyler? style; + + @override + Widget build(BuildContext context) { + // Base style with selection-aware colors + final bgColor = isSelected ? FortalTokens.accent5() : FortalTokens.gray3(); + final borderColor = isSelected + ? FortalTokens.accent7() + : FortalTokens.gray5(); + final hoverColor = isSelected + ? FortalTokens.accent6() + : FortalTokens.gray4(); + + // Use column so child gets width constraints (enables text wrapping) + final defaultStyle = FlexBoxStyler() + .column() + .borderRadiusAll(Radius.circular(SdTokens.cardRadius)) + .color(bgColor) + .borderAll(color: borderColor) + .paddingAll(SdTokens.cardPadding) + .onHovered(FlexBoxStyler().color(hoverColor)) + .animate(AnimationConfig.ease(SdTokens.motionFast)); + + return defaultStyle.merge(style)(children: [child]); + } +} + +/// Color circle widget for palette display. +class SdColorCircle extends StatelessWidget { + const SdColorCircle({ + super.key, + required this.color, + this.interactive = false, + this.style, + }); + + final Color color; + final bool interactive; + final BoxStyler? style; + + @override + Widget build(BuildContext context) { + var defaultStyle = BoxStyler() + .size(20, 20) + .color(color) + .shapeCircle( + side: BorderSideMix.color( + color, + ).width(3).strokeAlign(BorderSide.strokeAlignCenter), + ) + .shadowOnly( + color: FortalTokens.gray3(), + blurRadius: 0, + offset: Offset.zero, + spreadRadius: 4, + ); + + if (interactive) { + defaultStyle = defaultStyle + .onHovered(BoxStyler().shadowOnly(color: FortalTokens.gray4())) + .animate(AnimationConfig.ease(SdTokens.motionFast)); + } + + return defaultStyle.merge(style).call(); + } +} + +/// Section header for wizard steps. +class SdSectionHeader extends StatelessWidget { + const SdSectionHeader({ + super.key, + required this.heading, + this.subheading, + this.style, + }); + + final String heading; + final String? subheading; + final FlexBoxStyler? style; + + @override + Widget build(BuildContext context) { + final defaultStyle = FlexBoxStyler() + .column() + .crossAxisAlignment(CrossAxisAlignment.start) + .spacing(8) + .marginBottom(16); + + return defaultStyle.merge(style)( + children: [ + SdTitle(heading, style: TextStyler().fontWeight(FontWeight.w600)), + if (subheading != null && subheading!.isNotEmpty) + SdCaption(subheading!), + ], + ); + } +} diff --git a/packages/genui/lib/src/ui/components/sd_feedback.dart b/packages/genui/lib/src/ui/components/sd_feedback.dart new file mode 100644 index 00000000..d0943857 --- /dev/null +++ b/packages/genui/lib/src/ui/components/sd_feedback.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +/// Pre-styled Fortal spinner. +class SdSpinner extends StatelessWidget { + const SdSpinner({super.key, this.size = FortalSpinnerSize.size3}); + + final FortalSpinnerSize size; + + @override + Widget build(BuildContext context) { + return FortalSpinnerStyles.create(size: size).call(); + } +} + +/// Pre-styled badge for chips, tags, and labels. +/// +/// Usage: `SdBadge(label: 'New')` +/// Override: `SdBadge(label: 'New', style: FortalBadgeStyles.soft())` +class SdBadge extends StatelessWidget { + const SdBadge({super.key, required this.label, this.style}); + + final String label; + final RemixBadgeStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalBadgeStyles.surface().merge(style); + return RemixBadge(label: label, style: finalStyle); + } +} + +/// Pre-styled divider line. +/// +/// Usage: `SdDivider()` +/// Override: `SdDivider(style: FortalDividerStyles.create(size: FortalDividerSize.size2))` +class SdDivider extends StatelessWidget { + const SdDivider({super.key, this.style}); + + final RemixDividerStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalDividerStyles.create().merge(style); + return RemixDivider(style: finalStyle); + } +} + +/// Pre-styled callout for status messages and alerts. +/// +/// Usage: `SdCallout(text: 'Information message')` +/// Override: `SdCallout(text: 'Warning', style: FortalCalloutStyles.soft())` +class SdCallout extends StatelessWidget { + const SdCallout({super.key, this.text, this.icon, this.child, this.style}); + + final String? text; + final IconData? icon; + final Widget? child; + final RemixCalloutStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalCalloutStyles.surface().merge(style); + return RemixCallout( + text: text, + icon: icon, + style: finalStyle, + child: child, + ); + } +} diff --git a/packages/genui/lib/src/ui/components/sd_form.dart b/packages/genui/lib/src/ui/components/sd_form.dart new file mode 100644 index 00000000..cb55308c --- /dev/null +++ b/packages/genui/lib/src/ui/components/sd_form.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +/// Pre-styled surface checkbox. +class SdCheckbox extends StatelessWidget { + const SdCheckbox({ + super.key, + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.autofocus = false, + this.semanticLabel, + this.style, + }); + + final bool? selected; + final ValueChanged? onChanged; + final bool enabled; + final bool tristate; + final bool autofocus; + + /// Semantic label for accessibility. + final String? semanticLabel; + final RemixCheckboxStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalCheckboxStyles.surface().merge(style); + final checkbox = RemixCheckbox( + selected: selected, + onChanged: onChanged, + enabled: enabled, + tristate: tristate, + autofocus: autofocus, + style: finalStyle, + ); + + if (semanticLabel != null) { + return Semantics( + checked: selected, + label: semanticLabel, + enabled: enabled, + child: checkbox, + ); + } + return checkbox; + } +} + +/// Pre-styled surface switch. +class SdSwitch extends StatelessWidget { + const SdSwitch({ + super.key, + required this.selected, + required this.onChanged, + this.enabled = true, + this.semanticLabel, + this.style, + }); + + final bool selected; + final ValueChanged onChanged; + final bool enabled; + + /// Semantic label for accessibility. + final String? semanticLabel; + final RemixSwitchStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalSwitchStyles.surface().merge(style); + final switchWidget = RemixSwitch( + selected: selected, + onChanged: onChanged, + enabled: enabled, + style: finalStyle, + ); + + if (semanticLabel != null) { + return Semantics( + toggled: selected, + label: semanticLabel, + enabled: enabled, + child: switchWidget, + ); + } + return switchWidget; + } +} + +/// Pre-styled surface slider. +class SdSlider extends StatelessWidget { + const SdSlider({ + super.key, + required this.value, + required this.onChanged, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.snapDivisions, + this.semanticLabel, + this.style, + }); + + final double value; + final ValueChanged onChanged; + final double min; + final double max; + final bool enabled; + final int? snapDivisions; + + /// Semantic label for accessibility. + final String? semanticLabel; + final RemixSliderStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalSliderStyles.surface().merge(style); + final slider = RemixSlider( + value: value, + onChanged: onChanged, + min: min, + max: max, + enabled: enabled, + snapDivisions: snapDivisions, + style: finalStyle, + ); + + if (semanticLabel != null) { + return Semantics( + slider: true, + value: '${value.round()}', + label: semanticLabel, + enabled: enabled, + child: slider, + ); + } + return slider; + } +} + +/// Pre-styled surface radio button. +/// +/// Must be used within an [SdRadioGroup]. +class SdRadio extends StatelessWidget { + const SdRadio({ + super.key, + required this.value, + this.enabled = true, + this.autofocus = false, + this.style, + }); + + final T value; + final bool enabled; + final bool autofocus; + final RemixRadioStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalRadioStyles.surface().merge(style); + return RemixRadio( + value: value, + enabled: enabled, + autofocus: autofocus, + style: finalStyle, + ); + } +} + +/// Radio group that manages selection state for [SdRadio] children. +class SdRadioGroup extends StatelessWidget { + const SdRadioGroup({ + super.key, + required this.groupValue, + required this.onChanged, + required this.child, + }); + + final T? groupValue; + final ValueChanged onChanged; + final Widget child; + + @override + Widget build(BuildContext context) { + return RemixRadioGroup( + groupValue: groupValue, + onChanged: onChanged, + child: child, + ); + } +} + +/// Pre-styled surface select dropdown. +class SdSelect extends StatelessWidget { + const SdSelect({ + super.key, + required this.items, + this.placeholder = 'Select...', + this.icon, + this.selectedValue, + this.onChanged, + this.enabled = true, + this.style, + }); + + final List> items; + final String placeholder; + final IconData? icon; + final T? selectedValue; + final ValueChanged? onChanged; + final bool enabled; + final RemixSelectStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalSelectStyles.surface().merge(style); + return IntrinsicWidth( + child: RemixSelect( + trigger: RemixSelectTrigger(placeholder: placeholder, icon: icon), + items: items.map((item) => item._toRemixSelectItem()).toList(), + selectedValue: selectedValue, + onChanged: onChanged, + enabled: enabled, + style: finalStyle, + ), + ); + } +} + +/// Pre-styled select item for use with [SdSelect]. +class SdSelectItem { + const SdSelectItem({ + required this.value, + required this.label, + this.enabled = true, + }); + + final T value; + final String label; + final bool enabled; + + RemixSelectItem _toRemixSelectItem() { + return RemixSelectItem( + value: value, + label: label, + enabled: enabled, + style: FortalSelectItemStyles.surface(), + ); + } +} + +/// Pre-styled surface text field. +class SdTextField extends StatelessWidget { + const SdTextField({ + super.key, + this.controller, + this.focusNode, + this.hintText, + this.label, + this.leading, + this.trailing, + this.onChanged, + this.onSubmitted, + this.textInputAction, + this.keyboardType, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.obscureText = false, + this.semanticLabel, + this.style, + }); + + final TextEditingController? controller; + final FocusNode? focusNode; + final String? hintText; + final String? label; + final Widget? leading; + final Widget? trailing; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final TextInputAction? textInputAction; + final TextInputType? keyboardType; + final int? maxLines; + final int? minLines; + final bool expands; + final bool enabled; + final bool readOnly; + final bool autofocus; + final bool obscureText; + + /// Semantic label for accessibility (screen readers, browser automation). + final String? semanticLabel; + final RemixTextFieldStyle? style; + + @override + Widget build(BuildContext context) { + final finalStyle = FortalTextFieldStyles.surface().merge(style); + final field = RemixTextField( + controller: controller, + focusNode: focusNode, + hintText: hintText, + label: label, + leading: leading, + trailing: trailing, + onChanged: onChanged, + onSubmitted: onSubmitted, + textInputAction: textInputAction, + keyboardType: keyboardType, + maxLines: maxLines, + minLines: minLines, + expands: expands, + enabled: enabled, + readOnly: readOnly, + autofocus: autofocus, + obscureText: obscureText, + style: finalStyle, + ); + + // Wrap with Semantics for web accessibility/automation + final effectiveLabel = semanticLabel ?? label ?? hintText; + if (effectiveLabel != null) { + return Semantics( + textField: true, + label: effectiveLabel, + enabled: enabled, + child: field, + ); + } + return field; + } +} diff --git a/packages/genui/lib/src/ui/components/sd_tokens.dart b/packages/genui/lib/src/ui/components/sd_tokens.dart new file mode 100644 index 00000000..9e758c89 --- /dev/null +++ b/packages/genui/lib/src/ui/components/sd_tokens.dart @@ -0,0 +1,10 @@ +/// Shared design tokens for Sd components. +abstract final class SdTokens { + static const double cardRadius = 8; + static const double cardInnerRadius = 7; + static const double cardPadding = 16; + static const double cardMinHeight = 140; + + static const motionFast = Duration(milliseconds: 200); + static const motionMedium = Duration(milliseconds: 300); +} diff --git a/packages/genui/lib/src/ui/components/sd_typography.dart b/packages/genui/lib/src/ui/components/sd_typography.dart new file mode 100644 index 00000000..e929c600 --- /dev/null +++ b/packages/genui/lib/src/ui/components/sd_typography.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +/// Large display text for main headings. +/// +/// Usage: `SdHeadline('Welcome')` +/// Override: `SdHeadline('Welcome', style: TextStyler().fontWeight(FontWeight.bold))` +class SdHeadline extends StatelessWidget { + const SdHeadline(this.text, {super.key, this.style}); + + final String text; + final TextStyler? style; + + @override + Widget build(BuildContext context) { + final defaultStyle = TextStyler() + .style(FortalTokens.text6.mix()) + .color(FortalTokens.gray12()); + return defaultStyle.merge(style).call(text); + } +} + +/// Section title text. +/// +/// Usage: `SdTitle('Settings')` +/// Override: `SdTitle('Settings', style: TextStyler().fontWeight(FontWeight.w600))` +class SdTitle extends StatelessWidget { + const SdTitle(this.text, {super.key, this.style}); + + final String text; + final TextStyler? style; + + @override + Widget build(BuildContext context) { + final defaultStyle = TextStyler() + .style(FortalTokens.text4.mix()) + .color(FortalTokens.gray12()); + return defaultStyle.merge(style).call(text); + } +} + +/// Regular body text. +/// +/// Usage: `SdBody('Description here')` +/// Override: `SdBody('Text', style: TextStyler().color(FortalTokens.accent12()))` +class SdBody extends StatelessWidget { + const SdBody(this.text, {super.key, this.style}); + + final String text; + final TextStyler? style; + + @override + Widget build(BuildContext context) { + final defaultStyle = TextStyler() + .style(FortalTokens.text3.mix()) + .color(FortalTokens.gray11()); + return defaultStyle.merge(style).call(text); + } +} + +/// Small secondary text for labels and captions. +/// +/// Usage: `SdCaption('Updated 2 hours ago')` +/// Override: `SdCaption('Text', style: TextStyler().color(FortalTokens.accent11()))` +class SdCaption extends StatelessWidget { + const SdCaption(this.text, {super.key, this.style}); + + final String text; + final TextStyler? style; + + @override + Widget build(BuildContext context) { + final defaultStyle = TextStyler() + .style(FortalTokens.text2.mix()) + .color(FortalTokens.gray10()); + return defaultStyle.merge(style).call(text); + } +} + +/// Subtle hint text. +/// +/// Usage: `SdHint('Enter your name')` +/// Override: `SdHint('Hint', style: TextStyler().fontStyle(FontStyle.italic))` +class SdHint extends StatelessWidget { + const SdHint(this.text, {super.key, this.style}); + + final String text; + final TextStyler? style; + + @override + Widget build(BuildContext context) { + final defaultStyle = TextStyler() + .style(FortalTokens.text2.mix()) + .color(FortalTokens.gray8()); + return defaultStyle.merge(style).call(text); + } +} diff --git a/packages/genui/lib/src/ui/ui.dart b/packages/genui/lib/src/ui/ui.dart new file mode 100644 index 00000000..74605ca4 --- /dev/null +++ b/packages/genui/lib/src/ui/ui.dart @@ -0,0 +1,4 @@ +export 'components/sd_components.dart'; +export 'widgets/catalog_next_button.dart'; +export 'widgets/header.dart'; +export 'widgets/wizard_loading_state.dart'; diff --git a/packages/genui/lib/src/ui/widgets/catalog_next_button.dart b/packages/genui/lib/src/ui/widgets/catalog_next_button.dart new file mode 100644 index 00000000..8072b423 --- /dev/null +++ b/packages/genui/lib/src/ui/widgets/catalog_next_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import '../ui.dart'; + +class CatalogNextButton extends StatelessWidget { + const CatalogNextButton({super.key, required this.onPressed}); + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: SdIconButton( + icon: Icons.arrow_forward_rounded, + onPressed: onPressed, + semanticLabel: 'Next step', + ), + ), + ); + } +} diff --git a/packages/genui/lib/src/ui/widgets/header.dart b/packages/genui/lib/src/ui/widgets/header.dart new file mode 100644 index 00000000..a1d92aea --- /dev/null +++ b/packages/genui/lib/src/ui/widgets/header.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +/// Height of the header content area (excluding system padding). +const _kHeaderContentHeight = 60.0; + +class SdHeader extends StatelessWidget implements PreferredSizeWidget { + const SdHeader({super.key, this.leading, this.trailing}); + + final Widget? leading; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final flex = FlexBoxStyler() + .borderBottom(color: FortalTokens.gray3()) + .padding(.symmetric(horizontal: 24, vertical: 12)) + .height(_kHeaderContentHeight) + .crossAxisAlignment(.center) + .mainAxisAlignment(.spaceBetween); + + return SafeArea( + bottom: false, + child: flex( + children: [ + SizedBox(child: leading), + SizedBox(child: trailing), + ], + ), + ); + } + + @override + Size get preferredSize { + // Content height only; Scaffold accounts for top system padding. + return const Size.fromHeight(_kHeaderContentHeight); + } +} diff --git a/packages/genui/lib/src/ui/widgets/wizard_loading_state.dart b/packages/genui/lib/src/ui/widgets/wizard_loading_state.dart new file mode 100644 index 00000000..0ac6d7fc --- /dev/null +++ b/packages/genui/lib/src/ui/widgets/wizard_loading_state.dart @@ -0,0 +1,20 @@ +import 'package:flutter/widgets.dart'; + +/// InheritedWidget that provides loading state to wizard card components. +/// +/// This allows catalog cards to show loading indicators on their Next buttons +/// when the AI is processing a response. +class WizardLoadingState extends InheritedWidget { + final bool isLoading; + + const WizardLoadingState({ + super.key, + required this.isLoading, + required super.child, + }); + + @override + bool updateShouldNotify(WizardLoadingState oldWidget) { + return isLoading != oldWidget.isLoading; + } +} diff --git a/packages/genui/lib/src/utils/color_utils.dart b/packages/genui/lib/src/utils/color_utils.dart new file mode 100644 index 00000000..96fc4414 --- /dev/null +++ b/packages/genui/lib/src/utils/color_utils.dart @@ -0,0 +1,42 @@ +import 'dart:developer' as developer; +import 'dart:ui'; + +/// Result of parsing a hex color string. +typedef ColorParseResult = ({Color color, bool isValid}); + +/// Default fallback color when parsing fails. +const _fallbackGray = Color(0xFF808080); + +/// Parses a hex color string and returns both the color and validity status. +/// +/// Supports both 6-digit (RGB) and 8-digit (ARGB) hex strings. +/// The '#' prefix is optional. +/// +/// Returns a [ColorParseResult] with: +/// - `color`: The parsed color, or fallback gray if invalid +/// - `isValid`: Whether the hex string was successfully parsed +/// +/// Examples: +/// ```dart +/// parseHexColor('#FF5733') // (color: Color(0xFFFF5733), isValid: true) +/// parseHexColor('FF5733') // (color: Color(0xFFFF5733), isValid: true) +/// parseHexColor('80FF5733') // (color: Color(0x80FF5733), isValid: true) +/// parseHexColor('invalid') // (color: Color(0xFF808080), isValid: false) +/// ``` +ColorParseResult parseHexColor(String hex) { + try { + hex = hex.replaceFirst('#', '').toUpperCase(); + if (hex.length == 6) hex = 'FF$hex'; + if (hex.length != 8) { + throw FormatException('Invalid hex color length: $hex'); + } + return (color: Color(int.parse(hex, radix: 16)), isValid: true); + } catch (e) { + developer.log('Invalid hex color: $hex', name: 'ColorUtils', error: e); + return (color: _fallbackGray, isValid: false); + } +} + +/// Convenience wrapper that returns only the color. +/// Use [parseHexColor] if you need to check validity. +Color hexToColor(String hex) => parseHexColor(hex).color; diff --git a/packages/genui/lib/src/utils/deck_style_service.dart b/packages/genui/lib/src/utils/deck_style_service.dart new file mode 100644 index 00000000..163684f1 --- /dev/null +++ b/packages/genui/lib/src/utils/deck_style_service.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:signals/signals_flutter.dart'; +import '../ai/schemas/deck_schemas.dart'; +import '../constants/paths.dart'; +import '../debug_logger.dart'; + +/// Service for reading style configuration from superdeck.json. +/// +/// Provides both async and sync access to the persisted style. +/// Call [preloadStyle] at app startup to populate the cache, +/// then [readStyleFromCache] can be used synchronously in routing. +class DeckStyleService { + static final _styleSignal = signal(null); + static bool _preloaded = false; + + static ReadonlySignal get style => _styleSignal; + + /// Preloads style from .superdeck/superdeck.json asynchronously. + /// + /// Call this at app startup to populate the cache before routing. + /// This is non-blocking and won't affect UI thread. + static Future preloadStyle() async { + if (_preloaded) return; + + try { + final file = File(Paths.deckJsonPath); + if (!await file.exists()) { + // File doesn't exist - expected case, mark preloaded + _preloaded = true; + return; + } + + final content = await file.readAsString(); + final data = jsonDecode(content) as Map; + _styleSignal.value = DeckStyleType.safeParse(data['style']).getOrNull(); + _preloaded = true; + } on FormatException catch (e) { + // Invalid JSON - permanent error, mark preloaded + debugLog.log('STYLE', 'Invalid JSON in deck file: $e'); + _styleSignal.value = null; + _preloaded = true; + } catch (e) { + // I/O or other transient error - allow retry + debugLog.log('STYLE', 'Could not preload style (will retry): $e'); + // Don't set _preloaded = true, allowing retry on next call + } + } + + /// Reads style from the in-memory cache. + /// + /// Returns null if cache is empty or style was not present. + /// Call [preloadStyle] at app startup to populate the cache. + static DeckStyleType? readStyleFromCache() { + return _styleSignal.value; + } + + /// Updates style and notifies listeners. + static void setStyle(DeckStyleType? style) { + _styleSignal.value = style; + _preloaded = true; + } + + /// Parses [rawStyle] and updates cache only if valid. + /// + /// Returns parsed style when valid, null when [rawStyle] is null or invalid. + static DeckStyleType? setStyleFromJson(Object? rawStyle) { + if (rawStyle == null) { + setStyle(null); + return null; + } + + final parsed = DeckStyleType.safeParse(rawStyle).getOrNull(); + if (parsed == null) { + return null; + } + + setStyle(parsed); + return parsed; + } + + /// Updates the cached style. + /// + /// Call this after generation to update the in-memory cache + /// without requiring a disk read. + static void updateCache(DeckStyleType? style) { + setStyle(style); + } + + /// Clears the cached style. + /// + /// Call this after generating a new presentation to ensure + /// fresh style is read on next access. + static void clearCache() { + _styleSignal.value = null; + _preloaded = false; + } +} diff --git a/packages/genui/lib/src/utils/font_utils.dart b/packages/genui/lib/src/utils/font_utils.dart new file mode 100644 index 00000000..28b5715d --- /dev/null +++ b/packages/genui/lib/src/utils/font_utils.dart @@ -0,0 +1,13 @@ +import 'package:google_fonts/google_fonts.dart'; + +/// Safely loads a Google Font and returns its family name. +/// +/// Returns `null` if the font cannot be loaded (e.g., invalid name, +/// network error, or font not available in GoogleFonts package). +String? tryGetGoogleFontFamily(String fontName) { + try { + return GoogleFonts.getFont(fontName).fontFamily; + } catch (_) { + return null; + } +} diff --git a/packages/genui/lib/src/utils/hash_utils.dart b/packages/genui/lib/src/utils/hash_utils.dart new file mode 100644 index 00000000..1726ed63 --- /dev/null +++ b/packages/genui/lib/src/utils/hash_utils.dart @@ -0,0 +1,28 @@ +/// Generates a short, human-readable hash from a string value. +/// +/// Produces an 8-character alphanumeric string that can be used for +/// generating unique identifiers (e.g., for slide keys, file names). +/// +/// The hash is deterministic - the same input always produces the same output. +String generateValueHash(String valueToHash) { + const characters = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + var hash = 0; + + for (var i = 0; i < valueToHash.length; i++) { + final charCode = valueToHash.codeUnitAt(i); + hash = (hash * 31 + charCode) % 2147483647; + } + + var shortId = ''; + final base = characters.length; + var remainingHash = hash; + + for (var i = 0; i < 8; i++) { + shortId += characters[remainingHash % base]; + remainingHash = (remainingHash * 31 + hash + i) % 2147483647; + } + + return shortId; +} diff --git a/packages/genui/lib/src/utils/style_builder.dart b/packages/genui/lib/src/utils/style_builder.dart new file mode 100644 index 00000000..a57d040d --- /dev/null +++ b/packages/genui/lib/src/utils/style_builder.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:mix/mix.dart'; +import 'package:superdeck/superdeck.dart'; +import '../ai/schemas/deck_schemas.dart'; +import './color_utils.dart'; + +/// Builds [DeckOptions] from AI-generated style configuration. +/// +/// Takes the style object from [DeckGenerationResult] and creates +/// [DeckOptions] with appropriate [SlideStyle] overrides for colors, +/// fonts, and background. +DeckOptions buildDeckOptionsFromStyle(DeckStyleType? style) { + if (style == null) return const DeckOptions(); + + final colors = style.colors; + final fonts = style.fonts; + + // Extract colors with new semantic names + final backgroundHex = colors.background; + final headingHex = colors.heading; + final bodyHex = colors.body; + + final headingColor = hexToColor(headingHex); + final bodyColor = hexToColor(bodyHex); + final backgroundColor = hexToColor(backgroundHex); + + // Font enums already carry the concrete family names to use in text styles. + final headlineFontFamily = fonts.headline.fontFamily; + final bodyFontFamily = fonts.body.fontFamily; + + TextStyler headingStyler() { + var styler = TextStyler().style(TextStyleMix(color: headingColor)); + if (headlineFontFamily.isNotEmpty) { + styler = styler.style(TextStyleMix(fontFamily: headlineFontFamily)); + } + return styler; + } + + // Build body text styler with color and optional font + TextStyler bodyStyler() { + var styler = TextStyler().style(TextStyleMix(color: bodyColor)); + if (bodyFontFamily.isNotEmpty) { + styler = styler.style(TextStyleMix(fontFamily: bodyFontFamily)); + } + return styler; + } + + // Create style with color and font overrides + final colorOverrideStyle = SlideStyle( + // Headings use primary color + headline font + h1: headingStyler(), + h2: headingStyler(), + h3: headingStyler(), + h4: headingStyler(), + h5: headingStyler(), + h6: headingStyler(), + + // Body text uses secondary color + body font + p: bodyStyler(), + + // Links and emphasis use body/heading colors + a: TextStyle(color: bodyColor), + strong: TextStyle(color: headingColor), + + // List styling - bullets and text use body color + list: MarkdownListStyle( + bullet: TextStyler().style(TextStyleMix(color: bodyColor)), + text: bodyStyler(), + ), + + // Table styling - colors for text, borders, and cell backgrounds + table: MarkdownTableStyle( + headStyle: TextStyle(color: headingColor, fontWeight: FontWeight.bold), + bodyStyle: TextStyle(color: bodyColor), + cellPadding: const EdgeInsets.all(12), + border: TableBorder.all(color: bodyColor, width: 2), + cellDecoration: BoxDecoration(color: bodyColor.withValues(alpha: 0.1)), + ), + + // Blockquote styling - left bar uses body color + blockquote: MarkdownBlockquoteStyle( + textStyle: TextStyle(color: bodyColor, fontSize: 32), + padding: const EdgeInsets.only(bottom: 12, left: 30), + decoration: BoxDecoration( + border: Border(left: BorderSide(color: bodyColor, width: 4)), + ), + ), + + // Horizontal rule uses body color + horizontalRuleDecoration: BoxDecoration( + border: Border(bottom: BorderSide(color: bodyColor, width: 2)), + ), + + // Slide background color (if specified) + slideContainer: BoxStyler().color(backgroundColor), + ); + + return DeckOptions(baseStyle: colorOverrideStyle); +} diff --git a/packages/genui/lib/src/viewmodel_scope.dart b/packages/genui/lib/src/viewmodel_scope.dart new file mode 100644 index 00000000..a68d3461 --- /dev/null +++ b/packages/genui/lib/src/viewmodel_scope.dart @@ -0,0 +1,71 @@ +import 'package:flutter/widgets.dart'; + +abstract interface class Disposable { + void dispose(); +} + +/// A generic InheritedWidget for providing ViewModels to the widget tree. +/// +/// Usage: +/// ```dart +/// ViewModelScope( +/// create: () => MyViewModel(), +/// child: MyWidget(), +/// ) +/// ``` +class ViewModelScope extends StatefulWidget { + final T Function() create; + final Widget child; + + const ViewModelScope({super.key, required this.create, required this.child}); + + /// Retrieves the ViewModel of type [T] from the nearest ancestor. + static T of(BuildContext context) { + final element = context + .getElementForInheritedWidgetOfExactType<_ViewModelInherited>(); + final widget = element?.widget as _ViewModelInherited?; + assert(widget != null, 'No ViewModelScope<$T> found in context'); + return widget!.viewModel; + } + + @override + State> createState() => _ViewModelScopeState(); +} + +class _ViewModelScopeState extends State> { + late final T _viewModel; + + @override + void initState() { + super.initState(); + _viewModel = widget.create(); + } + + @override + void dispose() { + if (_viewModel is Disposable) { + (_viewModel as Disposable).dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _ViewModelInherited(viewModel: _viewModel, child: widget.child); + } +} + +class _ViewModelInherited extends InheritedWidget { + final T viewModel; + + const _ViewModelInherited({required this.viewModel, required super.child}); + + @override + bool updateShouldNotify(_ViewModelInherited oldWidget) => false; +} + +/// Extension to mirror Provider's context.read API. +extension ViewModelScopeExtension on BuildContext { + /// Retrieves the ViewModel of type [T] without listening to changes. + T read() => ViewModelScope.of(this); +} diff --git a/packages/genui/lib/superdeck_genui.dart b/packages/genui/lib/superdeck_genui.dart new file mode 100644 index 00000000..7913c779 --- /dev/null +++ b/packages/genui/lib/superdeck_genui.dart @@ -0,0 +1,35 @@ +library; + +// Chat +export 'src/chat/chat_message.dart'; +export 'src/chat/chat_viewmodel.dart'; +export 'src/chat/view/chat_screen.dart'; + +// AI Catalog & Wizard +export 'src/ai/catalog/catalog.dart'; +export 'src/ai/wizard_context.dart'; + +// AI Services +export 'src/ai/services/services.dart'; + +// AI Prompts +export 'src/ai/prompts/prompt_registry.dart'; +export 'src/ai/prompts/examples_loader.dart'; + +// Presentation +export 'src/presentation/presentation_viewmodel.dart'; +export 'src/presentation/view/creating_presentation_screen.dart'; +export 'src/presentation/view/presentation_deck_host.dart'; + +// Routes +export 'src/routes.dart'; + +// UI Components +export 'src/ui/ui.dart'; + +// Utilities +export 'src/viewmodel_scope.dart'; +export 'src/env_config.dart'; +export 'src/path_service.dart'; +export 'src/debug_logger.dart'; +export 'src/utils/deck_style_service.dart'; diff --git a/packages/genui/pubspec.yaml b/packages/genui/pubspec.yaml new file mode 100644 index 00000000..7e3923f1 --- /dev/null +++ b/packages/genui/pubspec.yaml @@ -0,0 +1,53 @@ +name: superdeck_genui +description: AI-powered presentation wizard for SuperDeck using GenUI and Gemini. +version: 1.0.0 +publish_to: none +homepage: https://github.com/leoafarias/superdeck + +environment: + sdk: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" + +dependencies: + flutter: + sdk: flutter + mix: ^2.0.0-rc.0 + remix: ^0.1.0-beta.2 + genui: ^0.6.1 + genui_google_generative_ai: ^0.6.1 + json_schema_builder: ^0.1.3 + signals: ^6.2.0 + signals_flutter: ^6.2.0 + gpt_markdown: ^1.1.4 + go_router: ^14.2.7 + google_fonts: ^6.2.1 + superdeck: ^1.0.0 + superdeck_core: ^1.0.0 + flutter_dotenv: ^6.0.0 + google_cloud_ai_generativelanguage_v1beta: ^0.3.0 + path: ^1.9.0 + path_provider: ^2.1.0 + dotprompt_dart: ^0.5.0 + ack: ^1.0.0-beta.7 + ack_annotations: ^1.0.0-beta.7 + ack_json_schema_builder: + git: + url: https://github.com/btwld/ack + path: packages/ack_json_schema_builder + ref: main + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + dart_code_metrics_presets: ^2.19.0 + ack_generator: ^1.0.0-beta.7 + build_runner: ^2.4.0 + mocktail: ^1.0.4 + +flutter: + uses-material-design: true + assets: + - assets/prompts/ + - assets/prompts/partials/ + - assets/examples/ diff --git a/packages/genui/test/chat/models/chat_message_test.dart b/packages/genui/test/chat/models/chat_message_test.dart new file mode 100644 index 00000000..2d0298c7 --- /dev/null +++ b/packages/genui/test/chat/models/chat_message_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/chat/chat_message.dart'; + +void main() { + group('SuperdeckChatMessage', () { + group('SuperdeckUserMessage', () { + test('stores text correctly', () { + const message = SuperdeckUserMessage('Hello world'); + expect(message.text, 'Hello world'); + }); + }); + + group('SuperdeckAiMessage', () { + test('stores text correctly', () { + const message = SuperdeckAiMessage('AI response'); + expect(message.text, 'AI response'); + }); + }); + + group('SuperdeckDebugMessage', () { + test('stores text correctly', () { + const message = SuperdeckDebugMessage('Debug info'); + expect(message.text, 'Debug info'); + }); + }); + + group('SuperdeckJsonDebugMessage', () { + test('formats valid JSON with pretty printing', () { + final message = SuperdeckJsonDebugMessage('{"key":"value"}'); + expect(message.text, contains('```json')); + expect(message.text, contains('"key"')); + expect(message.text, contains('"value"')); + }); + + test('preserves invalid JSON as-is', () { + final message = SuperdeckJsonDebugMessage('not valid json'); + expect(message.text, 'not valid json'); + }); + + test('handles nested JSON', () { + final message = SuperdeckJsonDebugMessage( + '{"outer":{"inner":"value"}}', + ); + expect(message.text, contains('"outer"')); + expect(message.text, contains('"inner"')); + }); + + test('handles empty object', () { + final message = SuperdeckJsonDebugMessage('{}'); + expect(message.text, contains('```json')); + expect(message.text, contains('{}')); + }); + }); + + group('exhaustive pattern matching', () { + test('switch expression covers all cases', () { + String describe(SuperdeckChatMessage message) { + return switch (message) { + SuperdeckUserMessage() => 'user', + SuperdeckAiMessage() => 'ai', + SuperdeckDebugMessage() => 'debug', + SuperdeckJsonDebugMessage() => 'json_debug', + }; + } + + expect(describe(const SuperdeckUserMessage('test')), 'user'); + expect(describe(const SuperdeckAiMessage('test')), 'ai'); + expect(describe(const SuperdeckDebugMessage('test')), 'debug'); + expect(describe(SuperdeckJsonDebugMessage('{"a":1}')), 'json_debug'); + }); + }); + }); + + group('UserActionPayload', () { + group('tryParse', () { + test('parses valid user action', () { + const json = ''' + { + "userAction": { + "name": "select_option", + "context": {"message": "Selected A"} + } + } + '''; + + final payload = UserActionPayload.tryParse(json); + + expect(payload, isNotNull); + expect(payload!.actionName, 'select_option'); + expect(payload.displayMessage, 'Selected A'); + }); + + test('returns null for invalid JSON', () { + final payload = UserActionPayload.tryParse('not json'); + expect(payload, isNull); + }); + + test('returns null for missing userAction key', () { + final payload = UserActionPayload.tryParse('{"other": "data"}'); + expect(payload, isNull); + }); + + test('returns null for missing name', () { + final payload = UserActionPayload.tryParse( + '{"userAction": {"context": {}}}', + ); + expect(payload, isNull); + }); + + test('returns null for non-string name', () { + final payload = UserActionPayload.tryParse( + '{"userAction": {"name": 123}}', + ); + expect(payload, isNull); + }); + + test('handles missing context gracefully', () { + const json = '{"userAction": {"name": "action"}}'; + final payload = UserActionPayload.tryParse(json); + + expect(payload, isNotNull); + expect(payload!.actionName, 'action'); + expect(payload.context, isEmpty); + }); + + test('uses actionName as fallback displayMessage', () { + const json = '{"userAction": {"name": "my_action", "context": {}}}'; + final payload = UserActionPayload.tryParse(json); + + expect(payload, isNotNull); + expect(payload!.displayMessage, 'my_action'); + }); + + test('returns null for array input', () { + final payload = UserActionPayload.tryParse('[1, 2, 3]'); + expect(payload, isNull); + }); + + test('returns null when userAction is not a map', () { + final payload = UserActionPayload.tryParse('{"userAction": "string"}'); + expect(payload, isNull); + }); + }); + }); +} diff --git a/packages/genui/test/chat/viewmodel/chat_viewmodel_test.dart b/packages/genui/test/chat/viewmodel/chat_viewmodel_test.dart new file mode 100644 index 00000000..1aeeb76e --- /dev/null +++ b/packages/genui/test/chat/viewmodel/chat_viewmodel_test.dart @@ -0,0 +1,383 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; + +import 'package:superdeck_genui/src/chat/chat_message.dart'; +import 'package:superdeck_genui/src/chat/chat_viewmodel.dart'; +import 'package:superdeck_genui/src/chat/view/widgets/model_select.dart'; +import 'package:superdeck_genui/src/ai/prompts/prompt_registry.dart'; + +/// A mock [A2uiMessageProcessor] for testing. +class MockA2uiMessageProcessor implements A2uiMessageProcessor { + final _onSubmitController = + StreamController.broadcast(); + final _surfaceUpdatesController = StreamController.broadcast(); + final _surfaces = >{}; + final _dataModels = {}; + + @override + Stream get onSubmit => _onSubmitController.stream; + + @override + Stream get surfaceUpdates => _surfaceUpdatesController.stream; + + @override + Iterable get catalogs => []; + + @override + Map> get surfaces => _surfaces; + + @override + Map get dataModels => Map.unmodifiable(_dataModels); + + @override + DataModel dataModelForSurface(String surfaceId) { + return _dataModels.putIfAbsent(surfaceId, DataModel.new); + } + + @override + void handleMessage(A2uiMessage message) {} + + @override + void handleUiEvent(UiEvent event) {} + + @override + ValueNotifier getSurfaceNotifier(String surfaceId) { + return _surfaces.putIfAbsent( + surfaceId, + () => ValueNotifier(null), + ); + } + + @override + void dispose() { + _onSubmitController.close(); + _surfaceUpdatesController.close(); + for (final notifier in _surfaces.values) { + notifier.dispose(); + } + } +} + +/// A mock [ContentGenerator] for testing. +class MockContentGenerator implements ContentGenerator { + final _a2uiMessageController = StreamController.broadcast(); + final _textResponseController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + final _isProcessing = ValueNotifier(false); + + @override + Stream get a2uiMessageStream => _a2uiMessageController.stream; + + @override + Stream get textResponseStream => _textResponseController.stream; + + @override + Stream get errorStream => _errorController.stream; + + @override + ValueListenable get isProcessing => _isProcessing; + + @override + Future sendRequest( + ChatMessage message, { + Iterable? history, + A2UiClientCapabilities? clientCapabilities, + }) async {} + + @override + void dispose() { + _a2uiMessageController.close(); + _textResponseController.close(); + _errorController.close(); + _isProcessing.dispose(); + } +} + +/// A mock [GenUiConversation] that performs no async operations. +/// +/// Explicitly implements all members to ensure tests fail at compile time +/// when the [GenUiConversation] class changes. +class MockGenUiConversation implements GenUiConversation { + MockGenUiConversation() + : _processor = MockA2uiMessageProcessor(), + _generator = MockContentGenerator(); + + final MockA2uiMessageProcessor _processor; + final MockContentGenerator _generator; + final _isProcessing = ValueNotifier(false); + final _conversation = ValueNotifier>([]); + bool _isDisposed = false; + + @override + ValueListenable get isProcessing => _isProcessing; + + @override + ValueListenable> get conversation => _conversation; + + @override + Future sendRequest(ChatMessage message) async { + // No-op for tests - don't trigger async operations + } + + @override + void dispose() { + _isDisposed = true; + _isProcessing.dispose(); + _conversation.dispose(); + _processor.dispose(); + _generator.dispose(); + } + + bool get isDisposed => _isDisposed; + + @override + GenUiHost get host => _processor; + + @override + A2uiMessageProcessor get a2uiMessageProcessor => _processor; + + @override + ContentGenerator get contentGenerator => _generator; + + @override + ValueNotifier surface(String surfaceId) { + return _processor.getSurfaceNotifier(surfaceId); + } + + @override + ValueChanged? get onSurfaceAdded => null; + + @override + ValueChanged? get onSurfaceDeleted => null; + + @override + ValueChanged? get onSurfaceUpdated => null; + + @override + ValueChanged? get onTextResponse => null; + + @override + ValueChanged? get onError => null; +} + +/// A mock builder that creates [MockGenUiConversation] instances. +class MockConversationBuilder { + MockGenUiConversation? lastCreatedConversation; + + GenUiConversation call({ + required ContentGenerator contentGenerator, + required A2uiMessageProcessor a2uiMessageProcessor, + required ValueChanged? onTextResponse, + required ValueChanged? onError, + required ValueChanged? onSurfaceAdded, + required ValueChanged? onSurfaceUpdated, + required ValueChanged? onSurfaceDeleted, + }) { + lastCreatedConversation = MockGenUiConversation(); + return lastCreatedConversation!; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late ChatViewModel viewModel; + late MockConversationBuilder mockConversationBuilder; + var viewModelDisposedInTest = false; + + setUpAll(() async { + // Load test environment variables + dotenv.loadFromString(envString: 'GOOGLE_AI_API_KEY=test_api_key'); + + // Load PromptRegistry with a minimal test prompt + PromptRegistry.instance.loadForTest( + prompts: {'wizard_system': 'Test system prompt for wizard'}, + ); + }); + + tearDownAll(() { + PromptRegistry.instance.reset(); + }); + + setUp(() { + viewModelDisposedInTest = false; + mockConversationBuilder = MockConversationBuilder(); + viewModel = ChatViewModel( + conversationBuilder: mockConversationBuilder.call, + ); + }); + + tearDown(() { + if (!viewModelDisposedInTest) { + viewModel.dispose(); + } + }); + + group('ChatViewModel', () { + group('initial state', () { + test('surfaceIds should be empty list', () { + expect(viewModel.surfaceIds.value, isEmpty); + }); + + test('hasConversationStarted should be false', () { + expect(viewModel.hasConversationStarted.value, isFalse); + }); + + test('messages should be empty', () { + expect(viewModel.messages.value, isEmpty); + }); + + test('isThinking should return false', () { + expect(viewModel.isThinking.value, isFalse); + }); + }); + + group('sendMessage', () { + test('should not start conversation when message is empty', () { + viewModel.sendMessage(''); + + expect(viewModel.hasConversationStarted.value, isFalse); + expect(viewModel.messages.value, isEmpty); + }); + + test('should start conversation when message is not empty', () { + viewModel.sendMessage('Hello'); + + expect(viewModel.hasConversationStarted.value, isTrue); + expect(viewModel.messages.value, isNotEmpty); + }); + + test('should add user message to messages list', () { + viewModel.sendMessage('Hello'); + + expect(viewModel.messages.value.length, 1); + expect(viewModel.messages.value.first, isA()); + expect( + (viewModel.messages.value.first as SuperdeckUserMessage).text, + 'Hello', + ); + }); + + test('should add multiple messages to messages list', () { + viewModel.sendMessage('First message'); + viewModel.sendMessage('Second message'); + + expect(viewModel.messages.value.length, 2); + }); + + test('should create conversation via builder', () { + viewModel.sendMessage('Hello'); + + expect(mockConversationBuilder.lastCreatedConversation, isNotNull); + }); + }); + + group('restartConversation', () { + test('should clear conversation and messages', () { + viewModel.sendMessage('Hello'); + expect(viewModel.hasConversationStarted.value, isTrue); + + viewModel.restartConversation(); + + expect(viewModel.hasConversationStarted.value, isFalse); + expect(viewModel.messages.value, isEmpty); + }); + + test('should clear surfaceIds', () { + viewModel.sendMessage('Hello'); + viewModel.surfaceIds.value = [ + ...viewModel.surfaceIds.value, + 'surface-1', + ]; + expect(viewModel.surfaceIds.value, isNotEmpty); + + viewModel.restartConversation(); + + expect(viewModel.surfaceIds.value, isEmpty); + }); + + test('should dispose previous conversation', () { + viewModel.sendMessage('Hello'); + final conversation = mockConversationBuilder.lastCreatedConversation!; + + viewModel.restartConversation(); + + expect(conversation.isDisposed, isTrue); + }); + }); + + group('dispose', () { + // These tests create their own viewModel to avoid double-disposal + // from the shared tearDown. + + test('should dispose conversation when present', () { + final localBuilder = MockConversationBuilder(); + final localViewModel = ChatViewModel( + conversationBuilder: localBuilder.call, + ); + + localViewModel.sendMessage('Hello'); + final conversation = localBuilder.lastCreatedConversation!; + + localViewModel.dispose(); + + expect(conversation.isDisposed, isTrue); + }); + + test('should not throw when no conversation exists', () { + final localViewModel = ChatViewModel( + conversationBuilder: mockConversationBuilder.call, + ); + + expect(() => localViewModel.dispose(), returnsNormally); + }); + }); + + group('model', () { + test('should be able to change model', () { + // Pick a different model than current + final newModel = viewModel.model.value == GeminiModels.gemini25Pro + ? GeminiModels.gemini25Flash + : GeminiModels.gemini25Pro; + + viewModel.model.value = newModel; + + expect(viewModel.model.value, newModel); + }); + }); + + group('surfaceIds', () { + test('should be able to add surface id', () { + viewModel.surfaceIds.value = [ + ...viewModel.surfaceIds.value, + 'surface-1', + ]; + + expect(viewModel.surfaceIds.value, contains('surface-1')); + }); + + test('should be able to remove surface id', () { + viewModel.surfaceIds.value = ['surface-1', 'surface-2']; + + viewModel.surfaceIds.value = viewModel.surfaceIds.value + .where((id) => id != 'surface-1') + .toList(); + + expect(viewModel.surfaceIds.value, isNot(contains('surface-1'))); + expect(viewModel.surfaceIds.value, contains('surface-2')); + }); + + test('should be able to clear all surface ids', () { + viewModel.surfaceIds.value = ['surface-1', 'surface-2']; + + viewModel.surfaceIds.value = []; + + expect(viewModel.surfaceIds.value, isEmpty); + }); + }); + }); +} diff --git a/packages/genui/test/core/ai/catalog/ask_user_checkbox_test.dart b/packages/genui/test/core/ai/catalog/ask_user_checkbox_test.dart new file mode 100644 index 00000000..8aab3b58 --- /dev/null +++ b/packages/genui/test/core/ai/catalog/ask_user_checkbox_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/test/validation.dart'; + +import 'package:superdeck_genui/src/ai/catalog/catalog.dart'; + +void main() { + group('AskUserCheckbox Schema', () { + test('parses valid checkbox data', () { + final data = { + 'question': 'What topics?', + 'items': ['History', 'Current State', 'Future'], + 'minSelections': 1, + 'maxSelections': 2, + 'action': {'name': 'submit_answer', 'context': []}, + }; + + final parsed = AskUserCheckboxType.parse(data); + expect(parsed.question, 'What topics?'); + expect(parsed.items, hasLength(3)); + expect(parsed.minSelections, 1); + expect(parsed.maxSelections, 2); + }); + + test('optional fields default to null', () { + final data = { + 'question': 'Pick topics', + 'items': ['A', 'B'], + 'action': {'name': 'submit', 'context': []}, + }; + + final parsed = AskUserCheckboxType.parse(data); + expect(parsed.description, isNull); + expect(parsed.selectedItems, isNull); + expect(parsed.minSelections, isNull); + expect(parsed.maxSelections, isNull); + }); + + test('parses selectedItems', () { + final data = { + 'question': 'Pick topics', + 'items': ['A', 'B', 'C'], + 'selectedItems': ['A', 'C'], + 'action': {'name': 'submit', 'context': []}, + }; + + final parsed = AskUserCheckboxType.parse(data); + expect(parsed.selectedItems, ['A', 'C']); + }); + }); + + group('AskUserCheckbox CatalogItem', () { + test('has correct name', () { + expect(askUserCheckbox.name, 'AskUserCheckbox'); + }); + + test('has non-null schema', () { + expect(askUserCheckbox.dataSchema, isNotNull); + expect(askUserCheckbox.dataSchema.value, isA>()); + }); + + test('has example data', () { + expect(askUserCheckbox.exampleData, isNotEmpty); + }); + + test('examples are valid JSON', () async { + final errors = await validateCatalogItemExamples( + askUserCheckbox, + chatCatalog, + ); + + final criticalErrors = errors.where((e) { + final message = e.toString(); + return !message.contains('optional') && + !message.contains('not required'); + }).toList(); + + expect( + criticalErrors, + isEmpty, + reason: + 'Examples should be valid. Errors:\n${criticalErrors.join('\n')}', + ); + }); + }); +} diff --git a/packages/genui/test/core/ai/catalog/ask_user_image_style_test.dart b/packages/genui/test/core/ai/catalog/ask_user_image_style_test.dart new file mode 100644 index 00000000..ad23e775 --- /dev/null +++ b/packages/genui/test/core/ai/catalog/ask_user_image_style_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/test/validation.dart'; + +import 'package:superdeck_genui/src/ai/catalog/catalog.dart'; + +void main() { + group('AskUserImageStyle Schema', () { + test('parses valid image style data', () { + final data = { + 'question': 'Choose an image style', + 'description': 'Select the visual direction for imagery.', + 'subject': 'solar system with planets', + 'imageStyles': ['watercolor', 'minimalist', 'gradient'], + 'action': {'name': 'submit_answer', 'context': []}, + }; + + final parsed = AskUserImageStyleType.parse(data); + expect(parsed.question, 'Choose an image style'); + expect(parsed.description, 'Select the visual direction for imagery.'); + expect(parsed.subject, 'solar system with planets'); + expect(parsed.imageStyles, hasLength(3)); + final imageStyleIds = parsed.imageStyles.map((style) => style.name); + expect(imageStyleIds, contains('watercolor')); + expect(imageStyleIds, contains('minimalist')); + expect(imageStyleIds, contains('gradient')); + }); + + test('description is optional', () { + final data = { + 'question': 'Pick a style', + 'subject': 'mountain landscape', + 'imageStyles': ['watercolor', 'minimalist'], + 'action': {'name': 'submit', 'context': []}, + }; + + final parsed = AskUserImageStyleType.parse(data); + expect(parsed.description, isNull); + }); + }); + + group('AskUserImageStyle CatalogItem', () { + test('has correct name', () { + expect(askUserImageStyle.name, 'AskUserImageStyle'); + }); + + test('has non-null schema', () { + expect(askUserImageStyle.dataSchema, isNotNull); + expect(askUserImageStyle.dataSchema.value, isA>()); + }); + + test('has example data', () { + expect(askUserImageStyle.exampleData, isNotEmpty); + }); + + test('examples are valid JSON', () async { + final errors = await validateCatalogItemExamples( + askUserImageStyle, + chatCatalog, + ); + + final criticalErrors = errors.where((e) { + final message = e.toString(); + return !message.contains('optional') && + !message.contains('not required'); + }).toList(); + + expect( + criticalErrors, + isEmpty, + reason: + 'Examples should be valid. Errors:\n${criticalErrors.join('\n')}', + ); + }); + }); +} diff --git a/packages/genui/test/core/ai/catalog/ask_user_radio_test.dart b/packages/genui/test/core/ai/catalog/ask_user_radio_test.dart new file mode 100644 index 00000000..e9ddab2d --- /dev/null +++ b/packages/genui/test/core/ai/catalog/ask_user_radio_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/test/validation.dart'; + +import 'package:superdeck_genui/src/ai/catalog/catalog.dart'; + +void main() { + group('AskUserRadio Schema', () { + test('parses valid radio data', () { + final data = { + 'question': 'Who is your audience?', + 'description': 'Select one', + 'options': [ + {'title': 'Students', 'description': 'Academic learners'}, + {'title': 'Professionals', 'description': 'Business context'}, + ], + 'action': {'name': 'submit_answer', 'context': []}, + }; + + final parsed = AskUserRadioType.parse(data); + expect(parsed.question, 'Who is your audience?'); + expect(parsed.description, 'Select one'); + expect(parsed.options, hasLength(2)); + expect(parsed.options[0].title, 'Students'); + expect(parsed.options[0].description, 'Academic learners'); + }); + + test('description is optional', () { + final data = { + 'question': 'Simple question', + 'options': [ + {'title': 'Option A'}, + ], + 'action': {'name': 'submit', 'context': []}, + }; + + final parsed = AskUserRadioType.parse(data); + expect(parsed.description, isNull); + }); + + test('option description is optional', () { + final option = InputOptionType({'title': 'Title Only'}); + + expect(option.title, 'Title Only'); + expect(option.description, isNull); + }); + }); + + group('AskUserRadio CatalogItem', () { + test('has correct name', () { + expect(askUserRadio.name, 'AskUserRadio'); + }); + + test('has non-null schema', () { + expect(askUserRadio.dataSchema, isNotNull); + expect(askUserRadio.dataSchema.value, isA>()); + }); + + test('has example data', () { + expect(askUserRadio.exampleData, isNotEmpty); + }); + + test('examples are valid JSON', () async { + final errors = await validateCatalogItemExamples( + askUserRadio, + chatCatalog, + ); + + final criticalErrors = errors.where((e) { + final message = e.toString(); + return !message.contains('optional') && + !message.contains('not required'); + }).toList(); + + expect( + criticalErrors, + isEmpty, + reason: + 'Examples should be valid. Errors:\n${criticalErrors.join('\n')}', + ); + }); + }); +} diff --git a/packages/genui/test/core/ai/catalog/ask_user_slider_test.dart b/packages/genui/test/core/ai/catalog/ask_user_slider_test.dart new file mode 100644 index 00000000..014be5f6 --- /dev/null +++ b/packages/genui/test/core/ai/catalog/ask_user_slider_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/test/validation.dart'; + +import 'package:superdeck_genui/src/ai/catalog/catalog.dart'; + +void main() { + group('AskUserSlider Schema', () { + test('parses valid slider data', () { + final data = { + 'question': 'How many slides?', + 'minValue': 5, + 'maxValue': 20, + 'defaultValue': 10, + 'unit': 'slides', + 'action': {'name': 'submit_answer', 'context': []}, + }; + + final parsed = AskUserSliderType.parse(data); + expect(parsed.question, 'How many slides?'); + expect(parsed.minValue, 5); + expect(parsed.maxValue, 20); + expect(parsed.defaultValue, 10); + expect(parsed.unit, 'slides'); + }); + + test('unit is optional', () { + final data = { + 'question': 'Pick a number', + 'minValue': 1, + 'maxValue': 100, + 'defaultValue': 50, + 'action': {'name': 'submit', 'context': []}, + }; + + final parsed = AskUserSliderType.parse(data); + expect(parsed.unit, isNull); + expect(parsed.description, isNull); + }); + }); + + group('AskUserSlider CatalogItem', () { + test('has correct name', () { + expect(askUserSlider.name, 'AskUserSlider'); + }); + + test('has non-null schema', () { + expect(askUserSlider.dataSchema, isNotNull); + expect(askUserSlider.dataSchema.value, isA>()); + }); + + test('has example data', () { + expect(askUserSlider.exampleData, isNotEmpty); + }); + + test('examples are valid JSON', () async { + final errors = await validateCatalogItemExamples( + askUserSlider, + chatCatalog, + ); + + final criticalErrors = errors.where((e) { + final message = e.toString(); + return !message.contains('optional') && + !message.contains('not required'); + }).toList(); + + expect( + criticalErrors, + isEmpty, + reason: + 'Examples should be valid. Errors:\n${criticalErrors.join('\n')}', + ); + }); + }); +} diff --git a/packages/genui/test/core/ai/catalog/ask_user_style_test.dart b/packages/genui/test/core/ai/catalog/ask_user_style_test.dart new file mode 100644 index 00000000..7ae33f5b --- /dev/null +++ b/packages/genui/test/core/ai/catalog/ask_user_style_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/test/validation.dart'; + +import 'package:superdeck_genui/src/ai/catalog/catalog.dart'; + +void main() { + group('AskUserStyle Schema', () { + test('parses valid style data', () { + final data = { + 'question': 'Choose a visual style', + 'description': + 'Pick the palette and fonts that best fit your audience.', + 'styleOptions': [ + { + 'id': 'professional_clean', + 'title': 'Professional & Clean', + 'description': 'Muted palette with crisp typography.', + 'colors': ['#F8FAFC', '#1E3A8A', '#475569'], + 'headlineFont': 'montserrat', + 'bodyFont': 'openSans', + }, + { + 'id': 'playful_bright', + 'title': 'Playful & Bright', + 'description': 'Cheerful colors with friendly fonts.', + 'colors': ['#F5F3FF', '#5B21B6', '#6B7280'], + 'headlineFont': 'lobster', + 'bodyFont': 'inter', + }, + ], + 'action': {'name': 'submit_answer', 'context': []}, + }; + + final parsed = AskUserStyleType.parse(data); + expect(parsed.question, 'Choose a visual style'); + expect(parsed.styleOptions, hasLength(2)); + expect(parsed.styleOptions[0].id, 'professional_clean'); + expect(parsed.styleOptions[0].title, 'Professional & Clean'); + expect(parsed.styleOptions[0].colors, hasLength(3)); + expect(parsed.styleOptions[0].headlineFont.name, 'montserrat'); + expect(parsed.styleOptions[0].bodyFont.name, 'openSans'); + }); + + test('StyleOptionType parses all fields', () { + final option = StyleOptionType.parse({ + 'id': 'test_style', + 'title': 'Test Style', + 'description': 'A test style', + 'colors': ['#FFFFFF', '#000000'], + 'headlineFont': 'montserrat', + 'bodyFont': 'inter', + }); + + expect(option.id, 'test_style'); + expect(option.title, 'Test Style'); + expect(option.description, 'A test style'); + expect(option.colors, hasLength(2)); + expect(option.headlineFont.name, 'montserrat'); + expect(option.bodyFont.name, 'inter'); + }); + }); + + group('AskUserStyle CatalogItem', () { + test('has correct name', () { + expect(askUserStyle.name, 'AskUserStyle'); + }); + + test('has non-null schema', () { + expect(askUserStyle.dataSchema, isNotNull); + expect(askUserStyle.dataSchema.value, isA>()); + }); + + test('has example data', () { + expect(askUserStyle.exampleData, isNotEmpty); + }); + + test('examples are valid JSON', () async { + final errors = await validateCatalogItemExamples( + askUserStyle, + chatCatalog, + ); + + final criticalErrors = errors.where((e) { + final message = e.toString(); + return !message.contains('optional') && + !message.contains('not required'); + }).toList(); + + expect( + criticalErrors, + isEmpty, + reason: + 'Examples should be valid. Errors:\n${criticalErrors.join('\n')}', + ); + }); + }); +} diff --git a/packages/genui/test/core/ai/catalog/ask_user_text_test.dart b/packages/genui/test/core/ai/catalog/ask_user_text_test.dart new file mode 100644 index 00000000..4be9d764 --- /dev/null +++ b/packages/genui/test/core/ai/catalog/ask_user_text_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/test/validation.dart'; + +import 'package:superdeck_genui/src/ai/catalog/catalog.dart'; + +void main() { + group('AskUserText Schema', () { + test('parses valid text data', () { + final data = { + 'question': 'Any requirements?', + 'placeholder': 'Enter details...', + 'maxLength': 500, + 'multiline': true, + 'action': {'name': 'submit_answer', 'context': []}, + }; + + final parsed = AskUserTextType.parse(data); + expect(parsed.question, 'Any requirements?'); + expect(parsed.placeholder, 'Enter details...'); + expect(parsed.maxLength, 500); + expect(parsed.multiline, true); + }); + + test('all fields except question and action are optional', () { + final data = { + 'question': 'Simple question', + 'action': {'name': 'submit', 'context': []}, + }; + + final parsed = AskUserTextType.parse(data); + expect(parsed.question, 'Simple question'); + expect(parsed.description, isNull); + expect(parsed.placeholder, isNull); + expect(parsed.maxLength, isNull); + expect(parsed.multiline, isNull); + }); + }); + + group('AskUserText CatalogItem', () { + test('has correct name', () { + expect(askUserText.name, 'AskUserText'); + }); + + test('has non-null schema', () { + expect(askUserText.dataSchema, isNotNull); + expect(askUserText.dataSchema.value, isA>()); + }); + + test('has example data', () { + expect(askUserText.exampleData, isNotEmpty); + }); + + test('examples are valid JSON', () async { + final errors = await validateCatalogItemExamples( + askUserText, + chatCatalog, + ); + + final criticalErrors = errors.where((e) { + final message = e.toString(); + return !message.contains('optional') && + !message.contains('not required'); + }).toList(); + + expect( + criticalErrors, + isEmpty, + reason: + 'Examples should be valid. Errors:\n${criticalErrors.join('\n')}', + ); + }); + }); +} diff --git a/packages/genui/test/core/ai/catalog/catalog_validation_test.dart b/packages/genui/test/core/ai/catalog/catalog_validation_test.dart new file mode 100644 index 00000000..b4397935 --- /dev/null +++ b/packages/genui/test/core/ai/catalog/catalog_validation_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/test/validation.dart'; +import 'package:superdeck_genui/src/ai/catalog/catalog.dart'; + +void main() { + group('SuperDeck Catalog Validation', () { + test('chatCatalog should have a valid catalogId', () { + expect(chatCatalog.catalogId, isNotNull); + expect(chatCatalog.catalogId, equals('com.superdeck.ai.chat')); + }); + + test('chatCatalog should contain all expected items', () { + final itemNames = chatCatalog.items.map((item) => item.name).toList(); + + // Individual question components (steps 1-6) + expect(itemNames, contains('AskUserRadio')); + expect(itemNames, contains('AskUserCheckbox')); + expect(itemNames, contains('AskUserSlider')); + expect(itemNames, contains('AskUserText')); + expect(itemNames, contains('AskUserStyle')); + expect(itemNames, contains('AskUserImageStyle')); + + // Summary component (step 7) + expect(itemNames, contains('SummaryCard')); + + expect(itemNames.length, equals(7)); + }); + + for (final item in chatCatalog.items) { + group('CatalogItem: ${item.name}', () { + test('should have a valid dataSchema', () { + expect(item.dataSchema, isNotNull); + expect(item.dataSchema.value, isA>()); + }); + + test('should have a valid widgetBuilder', () { + expect(item.widgetBuilder, isNotNull); + }); + + test('should have exampleData for AI few-shot learning', () { + expect( + item.exampleData, + isNotEmpty, + reason: + '${item.name} should have at least one example for AI learning', + ); + }); + + test('examples should be valid JSON', () async { + final errors = await validateCatalogItemExamples(item, chatCatalog); + + // Filter out minor warnings if needed + final criticalErrors = errors.where((e) { + final message = e.toString(); + // Some warnings are not critical + return !message.contains('optional') && + !message.contains('not required'); + }).toList(); + + expect( + criticalErrors, + isEmpty, + reason: + '${item.name} examples should be valid. Errors:\n${criticalErrors.join('\n')}', + ); + }); + }); + } + }); + + group('Catalog Schema Generation', () { + test('chatCatalog should generate a valid schema definition', () { + final definition = chatCatalog.definition; + expect(definition, isNotNull); + expect(definition.value, isA>()); + }); + + test('catalog schema should include all component names', () { + final definition = chatCatalog.definition; + final schemaJson = definition.value; + + // The schema typically includes component definitions + expect(schemaJson, isNotNull); + }); + }); +} diff --git a/packages/genui/test/core/ai/catalog/catalog_widget_regressions_test.dart b/packages/genui/test/core/ai/catalog/catalog_widget_regressions_test.dart new file mode 100644 index 00000000..6a9b01fe --- /dev/null +++ b/packages/genui/test/core/ai/catalog/catalog_widget_regressions_test.dart @@ -0,0 +1,334 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; +import 'package:remix/remix.dart'; + +import 'package:superdeck_genui/src/ai/catalog/ask_user_question_cards.dart'; +import 'package:superdeck_genui/src/ai/catalog/catalog.dart'; +import 'package:superdeck_genui/src/ai/prompts/prompt_registry.dart'; +import 'package:superdeck_genui/src/ai/services/image_generator_service.dart'; +import 'package:superdeck_genui/src/ui/ui.dart'; + +class _QueuedImageService extends ImageGeneratorService { + _QueuedImageService(this._results) : super(apiKey: 'test'); + + final List> _results; + int _index = 0; + + @override + Future generateImage(String prompt) { + expect( + _index, + lessThan(_results.length), + reason: 'Unexpected generateImage call.', + ); + return _results[_index++].future; + } + + @override + void dispose() {} +} + +Widget _buildCatalogItemWidget({ + required CatalogItem item, + required Map data, + ValueChanged? onEvent, +}) { + return MaterialApp( + builder: (context, child) { + return createRemixScope(child: child!, accent: .iris); + }, + home: Scaffold( + body: SingleChildScrollView( + child: Builder( + builder: (context) { + return item.widgetBuilder( + CatalogItemContext( + id: 'root', + data: data, + buildChild: (_, [_]) => const SizedBox.shrink(), + dispatchEvent: onEvent ?? (_) {}, + buildContext: context, + dataContext: DataContext(DataModel(), '/'), + getComponent: (_) => null, + surfaceId: 'test-surface', + ), + ); + }, + ), + ), + ), + ); +} + +Map _imageStyleData({ + required String subject, + required List imageStyles, +}) { + return { + 'question': 'Choose style', + 'description': 'Pick one style', + 'subject': subject, + 'imageStyles': imageStyles, + 'action': {'name': 'submit_answer', 'context': []}, + }; +} + +Map _sliderData({ + required int min, + required int max, + required int defaultValue, +}) { + return { + 'question': 'How many slides?', + 'description': 'Pick a count', + 'minValue': min, + 'maxValue': max, + 'defaultValue': defaultValue, + 'unit': 'slides', + 'action': {'name': 'submit_answer', 'context': []}, + }; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + dotenv.loadFromString(envString: 'GOOGLE_AI_API_KEY=test_api_key'); + PromptRegistry.instance.loadForTest( + prompts: {'image_generation': 'test image prompt'}, + ); + }); + + tearDownAll(() { + resetImageGeneratorServiceFactory(); + PromptRegistry.instance.reset(); + }); + + tearDown(() { + resetImageGeneratorServiceFactory(); + }); + + group('Catalog widget regressions', () { + testWidgets( + 'AskUserImageStyle ignores stale results after subject change', + (tester) async { + final firstGen = [ + Completer(), + Completer(), + ]; + final secondGen = [ + Completer(), + Completer(), + ]; + final services = Queue<_QueuedImageService>.from([ + _QueuedImageService(firstGen), + _QueuedImageService(secondGen), + ]); + + imageGeneratorServiceFactory = ({required String apiKey}) { + expect(services, isNotEmpty, reason: 'No fake service queued.'); + return services.removeFirst(); + }; + + await tester.pumpWidget( + _buildCatalogItemWidget( + item: askUserImageStyle, + data: _imageStyleData( + subject: 'subject-a', + imageStyles: ['watercolor', 'minimalist'], + ), + ), + ); + await tester.pump(); + + List cards() => tester + .widgetList(find.byType(ImageStyleOptionCard)) + .toList(); + + expect(cards(), hasLength(2)); + expect(cards().every((c) => c.isLoading), isTrue); + + await tester.pumpWidget( + _buildCatalogItemWidget( + item: askUserImageStyle, + data: _imageStyleData( + subject: 'subject-b', + imageStyles: ['watercolor', 'minimalist'], + ), + ), + ); + await tester.pump(); + expect(cards().every((c) => c.isLoading), isTrue); + + firstGen[0].complete( + ImageGenerationResult.success(Uint8List.fromList([1])), + ); + firstGen[1].complete( + ImageGenerationResult.success(Uint8List.fromList([2])), + ); + await tester.pump(); + + final afterStale = cards(); + expect(afterStale.every((c) => c.isLoading), isTrue); + expect(afterStale.every((c) => c.imageBytes == null), isTrue); + + secondGen[0].complete( + ImageGenerationResult.success(Uint8List.fromList([9])), + ); + secondGen[1].complete( + ImageGenerationResult.success(Uint8List.fromList([8])), + ); + await tester.pump(); + + final afterFresh = cards(); + expect(afterFresh.every((c) => c.isLoading), isFalse); + expect(afterFresh[0].imageBytes?.toList(), equals([9])); + expect(afterFresh[1].imageBytes?.toList(), equals([8])); + }, + ); + + testWidgets( + 'AskUserImageStyle regenerates previews when style list changes', + (tester) async { + final firstGen = [ + Completer(), + Completer(), + ]; + final secondGen = [ + Completer(), + Completer(), + ]; + final services = Queue<_QueuedImageService>.from([ + _QueuedImageService(firstGen), + _QueuedImageService(secondGen), + ]); + + imageGeneratorServiceFactory = ({required String apiKey}) { + expect(services, isNotEmpty, reason: 'No fake service queued.'); + return services.removeFirst(); + }; + + await tester.pumpWidget( + _buildCatalogItemWidget( + item: askUserImageStyle, + data: _imageStyleData( + subject: 'same-subject', + imageStyles: ['watercolor', 'minimalist'], + ), + ), + ); + await tester.pump(); + + firstGen[0].complete( + ImageGenerationResult.success(Uint8List.fromList([1])), + ); + firstGen[1].complete( + ImageGenerationResult.success(Uint8List.fromList([2])), + ); + await tester.pump(); + + List cards() => tester + .widgetList(find.byType(ImageStyleOptionCard)) + .toList(); + expect(cards().every((c) => c.isLoading), isFalse); + expect(cards()[0].imageBytes?.toList(), equals([1])); + + await tester.pumpWidget( + _buildCatalogItemWidget( + item: askUserImageStyle, + data: _imageStyleData( + subject: 'same-subject', + imageStyles: ['watercolor', 'gradient'], + ), + ), + ); + await tester.pump(); + + final duringRegeneration = cards(); + expect(duringRegeneration.every((c) => c.isLoading), isTrue); + expect(duringRegeneration.every((c) => c.imageBytes == null), isTrue); + + secondGen[0].complete( + ImageGenerationResult.success(Uint8List.fromList([3])), + ); + secondGen[1].complete( + ImageGenerationResult.success(Uint8List.fromList([4])), + ); + await tester.pump(); + + final afterRegeneration = cards(); + expect(afterRegeneration.every((c) => c.isLoading), isFalse); + expect(afterRegeneration[0].imageBytes?.toList(), equals([3])); + expect(afterRegeneration[1].imageBytes?.toList(), equals([4])); + }, + ); + + testWidgets('AskUserSlider clamps current value when max decreases', ( + tester, + ) async { + await tester.pumpWidget( + _buildCatalogItemWidget( + item: askUserSlider, + data: _sliderData(min: 0, max: 10, defaultValue: 5), + ), + ); + await tester.pump(); + + var slider = tester.widget(find.byType(SdSlider)); + expect(slider.value, 5.0); + + slider.onChanged(9); + await tester.pump(); + slider = tester.widget(find.byType(SdSlider)); + expect(slider.value, 9.0); + + await tester.pumpWidget( + _buildCatalogItemWidget( + item: askUserSlider, + data: _sliderData(min: 0, max: 4, defaultValue: 5), + ), + ); + await tester.pump(); + + slider = tester.widget(find.byType(SdSlider)); + expect(slider.value, 4.0); + }); + + testWidgets('AskUserSlider applies new default value on widget update', ( + tester, + ) async { + await tester.pumpWidget( + _buildCatalogItemWidget( + item: askUserSlider, + data: _sliderData(min: 0, max: 10, defaultValue: 3), + ), + ); + await tester.pump(); + + var slider = tester.widget(find.byType(SdSlider)); + expect(slider.value, 3.0); + + slider.onChanged(8); + await tester.pump(); + slider = tester.widget(find.byType(SdSlider)); + expect(slider.value, 8.0); + + await tester.pumpWidget( + _buildCatalogItemWidget( + item: askUserSlider, + data: _sliderData(min: 0, max: 10, defaultValue: 2), + ), + ); + await tester.pump(); + + slider = tester.widget(find.byType(SdSlider)); + expect(slider.value, 2.0); + }); + }); +} diff --git a/packages/genui/test/core/ai/catalog/schema_equivalence_test.dart b/packages/genui/test/core/ai/catalog/schema_equivalence_test.dart new file mode 100644 index 00000000..bfee44bb --- /dev/null +++ b/packages/genui/test/core/ai/catalog/schema_equivalence_test.dart @@ -0,0 +1,195 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +// ACK schemas (converted via toJsonSchemaBuilder) +import 'package:superdeck_genui/src/ai/catalog/summary_card.dart'; +import 'package:superdeck_genui/src/ai/prompts/font_styles.dart'; +import 'package:superdeck_genui/src/ai/prompts/image_style_prompts.dart'; + +/// Tests to verify ACK schemas produce equivalent output to the original +/// json_schema_builder schemas that were replaced during migration. +/// +/// This ensures the AI receives the same schema structure and the migration +/// didn't change the contract between the app and the AI model. +/// +/// Note: CheckboxCardGroup, RadioCardGroup, SlideCountCard, RadioStyleCardGroup, +/// and ImageStyleCardGroup tests were removed as those components have been +/// split into AskUserRadio, AskUserCheckbox, AskUserSlider, AskUserText, +/// AskUserStyle, and AskUserImageStyle. +void main() { + group('Schema Equivalence Tests', () { + group('summary_card', () { + test('schema structure matches original', () { + // OLD schema (from git history before ACK migration) + final oldSchema = Schema.object( + description: + 'A summary card that displays a recap of all user selections before finalizing. ' + 'This should be used to show the user a complete overview of their choices ' + '(audience, approach, style, etc.) so they can review before proceeding.', + properties: { + 'title': Schema.string( + description: 'The main heading of the summary card.', + ), + 'items': Schema.list( + description: + 'The list of summary items representing each user selection to display.', + items: Schema.object( + properties: { + 'label': Schema.string( + description: 'The category label for this selection.', + ), + 'kind': Schema.string( + description: 'Discriminator for summary payload shape', + enumValues: ['text', 'style', 'imageStyle'], + ), + 'title': Schema.string( + description: + 'The primary text representing the user\'s choice.', + ), + 'description': Schema.string( + description: 'Additional details about the selection.', + ), + 'text': Schema.string( + description: 'Plain text content for simple display items.', + ), + 'colors': Schema.list( + description: + 'List of hex color strings for the style palette. Include for style selections.', + items: Schema.string(description: 'Hex color value.'), + ), + 'headlineFont': Schema.string( + description: HeadlineFont.schemaDescription, + enumValues: HeadlineFont.values.map((f) => f.name).toList(), + ), + 'bodyFont': Schema.string( + description: BodyFont.schemaDescription, + enumValues: BodyFont.values.map((f) => f.name).toList(), + ), + 'imageStyleId': Schema.string( + description: ImageStyle.schemaDescription(), + enumValues: ImageStyle.values.map((f) => f.name).toList(), + ), + }, + required: ['label'], + ), + ), + 'generateSlidesAction': A2uiSchemas.action( + description: + 'Generate the slides for the presentation. The context for this action should include references to the selected values so that the model can know what the user has selected.', + ), + }, + required: ['title', 'items', 'generateSlidesAction'], + ); + + // NEW schema (from ACK migration) + final newSchema = summaryCard.dataSchema; + + // Compare structure + _compareSchemaStructure(oldSchema, newSchema, 'SummaryCard'); + }); + }); + }); +} + +/// Compares two schemas and verifies their structure is equivalent. +/// +/// Ignores 'description' and 'additionalProperties' fields as these are +/// cosmetic/strictness changes that don't affect the AI contract. +/// +/// Checks recursively: +/// - Property names match exactly (no extra, no missing) +/// - Property types match +/// - Required fields match +/// - Enum values match (where applicable) +/// - Nested objects and array items match +void _compareSchemaStructure( + Schema oldSchema, + Schema newSchema, + String componentName, +) { + _compareJsonStructure(oldSchema.value, newSchema.value, componentName); +} + +/// Recursively compares two JSON schema maps, ignoring descriptions. +void _compareJsonStructure( + Map oldJson, + Map newJson, + String path, +) { + // Compare type + expect( + newJson['type'], + equals(oldJson['type']), + reason: '$path: type should match', + ); + + // Compare required fields (as sets to ignore ordering) + final oldRequired = (oldJson['required'] as List?)?.cast().toSet(); + final newRequired = (newJson['required'] as List?)?.cast().toSet(); + expect( + newRequired, + equals(oldRequired), + reason: '$path: required fields should match', + ); + + // Compare enum values (as sets to ignore ordering) + if (oldJson.containsKey('enum')) { + expect( + newJson.containsKey('enum'), + isTrue, + reason: '$path: should have enum values', + ); + final oldEnum = (oldJson['enum'] as List).toSet(); + final newEnum = (newJson['enum'] as List).toSet(); + expect(newEnum, equals(oldEnum), reason: '$path: enum values should match'); + } + + // Compare properties recursively + final oldProps = oldJson['properties'] as Map?; + final newProps = newJson['properties'] as Map?; + + if (oldProps != null) { + expect(newProps, isNotNull, reason: '$path: should have properties'); + + // Check property keys match exactly + final oldKeys = oldProps.keys.toSet(); + final newKeys = newProps!.keys.toSet(); + + final missingInNew = oldKeys.difference(newKeys); + final extraInNew = newKeys.difference(oldKeys); + + expect( + missingInNew, + isEmpty, + reason: '$path: missing properties in new schema: $missingInNew', + ); + expect( + extraInNew, + isEmpty, + reason: '$path: extra properties in new schema: $extraInNew', + ); + + // Recursively compare each property + for (final propName in oldProps.keys) { + final oldProp = oldProps[propName] as Map; + final newProp = newProps[propName] as Map; + + _compareJsonStructure(oldProp, newProp, '$path.$propName'); + } + } + + // Compare array items recursively + if (oldJson.containsKey('items')) { + expect( + newJson.containsKey('items'), + isTrue, + reason: '$path: should have items', + ); + + final oldItems = oldJson['items'] as Map; + final newItems = newJson['items'] as Map; + + _compareJsonStructure(oldItems, newItems, '$path.items'); + } +} diff --git a/packages/genui/test/core/ai/catalog/summary_item_kind_test.dart b/packages/genui/test/core/ai/catalog/summary_item_kind_test.dart new file mode 100644 index 00000000..946ef3e2 --- /dev/null +++ b/packages/genui/test/core/ai/catalog/summary_item_kind_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superdeck_genui/src/ai/catalog/catalog.dart'; + +void main() { + group('SummaryItem kind discriminator', () { + test('accepts known kind values', () { + final textItem = SummaryItemType.parse({ + 'kind': 'text', + 'label': 'Topic', + 'text': 'Astronomy', + }); + final styleItem = SummaryItemType.parse({ + 'kind': 'style', + 'label': 'Style', + 'title': 'Cosmic Blue', + 'colors': ['#0F172A', '#60A5FA', '#94A3B8'], + 'headlineFont': 'oswald', + 'bodyFont': 'inter', + }); + final imageItem = SummaryItemType.parse({ + 'kind': 'imageStyle', + 'label': 'Image Style', + 'imageStyleId': 'minimalist', + }); + + expect(textItem.kind, SummaryItemKind.text); + expect(styleItem.kind, SummaryItemKind.style); + expect(imageItem.kind, SummaryItemKind.imageStyle); + expect(textItem.shapeValidationError, isNull); + expect(styleItem.shapeValidationError, isNull); + expect(imageItem.shapeValidationError, isNull); + }); + + test('rejects unknown kind at schema parse level', () { + final result = SummaryItemType.safeParse({ + 'kind': 'unknown', + 'label': 'Topic', + 'text': 'Astronomy', + }); + expect(result.getOrNull(), isNull); + }); + + test('flags invalid explicit kind combinations', () { + final invalidStyle = SummaryItemType.parse({ + 'kind': 'style', + 'label': 'Style', + 'title': 'Missing fields', + 'colors': ['#111111'], + }); + final invalidText = SummaryItemType.parse({ + 'kind': 'text', + 'label': 'Topic', + 'text': 'Astronomy', + 'imageStyleId': 'minimalist', + }); + + expect( + invalidStyle.shapeValidationError, + contains('requires colors/headlineFont/bodyFont'), + ); + expect( + invalidText.shapeValidationError, + contains('should not include style or imageStyle fields'), + ); + }); + + test('legacy items without kind remain supported', () { + final legacyStyle = SummaryItemType.parse({ + 'label': 'Style', + 'title': 'Cosmic Blue', + 'colors': ['#0F172A', '#60A5FA', '#94A3B8'], + 'headlineFont': 'oswald', + 'bodyFont': 'inter', + }); + final legacyImage = SummaryItemType.parse({ + 'label': 'Image Style', + 'imageStyleId': 'minimalist', + }); + final legacyText = SummaryItemType.parse({ + 'label': 'Topic', + 'title': 'Astronomy', + }); + + expect(legacyStyle.kind, isNull); + expect(legacyImage.kind, isNull); + expect(legacyText.kind, isNull); + expect(legacyStyle.shapeValidationError, isNull); + expect(legacyImage.shapeValidationError, isNull); + expect(legacyText.shapeValidationError, isNull); + }); + }); +} diff --git a/packages/genui/test/core/ai/prompts/font_styles_test.dart b/packages/genui/test/core/ai/prompts/font_styles_test.dart new file mode 100644 index 00000000..1fcb4925 --- /dev/null +++ b/packages/genui/test/core/ai/prompts/font_styles_test.dart @@ -0,0 +1,269 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/prompts/font_styles.dart'; + +void main() { + group('HeadlineFont', () { + group('values', () { + test('has expected number of fonts', () { + expect(HeadlineFont.values.length, 5); + }); + + test('contains all expected fonts', () { + expect( + HeadlineFont.values, + containsAll([ + HeadlineFont.playfairDisplay, + HeadlineFont.montserrat, + HeadlineFont.poppins, + HeadlineFont.oswald, + HeadlineFont.lobster, + ]), + ); + }); + }); + + group('properties', () { + test('each font has non-empty title', () { + for (final font in HeadlineFont.values) { + expect( + font.title, + isNotEmpty, + reason: 'Font ${font.name} has empty title', + ); + } + }); + + test('each font has non-empty fontFamily', () { + for (final font in HeadlineFont.values) { + expect( + font.fontFamily, + isNotEmpty, + reason: 'Font ${font.name} has empty fontFamily', + ); + } + }); + + test('each font has non-empty description', () { + for (final font in HeadlineFont.values) { + expect( + font.description, + isNotEmpty, + reason: 'Font ${font.name} has empty description', + ); + } + }); + + test('id returns enum name', () { + expect(HeadlineFont.montserrat.id, 'montserrat'); + expect(HeadlineFont.playfairDisplay.id, 'playfairDisplay'); + }); + }); + + group('fromId', () { + test('returns font for valid ID', () { + expect(HeadlineFont.fromId('montserrat'), HeadlineFont.montserrat); + expect( + HeadlineFont.fromId('playfairDisplay'), + HeadlineFont.playfairDisplay, + ); + expect(HeadlineFont.fromId('poppins'), HeadlineFont.poppins); + expect(HeadlineFont.fromId('oswald'), HeadlineFont.oswald); + expect(HeadlineFont.fromId('lobster'), HeadlineFont.lobster); + }); + + test('returns null for invalid ID', () { + expect(HeadlineFont.fromId('nonexistent'), isNull); + expect(HeadlineFont.fromId(''), isNull); + expect(HeadlineFont.fromId('MONTSERRAT'), isNull); // Case sensitive + }); + + test('returns correct font for all values', () { + for (final font in HeadlineFont.values) { + expect(HeadlineFont.fromId(font.id), font); + } + }); + }); + + group('ids', () { + test('has correct count', () { + final ids = HeadlineFont.values.map((f) => f.name).toList(); + expect(ids.length, HeadlineFont.values.length); + }); + + test('contains all font IDs', () { + expect( + HeadlineFont.values.map((f) => f.name).toList(), + containsAll([ + 'playfairDisplay', + 'montserrat', + 'poppins', + 'oswald', + 'lobster', + ]), + ); + }); + + test('each ID can be resolved', () { + for (final id in HeadlineFont.values.map((f) => f.name)) { + expect( + HeadlineFont.fromId(id), + isNotNull, + reason: 'ID $id should resolve', + ); + } + }); + }); + + group('schemaDescription', () { + test('includes all font names', () { + final desc = HeadlineFont.schemaDescription; + for (final font in HeadlineFont.values) { + expect(desc, contains(font.name), reason: 'Missing ${font.name}'); + } + }); + + test('includes descriptions', () { + final desc = HeadlineFont.schemaDescription; + for (final font in HeadlineFont.values) { + expect( + desc, + contains(font.description), + reason: 'Missing description for ${font.name}', + ); + } + }); + + test('starts with expected prefix', () { + expect(HeadlineFont.schemaDescription, startsWith('Headline font.')); + }); + }); + }); + + group('BodyFont', () { + group('values', () { + test('has expected number of fonts', () { + expect(BodyFont.values.length, 5); + }); + + test('contains all expected fonts', () { + expect( + BodyFont.values, + containsAll([ + BodyFont.inter, + BodyFont.openSans, + BodyFont.lato, + BodyFont.roboto, + BodyFont.sourceSerif4, + ]), + ); + }); + }); + + group('properties', () { + test('each font has non-empty title', () { + for (final font in BodyFont.values) { + expect( + font.title, + isNotEmpty, + reason: 'Font ${font.name} has empty title', + ); + } + }); + + test('each font has non-empty fontFamily', () { + for (final font in BodyFont.values) { + expect( + font.fontFamily, + isNotEmpty, + reason: 'Font ${font.name} has empty fontFamily', + ); + } + }); + + test('each font has non-empty description', () { + for (final font in BodyFont.values) { + expect( + font.description, + isNotEmpty, + reason: 'Font ${font.name} has empty description', + ); + } + }); + + test('id returns enum name', () { + expect(BodyFont.inter.id, 'inter'); + expect(BodyFont.openSans.id, 'openSans'); + }); + }); + + group('fromId', () { + test('returns font for valid ID', () { + expect(BodyFont.fromId('inter'), BodyFont.inter); + expect(BodyFont.fromId('openSans'), BodyFont.openSans); + expect(BodyFont.fromId('lato'), BodyFont.lato); + expect(BodyFont.fromId('roboto'), BodyFont.roboto); + expect(BodyFont.fromId('sourceSerif4'), BodyFont.sourceSerif4); + }); + + test('returns null for invalid ID', () { + expect(BodyFont.fromId('nonexistent'), isNull); + expect(BodyFont.fromId(''), isNull); + expect(BodyFont.fromId('INTER'), isNull); // Case sensitive + }); + + test('returns correct font for all values', () { + for (final font in BodyFont.values) { + expect(BodyFont.fromId(font.id), font); + } + }); + }); + + group('ids', () { + test('has correct count', () { + final ids = BodyFont.values.map((f) => f.name).toList(); + expect(ids.length, BodyFont.values.length); + }); + + test('contains all font IDs', () { + expect( + BodyFont.values.map((f) => f.name).toList(), + containsAll(['inter', 'openSans', 'lato', 'roboto', 'sourceSerif4']), + ); + }); + + test('each ID can be resolved', () { + for (final id in BodyFont.values.map((f) => f.name)) { + expect( + BodyFont.fromId(id), + isNotNull, + reason: 'ID $id should resolve', + ); + } + }); + }); + + group('schemaDescription', () { + test('includes all font names', () { + final desc = BodyFont.schemaDescription; + for (final font in BodyFont.values) { + expect(desc, contains(font.name), reason: 'Missing ${font.name}'); + } + }); + + test('includes descriptions', () { + final desc = BodyFont.schemaDescription; + for (final font in BodyFont.values) { + expect( + desc, + contains(font.description), + reason: 'Missing description for ${font.name}', + ); + } + }); + + test('starts with expected prefix', () { + expect(BodyFont.schemaDescription, startsWith('Body font.')); + }); + }); + }); +} diff --git a/packages/genui/test/core/ai/prompts/image_style_prompts_test.dart b/packages/genui/test/core/ai/prompts/image_style_prompts_test.dart new file mode 100644 index 00000000..f28dabcf --- /dev/null +++ b/packages/genui/test/core/ai/prompts/image_style_prompts_test.dart @@ -0,0 +1,228 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/prompts/image_style_prompts.dart'; + +void main() { + group('ImageStyle', () { + group('values', () { + test('has expected number of styles', () { + expect(ImageStyle.values.length, 6); + }); + + test('contains all expected styles', () { + expect( + ImageStyle.values, + containsAll([ + ImageStyle.watercolor, + ImageStyle.minimalist, + ImageStyle.gradient, + ImageStyle.retro, + ImageStyle.geometric, + ImageStyle.flatDesign, + ]), + ); + }); + }); + + group('properties', () { + test('each style has non-empty title', () { + for (final style in ImageStyle.values) { + expect( + style.title, + isNotEmpty, + reason: 'Style ${style.name} has empty title', + ); + } + }); + + test('each style has non-empty description', () { + for (final style in ImageStyle.values) { + expect( + style.description, + isNotEmpty, + reason: 'Style ${style.name} has empty description', + ); + } + }); + + test('each style has non-empty treatment', () { + for (final style in ImageStyle.values) { + expect( + style.treatment, + isNotEmpty, + reason: 'Style ${style.name} has empty treatment', + ); + } + }); + + test('id returns enum name', () { + expect(ImageStyle.watercolor.id, 'watercolor'); + expect(ImageStyle.flatDesign.id, 'flatDesign'); + }); + }); + + group('buildPrompt', () { + test('capitalizes subject', () { + final result = ImageStyle.watercolor.buildPrompt( + 'runner crossing finish line', + ); + + expect(result, startsWith('Runner')); + }); + + test('includes treatment', () { + final result = ImageStyle.watercolor.buildPrompt('test subject'); + + expect(result, contains(ImageStyle.watercolor.treatment)); + }); + + test('combines subject and treatment with comma', () { + final result = ImageStyle.minimalist.buildPrompt('mountain landscape'); + + expect(result, startsWith('Mountain landscape, ')); + }); + + test('handles empty subject', () { + final result = ImageStyle.gradient.buildPrompt(''); + + // Should still include treatment + expect(result, contains(ImageStyle.gradient.treatment)); + }); + + test('handles single character subject', () { + final result = ImageStyle.retro.buildPrompt('a'); + + expect(result, startsWith('A, ')); + }); + + test('preserves rest of subject after capitalizing first letter', () { + final result = ImageStyle.geometric.buildPrompt('iPhone on desk'); + + expect(result, startsWith('IPhone on desk, ')); + }); + + test('each style produces unique prompt for same subject', () { + const subject = 'abstract art'; + final prompts = ImageStyle.values + .map((s) => s.buildPrompt(subject)) + .toSet(); + + // All prompts should be unique + expect(prompts.length, ImageStyle.values.length); + }); + }); + + group('fromId', () { + test('returns style for valid ID', () { + expect(ImageStyle.fromId('watercolor'), ImageStyle.watercolor); + expect(ImageStyle.fromId('minimalist'), ImageStyle.minimalist); + expect(ImageStyle.fromId('gradient'), ImageStyle.gradient); + expect(ImageStyle.fromId('retro'), ImageStyle.retro); + expect(ImageStyle.fromId('geometric'), ImageStyle.geometric); + expect(ImageStyle.fromId('flatDesign'), ImageStyle.flatDesign); + }); + + test('returns null for invalid ID', () { + expect(ImageStyle.fromId('nonexistent'), isNull); + expect(ImageStyle.fromId(''), isNull); + expect(ImageStyle.fromId('WATERCOLOR'), isNull); // Case sensitive + }); + + test('returns correct style for all values', () { + for (final style in ImageStyle.values) { + expect(ImageStyle.fromId(style.id), style); + } + }); + }); + + group('ids', () { + test('has correct count', () { + final ids = ImageStyle.values.map((s) => s.name).toList(); + expect(ids.length, ImageStyle.values.length); + }); + + test('contains all style IDs', () { + expect( + ImageStyle.values.map((s) => s.name).toList(), + containsAll([ + 'watercolor', + 'minimalist', + 'gradient', + 'retro', + 'geometric', + 'flatDesign', + ]), + ); + }); + + test('each ID can be resolved', () { + for (final id in ImageStyle.values.map((s) => s.name)) { + expect( + ImageStyle.fromId(id), + isNotNull, + reason: 'ID $id should resolve', + ); + } + }); + }); + + group('schemaDescription', () { + test('includes all style names', () { + final desc = ImageStyle.schemaDescription(); + for (final style in ImageStyle.values) { + expect(desc, contains(style.name), reason: 'Missing ${style.name}'); + } + }); + + test('includes descriptions', () { + final desc = ImageStyle.schemaDescription(); + for (final style in ImageStyle.values) { + expect( + desc, + contains(style.description), + reason: 'Missing description for ${style.name}', + ); + } + }); + + test('default count=1 uses "Choose one from"', () { + expect(ImageStyle.schemaDescription(), contains('Choose one from')); + }); + + test('count=3 uses "Select 3 styles"', () { + expect( + ImageStyle.schemaDescription(count: 3), + contains('Select 3 styles'), + ); + }); + }); + + group('specific styles', () { + test('watercolor treatment mentions watercolor painting', () { + expect( + ImageStyle.watercolor.treatment, + contains('watercolor painting'), + ); + }); + + test('minimalist treatment mentions negative space', () { + expect(ImageStyle.minimalist.treatment, contains('negative space')); + }); + + test('gradient treatment mentions gradients', () { + expect(ImageStyle.gradient.treatment, contains('gradients')); + }); + + test('retro treatment mentions vintage', () { + expect(ImageStyle.retro.treatment, contains('vintage')); + }); + + test('geometric treatment mentions angular shapes', () { + expect(ImageStyle.geometric.treatment, contains('angular shapes')); + }); + + test('flatDesign treatment mentions flat design', () { + expect(ImageStyle.flatDesign.treatment, contains('flat design')); + }); + }); + }); +} diff --git a/packages/genui/test/core/ai/prompts/prompt_registry_test.dart b/packages/genui/test/core/ai/prompts/prompt_registry_test.dart new file mode 100644 index 00000000..c85aae53 --- /dev/null +++ b/packages/genui/test/core/ai/prompts/prompt_registry_test.dart @@ -0,0 +1,293 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/prompts/prompt_registry.dart'; + +void main() { + setUp(() { + // Reset before each test + PromptRegistry.instance.reset(); + }); + + tearDown(() { + // Clean up after each test + PromptRegistry.instance.reset(); + }); + + group('PromptRegistry', () { + group('initial state', () { + test('isLoaded should be false initially', () { + expect(PromptRegistry.instance.isLoaded, isFalse); + }); + }); + + group('render', () { + test('throws StateError when not loaded', () { + expect( + () => PromptRegistry.instance.render('test'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'PromptRegistry not loaded.', + ), + ), + ); + }); + + test('throws StateError for missing prompt', () { + PromptRegistry.instance.loadForTest( + prompts: {'existing': 'Hello {{name}}'}, + ); + + expect( + () => PromptRegistry.instance.render('nonexistent'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Prompt not found: nonexistent', + ), + ), + ); + }); + + test('throws StateError for empty prompt content', () { + PromptRegistry.instance.loadForTest(prompts: {'empty': ''}); + + expect( + () => PromptRegistry.instance.render('empty'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Prompt not found: empty', + ), + ), + ); + }); + + test('throws StateError for whitespace-only prompt content', () { + PromptRegistry.instance.loadForTest( + prompts: {'whitespace': ' \n\t '}, + ); + + expect( + () => PromptRegistry.instance.render('whitespace'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Prompt not found: whitespace', + ), + ), + ); + }); + + test('returns rendered content with no variables', () { + PromptRegistry.instance.loadForTest( + prompts: {'simple': 'Hello, world!'}, + ); + + final result = PromptRegistry.instance.render('simple'); + + expect(result, 'Hello, world!'); + }); + + test('substitutes input variables', () { + PromptRegistry.instance.loadForTest( + prompts: {'greeting': 'Hello, {{name}}!'}, + ); + + final result = PromptRegistry.instance.render( + 'greeting', + input: {'name': 'Alice'}, + ); + + expect(result, 'Hello, Alice!'); + }); + + test('substitutes multiple input variables', () { + PromptRegistry.instance.loadForTest( + prompts: {'message': '{{greeting}}, {{name}}! Welcome to {{place}}.'}, + ); + + final result = PromptRegistry.instance.render( + 'message', + input: {'greeting': 'Hi', 'name': 'Bob', 'place': 'Wonderland'}, + ); + + expect(result, 'Hi, Bob! Welcome to Wonderland.'); + }); + + test('resolves partials', () { + PromptRegistry.instance.loadForTest( + prompts: {'main': 'Header: {{>header}}'}, + partials: {'header': 'This is the header'}, + ); + + final result = PromptRegistry.instance.render('main'); + + expect(result, 'Header: This is the header'); + }); + + test('resolves partials with variables', () { + PromptRegistry.instance.loadForTest( + prompts: {'main': '{{>greeting}}'}, + partials: {'greeting': 'Hello, {{name}}!'}, + ); + + final result = PromptRegistry.instance.render( + 'main', + input: {'name': 'Charlie'}, + ); + + expect(result, 'Hello, Charlie!'); + }); + + test('handles missing partial gracefully', () { + PromptRegistry.instance.loadForTest( + prompts: {'main': 'Before {{>missing}} After'}, + partials: {}, + ); + + // Behavior depends on dotprompt_dart - likely returns empty string + final result = PromptRegistry.instance.render('main'); + + // The missing partial should be replaced with empty string + expect(result, contains('Before')); + expect(result, contains('After')); + }); + }); + + group('loadForTest', () { + test('sets isLoaded to true', () { + PromptRegistry.instance.loadForTest(); + + expect(PromptRegistry.instance.isLoaded, isTrue); + }); + + test('loads prompts from provided map', () { + PromptRegistry.instance.loadForTest(prompts: {'test': 'Test content'}); + + final result = PromptRegistry.instance.render('test'); + + expect(result, 'Test content'); + }); + + test('loads partials from provided map', () { + PromptRegistry.instance.loadForTest( + prompts: {'main': '{{>partial}}'}, + partials: {'partial': 'Partial content'}, + ); + + final result = PromptRegistry.instance.render('main'); + + expect(result, 'Partial content'); + }); + + test('clears previous prompts', () { + PromptRegistry.instance.loadForTest(prompts: {'first': 'First'}); + + PromptRegistry.instance.loadForTest(prompts: {'second': 'Second'}); + + // First prompt should no longer exist + expect( + () => PromptRegistry.instance.render('first'), + throwsA(isA()), + ); + + // Second prompt should exist + expect(PromptRegistry.instance.render('second'), 'Second'); + }); + }); + + group('reset', () { + test('sets isLoaded to false', () { + PromptRegistry.instance.loadForTest(prompts: {'test': 'content'}); + expect(PromptRegistry.instance.isLoaded, isTrue); + + PromptRegistry.instance.reset(); + + expect(PromptRegistry.instance.isLoaded, isFalse); + }); + + test('clears prompts', () { + PromptRegistry.instance.loadForTest(prompts: {'test': 'content'}); + + PromptRegistry.instance.reset(); + + // Should throw StateError because not loaded + expect( + () => PromptRegistry.instance.render('test'), + throwsA(isA()), + ); + }); + + test('can be called multiple times', () { + expect(() { + PromptRegistry.instance.reset(); + PromptRegistry.instance.reset(); + PromptRegistry.instance.reset(); + }, returnsNormally); + }); + }); + + group('singleton', () { + test('instance returns same object', () { + final instance1 = PromptRegistry.instance; + final instance2 = PromptRegistry.instance; + + expect(identical(instance1, instance2), isTrue); + }); + + test('state persists across instance access', () { + PromptRegistry.instance.loadForTest(prompts: {'test': 'content'}); + + // Access through instance again + final result = PromptRegistry.instance.render('test'); + + expect(result, 'content'); + }); + }); + + group('complex templates', () { + test('handles multiline templates', () { + PromptRegistry.instance.loadForTest( + prompts: { + 'multiline': '''Line 1 +Line 2 +Line 3''', + }, + ); + + final result = PromptRegistry.instance.render('multiline'); + + expect(result, contains('Line 1')); + expect(result, contains('Line 2')); + expect(result, contains('Line 3')); + }); + + test('handles special characters in content', () { + PromptRegistry.instance.loadForTest( + prompts: {'special': r'Hello $name! Price: $100'}, + ); + + final result = PromptRegistry.instance.render('special'); + + // Dollar signs are preserved (not treated as variables) + expect(result, contains(r'$name')); + expect(result, contains(r'$100')); + }); + + test('handles nested partials', () { + PromptRegistry.instance.loadForTest( + prompts: {'main': 'Start {{>level1}} End'}, + partials: {'level1': 'L1 {{>level2}} L1', 'level2': 'L2'}, + ); + + final result = PromptRegistry.instance.render('main'); + + expect(result, 'Start L1 L2 L1 End'); + }); + }); + }); +} diff --git a/packages/genui/test/core/ai/schemas/deck_schemas_ack_types_test.dart b/packages/genui/test/core/ai/schemas/deck_schemas_ack_types_test.dart new file mode 100644 index 00000000..50339f52 --- /dev/null +++ b/packages/genui/test/core/ai/schemas/deck_schemas_ack_types_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/schemas/deck_schemas.dart'; + +void main() { + group('Slide ACK types', () { + test('parse valid block/section/slide payloads', () { + final block = SlideBlockType.parse({ + 'type': 'block', + 'content': 'Hello world', + }); + final section = SlideSectionType.parse({ + 'type': 'section', + 'blocks': [block.toJson()], + }); + final slide = SlideType.parse({ + 'key': 'slide-1', + 'options': {'title': 'Intro'}, + 'sections': [section.toJson()], + }); + + expect(block.type, 'block'); + expect(section.blocks.single.content, 'Hello world'); + expect(slide.key, 'slide-1'); + expect(slide.options?['title'], 'Intro'); + expect(slide.sections.single.type, 'section'); + }); + + test('reject invalid slide shapes', () { + expect( + SlideType.safeParse({ + 'options': {'title': 'Missing key'}, + 'sections': [], + }).getOrNull(), + isNull, + ); + expect( + SlideSectionType.safeParse({ + 'type': 'section', + 'blocks': 'not-a-list', + }).getOrNull(), + isNull, + ); + }); + }); + + group('Slide generation ACK type', () { + test('parse full generation payload', () { + final generation = SlideGenerationType.parse({ + 'slides': [ + { + 'key': 'slide-1', + 'options': {'title': 'Intro'}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + {'type': 'block', 'content': 'Body'}, + ], + }, + ], + }, + ], + 'style': _styleMap(), + }); + + expect(generation.slides, hasLength(1)); + expect(generation.slides.single.key, 'slide-1'); + expect(generation.style.name, 'Default'); + }); + }); +} + +Map _styleMap() { + return { + 'name': 'Default', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#112233', + 'body': '#445566', + }, + 'fonts': {'headline': 'montserrat', 'body': 'openSans'}, + }; +} diff --git a/packages/genui/test/core/ai/services/deck_generator_schema_test.dart b/packages/genui/test/core/ai/services/deck_generator_schema_test.dart new file mode 100644 index 00000000..24a511ff --- /dev/null +++ b/packages/genui/test/core/ai/services/deck_generator_schema_test.dart @@ -0,0 +1,300 @@ +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui_google_generative_ai/genui_google_generative_ai.dart'; +import 'package:superdeck_genui/src/ai/schemas/deck_schemas.dart'; + +void main() { + group('Slide Generation Schema', () { + test('should convert to valid Google AI schema without errors', () { + // Convert ACK schema to json_schema_builder format + final schema = slideGenerationSchema.toJsonSchemaBuilder(); + final adapter = GoogleSchemaAdapter(); + final result = adapter.adapt(schema); + + // Filter out ignorable warnings (additionalProperties is not supported but ignored) + final criticalErrors = result.errors.where((e) { + final message = e.toString(); + // Warnings about ignored keywords are not critical + return !message.contains('will be ignored'); + }).toList(); + + expect( + criticalErrors, + isEmpty, + reason: + 'Schema conversion should have no critical errors: ' + '${criticalErrors.join('; ')}', + ); + expect(result.schema, isNotNull, reason: 'Schema should be produced'); + }); + + test('required fields should only include truly required properties', () { + // Convert ACK schema to json_schema_builder and get raw JSON schema map + final schema = slideGenerationSchema.toJsonSchemaBuilder(); + final jsonSchema = schema.value; + + // Root level should have 'slides' and 'style' as required + final rootRequired = jsonSchema['required'] as List?; + expect(rootRequired, contains('slides')); + expect(rootRequired, contains('style')); + + // Slide level: only 'key' and 'sections' should be required + final slidesSchema = jsonSchema['properties'] as Map; + final slideItemSchema = (slidesSchema['slides'] as Map)['items'] as Map; + final slideRequired = slideItemSchema['required'] as List?; + + expect(slideRequired, contains('key'), reason: 'key should be required'); + expect( + slideRequired, + contains('sections'), + reason: 'sections should be required', + ); + expect( + slideRequired, + isNot(contains('options')), + reason: 'options should be optional', + ); + expect( + slideRequired, + isNot(contains('comments')), + reason: 'comments should be optional', + ); + + // Section level: only 'type' and 'blocks' should be required + final sectionsSchema = + (slideItemSchema['properties'] as Map)['sections'] as Map; + final sectionItemSchema = sectionsSchema['items'] as Map; + final sectionRequired = sectionItemSchema['required'] as List?; + + expect( + sectionRequired, + contains('type'), + reason: 'section type should be required', + ); + expect( + sectionRequired, + contains('blocks'), + reason: 'blocks should be required', + ); + expect( + sectionRequired, + isNot(contains('flex')), + reason: 'flex should be optional', + ); + expect( + sectionRequired, + isNot(contains('align')), + reason: 'align should be optional', + ); + + // Block level: only 'type' should be required + final blocksSchema = + (sectionItemSchema['properties'] as Map)['blocks'] as Map; + final blockItemSchema = blocksSchema['items'] as Map; + final blockRequired = blockItemSchema['required'] as List?; + + expect( + blockRequired, + equals(['type']), + reason: 'Only type should be required in blocks', + ); + }); + + test('optional fields should be marked as nullable in the schema', () { + // Convert ACK schema to json_schema_builder and get raw JSON schema map + final schema = slideGenerationSchema.toJsonSchemaBuilder(); + final jsonSchema = schema.value; + + final slidesSchema = jsonSchema['properties'] as Map; + final slideItemSchema = (slidesSchema['slides'] as Map)['items'] as Map; + final slideProps = slideItemSchema['properties'] as Map; + + // Check that optional fields exist but aren't in required + expect(slideProps.containsKey('options'), isTrue); + expect(slideProps.containsKey('comments'), isTrue); + }); + + test( + 'schema structure matches expected baseline from json_schema_builder', + () { + // This test ensures the ACK schema produces identical structure + // to the original json_schema_builder schema + final schema = slideGenerationSchema.toJsonSchemaBuilder(); + final jsonSchema = schema.value; + + // Verify root schema structure + expect(jsonSchema['type'], equals('object')); + expect( + jsonSchema['description'], + equals('A SuperDeck presentation with slides and style'), + ); + expect( + (jsonSchema['properties'] as Map).keys.toSet(), + equals({'slides', 'style'}), + ); + expect( + (jsonSchema['required'] as List).toSet(), + equals({'slides', 'style'}), + ); + + // Verify slides array schema + final slidesSchema = (jsonSchema['properties'] as Map)['slides'] as Map; + expect(slidesSchema['type'], equals('array')); + expect( + slidesSchema['description'], + equals('Array of slides in the presentation'), + ); + + // Verify slide item schema + final slideItemSchema = slidesSchema['items'] as Map; + expect(slideItemSchema['type'], equals('object')); + expect(slideItemSchema['description'], equals('A single slide')); + expect( + (slideItemSchema['properties'] as Map).keys.toSet(), + equals({'key', 'options', 'comments', 'sections'}), + ); + expect( + (slideItemSchema['required'] as List).toSet(), + equals({'key', 'sections'}), + ); + + // Verify style schema + final styleSchema = (jsonSchema['properties'] as Map)['style'] as Map; + expect(styleSchema['type'], equals('object')); + expect( + styleSchema['description'], + equals('Global style configuration for the deck'), + ); + expect( + (styleSchema['properties'] as Map).keys.toSet(), + equals({'name', 'colors', 'fonts'}), + ); + expect( + (styleSchema['required'] as List).toSet(), + equals({'name', 'colors', 'fonts'}), + ); + + // Verify colors schema + final colorsSchema = + (styleSchema['properties'] as Map)['colors'] as Map; + expect(colorsSchema['type'], equals('object')); + expect( + colorsSchema['description'], + equals('Color palette for the presentation'), + ); + expect( + (colorsSchema['properties'] as Map).keys.toSet(), + equals({'background', 'heading', 'body'}), + ); + expect( + (colorsSchema['required'] as List).toSet(), + equals({'background', 'heading', 'body'}), + ); + + // Verify fonts schema + final fontsSchema = (styleSchema['properties'] as Map)['fonts'] as Map; + expect(fontsSchema['type'], equals('object')); + expect(fontsSchema['description'], equals('Typography configuration')); + expect( + (fontsSchema['properties'] as Map).keys.toSet(), + equals({'headline', 'body'}), + ); + expect( + (fontsSchema['required'] as List).toSet(), + equals({'headline', 'body'}), + ); + + // Verify section schema structure + final sectionsSchema = + (slideItemSchema['properties'] as Map)['sections'] as Map; + final sectionItemSchema = sectionsSchema['items'] as Map; + expect(sectionItemSchema['type'], equals('object')); + expect( + sectionItemSchema['description'], + equals('A section containing blocks'), + ); + expect( + (sectionItemSchema['properties'] as Map).keys.toSet(), + equals({'type', 'flex', 'align', 'scrollable', 'blocks'}), + ); + expect( + (sectionItemSchema['required'] as List).toSet(), + equals({'type', 'blocks'}), + ); + + // Verify block schema structure + final blocksSchema = + (sectionItemSchema['properties'] as Map)['blocks'] as Map; + final blockItemSchema = blocksSchema['items'] as Map; + expect(blockItemSchema['type'], equals('object')); + expect( + blockItemSchema['description'], + equals('A content or widget block'), + ); + expect( + (blockItemSchema['properties'] as Map).keys.toSet(), + equals({'type', 'content', 'name', 'flex', 'align', 'scrollable'}), + ); + expect((blockItemSchema['required'] as List).toSet(), equals({'type'})); + + // Verify enum values are preserved + final blockTypeSchema = + (blockItemSchema['properties'] as Map)['type'] as Map; + expect( + (blockTypeSchema['enum'] as List).toSet(), + equals({'block', 'widget'}), + ); + + final alignSchema = + (blockItemSchema['properties'] as Map)['align'] as Map; + expect( + (alignSchema['enum'] as List).toSet(), + equals({ + 'topLeft', + 'topCenter', + 'topRight', + 'centerLeft', + 'center', + 'centerRight', + 'bottomLeft', + 'bottomCenter', + 'bottomRight', + }), + ); + }, + ); + + test('all individual schemas produce valid json_schema_builder output', () { + // Test each schema independently to catch conversion issues early + final schemas = { + 'colorsSchema': colorsSchema, + 'fontsSchema': fontsSchema, + 'styleSchema': styleSchema, + 'blockSchema': blockSchema, + 'sectionSchema': sectionSchema, + 'slideOptionsSchema': slideOptionsSchema, + 'slideSchema': slideSchema, + 'slideGenerationSchema': slideGenerationSchema, + }; + + for (final entry in schemas.entries) { + final converted = entry.value.toJsonSchemaBuilder(); + expect( + converted.value, + isA>(), + reason: '${entry.key} should produce a valid JSON schema map', + ); + expect( + converted.value['type'], + equals('object'), + reason: '${entry.key} should have type "object"', + ); + expect( + converted.value['properties'], + isA(), + reason: '${entry.key} should have properties map', + ); + } + }); + }); +} diff --git a/packages/genui/test/core/ai/services/error_classifier_test.dart b/packages/genui/test/core/ai/services/error_classifier_test.dart new file mode 100644 index 00000000..bcfc5513 --- /dev/null +++ b/packages/genui/test/core/ai/services/error_classifier_test.dart @@ -0,0 +1,275 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/services/error_classifier.dart'; + +void main() { + const classifier = ErrorClassifier(); + + group('ErrorClassifier', () { + group('rate limit errors', () { + test('classifies quota errors', () { + expect( + classifier.classify(Exception('Quota exceeded')), + ErrorCategory.rateLimit, + ); + }); + + test('classifies rate limit errors', () { + expect( + classifier.classify('Rate limit exceeded'), + ErrorCategory.rateLimit, + ); + }); + + test('classifies 429 status', () { + expect( + classifier.classify('HTTP 429 Too Many Requests'), + ErrorCategory.rateLimit, + ); + }); + + test('classifies resource_exhausted', () { + expect( + classifier.classify('RESOURCE_EXHAUSTED: quota exceeded'), + ErrorCategory.rateLimit, + ); + }); + + test('classifies overloaded', () { + expect( + classifier.classify('Service overloaded, try again later'), + ErrorCategory.rateLimit, + ); + }); + }); + + group('authentication errors', () { + test('classifies 401 status', () { + expect( + classifier.classify('HTTP 401 Unauthorized'), + ErrorCategory.authentication, + ); + }); + + test('classifies 403 status', () { + expect( + classifier.classify('HTTP 403 Forbidden'), + ErrorCategory.authentication, + ); + }); + + test('classifies unauthorized errors', () { + expect( + classifier.classify('Request unauthorized'), + ErrorCategory.authentication, + ); + }); + + test('classifies invalid api key errors', () { + expect( + classifier.classify('Invalid API key provided'), + ErrorCategory.authentication, + ); + }); + + test('classifies api key not valid errors', () { + expect( + classifier.classify('API key not valid for this operation'), + ErrorCategory.authentication, + ); + }); + }); + + group('network errors', () { + test('classifies socket exceptions', () { + expect( + classifier.classify(const SocketException('Connection refused')), + ErrorCategory.network, + ); + }); + + test('classifies connection errors', () { + expect( + classifier.classify('Connection reset by peer'), + ErrorCategory.network, + ); + }); + + test('classifies network errors', () { + expect( + classifier.classify('Network is unreachable'), + ErrorCategory.network, + ); + }); + + test('classifies timeout errors', () { + expect( + classifier.classify('Request timeout after 30 seconds'), + ErrorCategory.network, + ); + }); + + test('classifies failed host lookup', () { + expect( + classifier.classify('Failed host lookup: api.example.com'), + ErrorCategory.network, + ); + }); + }); + + group('safety filter errors', () { + test('classifies blocked content', () { + expect( + classifier.classify('Content blocked by safety filters'), + ErrorCategory.safetyFilter, + ); + }); + + test('classifies safety errors', () { + expect( + classifier.classify('Safety check failed'), + ErrorCategory.safetyFilter, + ); + }); + + test('classifies harmful content errors', () { + expect( + classifier.classify('Request contains harmful content'), + ErrorCategory.safetyFilter, + ); + }); + }); + + group('unknown errors', () { + test('returns unknown category for unrecognized patterns', () { + expect( + classifier.classify('Some random error xyz'), + ErrorCategory.unknown, + ); + }); + + test('returns unknown category for empty string', () { + expect(classifier.classify(''), ErrorCategory.unknown); + }); + + test('returns unknown category for generic exception', () { + expect( + classifier.classify(Exception('Something happened')), + ErrorCategory.unknown, + ); + }); + }); + + group('case insensitivity', () { + test('matches uppercase patterns', () { + expect(classifier.classify('QUOTA EXCEEDED'), ErrorCategory.rateLimit); + }); + + test('matches mixed case patterns', () { + expect( + classifier.classify('Rate Limit Exceeded'), + ErrorCategory.rateLimit, + ); + }); + + test('matches lowercase patterns', () { + expect( + classifier.classify('rate limit exceeded'), + ErrorCategory.rateLimit, + ); + }); + }); + + group('getUserMessage', () { + test('returns correct message for rate limit', () { + expect( + classifier.getUserMessage('Quota exceeded'), + equals( + 'The model is overloaded. Please wait a moment and try again.', + ), + ); + }); + + test('returns correct message for auth error', () { + expect( + classifier.getUserMessage('401 Unauthorized'), + equals( + 'API key is invalid or expired. Please check your configuration.', + ), + ); + }); + + test('returns correct message for network error', () { + expect( + classifier.getUserMessage('Connection refused'), + equals('Connection issue. Please check your internet and try again.'), + ); + }); + + test('returns correct message for safety error', () { + expect( + classifier.getUserMessage('Content blocked'), + equals( + 'The request was blocked by safety filters. Please try rephrasing.', + ), + ); + }); + + test('returns correct message for unknown error', () { + expect( + classifier.getUserMessage('random error'), + equals('Sorry, something went wrong. Please try again.'), + ); + }); + }); + + group('userMessage property', () { + test('each category has a non-empty message', () { + expect(ErrorCategory.rateLimit.userMessage, isNotEmpty); + expect(ErrorCategory.authentication.userMessage, isNotEmpty); + expect(ErrorCategory.network.userMessage, isNotEmpty); + expect(ErrorCategory.safetyFilter.userMessage, isNotEmpty); + expect(ErrorCategory.unknown.userMessage, isNotEmpty); + }); + + test('messages do not contain technical jargon', () { + // User messages should be friendly, not technical + final messages = [ + ErrorCategory.rateLimit.userMessage, + ErrorCategory.authentication.userMessage, + ErrorCategory.network.userMessage, + ErrorCategory.safetyFilter.userMessage, + ErrorCategory.unknown.userMessage, + ]; + + for (final message in messages) { + expect(message.toLowerCase(), isNot(contains('exception'))); + expect(message.toLowerCase(), isNot(contains('error code'))); + expect(message.toLowerCase(), isNot(contains('stack trace'))); + } + }); + }); + + group('exhaustive pattern matching', () { + test('can use switch expression on all categories', () { + // This test verifies the enum supports exhaustive matching + String describeError(ErrorCategory category) { + return switch (category) { + ErrorCategory.rateLimit => 'rate', + ErrorCategory.authentication => 'auth', + ErrorCategory.network => 'network', + ErrorCategory.safetyFilter => 'safety', + ErrorCategory.unknown => 'unknown', + }; + } + + expect(describeError(classifier.classify('quota')), 'rate'); + expect(describeError(classifier.classify('401')), 'auth'); + expect(describeError(classifier.classify('timeout')), 'network'); + expect(describeError(classifier.classify('blocked')), 'safety'); + expect(describeError(classifier.classify('xyz')), 'unknown'); + }); + }); + }); +} diff --git a/packages/genui/test/core/ai/services/prompt_builder_test.dart b/packages/genui/test/core/ai/services/prompt_builder_test.dart new file mode 100644 index 00000000..2c205a88 --- /dev/null +++ b/packages/genui/test/core/ai/services/prompt_builder_test.dart @@ -0,0 +1,293 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/wizard_context.dart'; +import 'package:superdeck_genui/src/ai/schemas/wizard_context_keys.dart'; +import 'package:superdeck_genui/src/ai/services/prompt_builder.dart'; + +void main() { + WizardContext ctx(Map map) => WizardContext.fromMap(map); + + group('buildPromptFromWizardContext', () { + test('generates header for empty context', () { + final prompt = buildPromptFromWizardContext(const WizardContext()); + + expect( + prompt, + contains('Generate a presentation with the following specifications'), + ); + }); + + test('includes topic when provided', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(topic: 'Climate Change'), + ); + + expect(prompt, contains('Topic: Climate Change')); + }); + + test('includes audience when provided', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(audience: 'Executives'), + ); + + expect(prompt, contains('Target Audience: Executives')); + }); + + test('includes approach when provided', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(approach: 'Educational'), + ); + + expect(prompt, contains('Presentation Approach: Educational')); + }); + + test('includes emphasis as comma-separated list', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(emphasis: ['Data', 'Visuals', 'Stories']), + ); + + expect( + prompt, + contains('Key Areas to Emphasize: Data, Visuals, Stories'), + ); + }); + + test('includes emphasis from single string input', () { + final prompt = buildPromptFromWizardContext( + ctx({WizardContextKeys.emphasis: 'Key Points'}), + ); + + expect(prompt, contains('Key Areas to Emphasize: Key Points')); + }); + + test('includes slide count when provided', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(slideCount: 10), + ); + + expect(prompt, contains('Number of Slides: 10')); + }); + + test('includes style when provided', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(style: 'Modern'), + ); + + expect(prompt, contains('Visual Style: Modern')); + }); + + test('includes color palette with background only', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(colors: ['#FF0000']), + ); + + expect(prompt, contains('Color Palette:')); + expect(prompt, contains('Background: #FF0000')); + expect(prompt, isNot(contains('Heading text'))); + }); + + test('includes color palette with background and heading', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(colors: ['#FF0000', '#00FF00']), + ); + + expect(prompt, contains('Background: #FF0000')); + expect(prompt, contains('Heading text: #00FF00')); + }); + + test('includes full color palette', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(colors: ['#FF0000', '#00FF00', '#0000FF']), + ); + + expect(prompt, contains('Background: #FF0000')); + expect(prompt, contains('Heading text: #00FF00')); + expect(prompt, contains('Body text: #0000FF')); + }); + + test('skips empty color palette', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(colors: []), + ); + + expect(prompt, isNot(contains('Color Palette'))); + }); + + test('includes headline font when provided', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(headlineFont: 'Roboto'), + ); + + expect(prompt, contains('Headline Font: Roboto')); + }); + + test('includes body font when provided', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(bodyFont: 'Open Sans'), + ); + + expect(prompt, contains('Body Font: Open Sans')); + }); + + test('includes image style name when provided', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(imageStyleName: 'Minimalist'), + ); + + expect(prompt, contains('Visual Direction: Minimalist')); + }); + + test('includes image style description when provided', () { + final prompt = buildPromptFromWizardContext( + const WizardContext( + imageStyleName: 'Minimalist', + imageStyleDescription: 'Clean and simple visuals', + ), + ); + + expect(prompt, contains('Visual Direction: Minimalist')); + expect( + prompt, + contains('Visual Style Description: Clean and simple visuals'), + ); + }); + + test('skips image style description without name', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(imageStyleDescription: 'Some description'), + ); + + expect(prompt, isNot(contains('Visual Direction'))); + expect(prompt, isNot(contains('Visual Style Description'))); + }); + + test('always includes layout guidance', () { + final prompt = buildPromptFromWizardContext(const WizardContext()); + + expect(prompt, contains('Layout Guidance:')); + expect(prompt, contains('Use sections as rows and blocks as columns')); + expect(prompt, contains('1-2 blocks per section')); + }); + + test('generates complete prompt with all fields', () { + final prompt = buildPromptFromWizardContext( + const WizardContext( + topic: 'AI in Healthcare', + audience: 'Medical Professionals', + approach: 'Technical', + emphasis: ['Diagnostics', 'Treatment'], + slideCount: 15, + style: 'Professional', + colors: ['#1A5276', '#D4AC0D', '#F8F9FA'], + headlineFont: 'Montserrat', + bodyFont: 'Lato', + imageStyleName: 'Clinical', + imageStyleDescription: 'Medical imagery', + ), + ); + + // Verify all sections present + expect(prompt, contains('Topic: AI in Healthcare')); + expect(prompt, contains('Target Audience: Medical Professionals')); + expect(prompt, contains('Presentation Approach: Technical')); + expect( + prompt, + contains('Key Areas to Emphasize: Diagnostics, Treatment'), + ); + expect(prompt, contains('Number of Slides: 15')); + expect(prompt, contains('Visual Style: Professional')); + expect(prompt, contains('Background: #1A5276')); + expect(prompt, contains('Heading text: #D4AC0D')); + expect(prompt, contains('Body text: #F8F9FA')); + expect(prompt, contains('Headline Font: Montserrat')); + expect(prompt, contains('Body Font: Lato')); + expect(prompt, contains('Visual Direction: Clinical')); + expect(prompt, contains('Visual Style Description: Medical imagery')); + expect(prompt, contains('Layout Guidance')); + }); + + group('input sanitization', () { + test('truncates topic exceeding max length', () { + final longTopic = 'A' * 600; + final prompt = buildPromptFromWizardContext( + WizardContext(topic: longTopic), + ); + + // Should contain truncated topic (500 chars) with ellipsis + expect(prompt, contains('A' * 500)); + expect(prompt, contains('...')); + // Should NOT contain full 600 chars + expect(prompt, isNot(contains('A' * 600))); + }); + + test('removes control characters from topic', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(topic: 'Test\x00\x1FTopic\x7FHere'), + ); + + // Control chars removed, text preserved + expect(prompt, contains('Topic: TestTopicHere')); + }); + + test('preserves newlines and tabs in input', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(topic: 'Line1\nLine2\tTabbed'), + ); + + expect(prompt, contains('Line1\nLine2\tTabbed')); + }); + + test('handles null values gracefully', () { + final prompt = buildPromptFromWizardContext(const WizardContext()); + + // Should not contain "Topic:" line when null + expect(prompt, isNot(contains('Topic:'))); + }); + }); + + group('slide count validation', () { + test('skips slide count of zero', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(slideCount: 0), + ); + + expect(prompt, isNot(contains('Number of Slides'))); + }); + + test('skips negative slide count', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(slideCount: -1), + ); + + expect(prompt, isNot(contains('Number of Slides'))); + }); + + test('skips slide count exceeding maximum', () { + final prompt = buildPromptFromWizardContext( + const WizardContext(slideCount: 51), + ); + + expect(prompt, isNot(contains('Number of Slides'))); + }); + + test('skips non-numeric slide count strings', () { + final prompt = buildPromptFromWizardContext( + ctx({WizardContextKeys.slideCount: 'ten'}), + ); + + expect(prompt, isNot(contains('Number of Slides'))); + }); + + test('accepts valid slide count at boundary', () { + final prompt1 = buildPromptFromWizardContext( + const WizardContext(slideCount: 1), + ); + final prompt50 = buildPromptFromWizardContext( + const WizardContext(slideCount: 50), + ); + + expect(prompt1, contains('Number of Slides: 1')); + expect(prompt50, contains('Number of Slides: 50')); + }); + }); + }); +} diff --git a/packages/genui/test/core/ai/services/retry_policy_test.dart b/packages/genui/test/core/ai/services/retry_policy_test.dart new file mode 100644 index 00000000..378700a1 --- /dev/null +++ b/packages/genui/test/core/ai/services/retry_policy_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/services/retry_policy.dart'; + +void main() { + group('RetryPolicy', () { + test('retries and succeeds with exponential backoff', () async { + var attempts = 0; + final delays = []; + final policy = RetryPolicy( + maxAttempts: 3, + baseDelay: const Duration(milliseconds: 100), + maxDelay: const Duration(seconds: 1), + jitterFactor: 0, + delayFn: (delay) async { + delays.add(delay); + }, + ); + + final result = await policy.run(() async { + attempts++; + if (attempts < 3) { + throw Exception('HTTP 503 UNAVAILABLE'); + } + return 'ok'; + }); + + expect(result, 'ok'); + expect(attempts, 3); + expect(delays, const [ + Duration(milliseconds: 100), + Duration(milliseconds: 200), + ]); + }); + + test('does not retry when predicate returns false', () async { + var attempts = 0; + final delays = []; + final policy = RetryPolicy( + maxAttempts: 5, + jitterFactor: 0, + shouldRetry: (_) => false, + delayFn: (delay) async { + delays.add(delay); + }, + ); + + await expectLater( + () => policy.run(() async { + attempts++; + throw StateError('nope'); + }), + throwsA(isA()), + ); + + expect(attempts, 1); + expect(delays, isEmpty); + }); + + test('stops after max attempts and rethrows', () async { + var attempts = 0; + final delays = []; + final policy = RetryPolicy( + maxAttempts: 2, + baseDelay: const Duration(milliseconds: 50), + jitterFactor: 0, + delayFn: (delay) async { + delays.add(delay); + }, + ); + + await expectLater( + () => policy.run(() async { + attempts++; + throw Exception('503 Service Unavailable'); + }), + throwsA(isA()), + ); + + expect(attempts, 2); + expect(delays, const [Duration(milliseconds: 50)]); + }); + + test('defaultRetryDecider matches transient errors', () { + expect(RetryPolicy.defaultRetryDecider('503 UNAVAILABLE'), isTrue); + expect( + RetryPolicy.defaultRetryDecider('RESOURCE_EXHAUSTED: quota'), + isFalse, + ); + expect(RetryPolicy.defaultRetryDecider('invalid api key'), isFalse); + }); + }); +} diff --git a/packages/genui/test/core/ai/services/slide_key_utils_test.dart b/packages/genui/test/core/ai/services/slide_key_utils_test.dart new file mode 100644 index 00000000..59c0d16b --- /dev/null +++ b/packages/genui/test/core/ai/services/slide_key_utils_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/services/slide_key_utils.dart'; + +void main() { + group('generateSlideKey', () { + test('uses slide title when present', () { + final slide = { + 'options': {'title': 'Hello World'}, + 'sections': [], + }; + + final key = generateSlideKey(slide, 0); + + expect(key, 'nRtIcohM'); + }); + + test('falls back to first block content when title is missing', () { + final slide = { + 'sections': [ + { + 'blocks': [ + {'content': 'First content'}, + ], + }, + ], + }; + + final key = generateSlideKey(slide, 3); + + expect(key, 'UJmN1boX'); + }); + + test( + 'uses default fallback token when no title or content is available', + () { + final slide = { + 'sections': [ + { + 'blocks': [ + {'content': ''}, + ], + }, + ], + }; + + final key = generateSlideKey(slide, 8); + + expect(key, 'qWXuhygc'); + }, + ); + }); +} diff --git a/packages/genui/test/core/ai/services/style_json_serializer_test.dart b/packages/genui/test/core/ai/services/style_json_serializer_test.dart new file mode 100644 index 00000000..1b20501b --- /dev/null +++ b/packages/genui/test/core/ai/services/style_json_serializer_test.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/services/style_json_serializer.dart'; +import 'package:superdeck_genui/src/ai/schemas/deck_schemas.dart'; + +void main() { + group('serializeDeckStyleForJson', () { + test('converts enum fields to string IDs', () { + final style = DeckStyleType.parse({ + 'name': 'Test Style', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#112233', + 'body': '#445566', + }, + 'fonts': {'headline': 'montserrat', 'body': 'openSans'}, + }); + + final jsonStyle = serializeDeckStyleForJson(style); + + expect((jsonStyle['fonts'] as Map)['headline'], 'montserrat'); + expect((jsonStyle['fonts'] as Map)['body'], 'openSans'); + }); + + test('produces JSON-encodable output', () { + final style = DeckStyleType.parse({ + 'name': 'Test Style', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#112233', + 'body': '#445566', + }, + 'fonts': {'headline': 'montserrat', 'body': 'openSans'}, + }); + + final jsonStyle = serializeDeckStyleForJson(style); + final encoded = jsonEncode({'style': jsonStyle}); + final decoded = jsonDecode(encoded) as Map; + + expect(decoded['style'], isA>()); + expect((decoded['style'] as Map)['fonts'], { + 'headline': 'montserrat', + 'body': 'openSans', + }); + }); + }); +} diff --git a/packages/genui/test/core/ai/wizard_context_test.dart b/packages/genui/test/core/ai/wizard_context_test.dart new file mode 100644 index 00000000..3b74e3a0 --- /dev/null +++ b/packages/genui/test/core/ai/wizard_context_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/schemas/wizard_context_keys.dart'; +import 'package:superdeck_genui/src/ai/wizard_context.dart'; + +void main() { + group('WizardContext.fromMap slideCount parsing', () { + int? parseSlideCount(Object? raw) { + final context = WizardContext.fromMap({ + WizardContextKeys.slideCount: raw, + }); + return context.slideCount; + } + + test('rejects zero as double', () { + expect(parseSlideCount(0.0), isNull); + }); + + test('rejects negative double', () { + expect(parseSlideCount(-3.0), isNull); + }); + + test('truncates and accepts positive double', () { + expect(parseSlideCount(5.7), 5); + }); + + test('accepts positive int', () { + expect(parseSlideCount(5), 5); + }); + + test('rejects zero as int', () { + expect(parseSlideCount(0), isNull); + }); + }); +} diff --git a/packages/genui/test/core/tools/deck_document_store_test.dart b/packages/genui/test/core/tools/deck_document_store_test.dart new file mode 100644 index 00000000..6188d61a --- /dev/null +++ b/packages/genui/test/core/tools/deck_document_store_test.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_genui/src/ai/schemas/deck_schemas.dart'; +import 'package:superdeck_genui/src/tools/deck_document_store.dart'; +import 'package:superdeck_genui/src/tools/errors.dart'; + +void main() { + late Directory tempDir; + late DeckConfiguration configuration; + late DeckDocumentStore store; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'deck_document_store_test_', + ); + configuration = DeckConfiguration(projectDir: tempDir.path); + store = DeckDocumentStore(configuration: configuration); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('readRequired', () { + test('throws deck_file_not_found when file does not exist', () async { + await expectLater( + store.readRequired(), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.deckFileNotFound, + ), + ), + ); + }); + + test('throws deck_json_invalid when JSON is malformed', () async { + final file = configuration.deckJson; + await file.parent.create(recursive: true); + await file.writeAsString('{ not valid json'); + + await expectLater( + store.readRequired(), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.deckJsonInvalid, + ), + ), + ); + }); + + test('throws deck_schema_invalid when root shape is invalid', () async { + final file = configuration.deckJson; + await file.parent.create(recursive: true); + await file.writeAsString(jsonEncode({'style': _styleMap()})); + + await expectLater( + store.readRequired(), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.deckSchemaInvalid, + ), + ), + ); + }); + + test('throws deck_schema_invalid when style payload is invalid', () async { + final file = configuration.deckJson; + await file.parent.create(recursive: true); + await file.writeAsString( + jsonEncode({ + 'slides': [_slideMap()], + 'style': {'name': 'Missing fields'}, + }), + ); + + await expectLater( + store.readRequired(), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.deckSchemaInvalid, + ), + ), + ); + }); + + test('reads parsed slides and style when document is valid', () async { + final file = configuration.deckJson; + await file.parent.create(recursive: true); + await file.writeAsString( + jsonEncode({ + 'slides': [_slideMap(key: 'slide-1')], + 'style': _styleMap(), + }), + ); + + final document = await store.readRequired(); + + expect(document.slides, hasLength(1)); + expect(document.slides.single.key, 'slide-1'); + expect(document.style, isNotNull); + expect(document.style!.colors.heading, '#112233'); + }); + }); + + group('writeCanonical', () { + test('writes only canonical root keys slides + style', () async { + final slide = Slide.parse(_slideMap(key: 'slide-1', title: 'Intro')); + final style = DeckStyleType.parse(_styleMap()); + + await store.writeCanonical(slides: [slide], style: style); + + final file = configuration.deckJson; + final map = jsonDecode(await file.readAsString()) as Map; + + expect(map.keys.toSet(), {'slides', 'style'}); + expect((map['slides'] as List).length, 1); + expect((map['slides'] as List).first['key'], 'slide-1'); + expect((map['style'] as Map)['name'], 'Default'); + }); + + test('omits style key when style is null', () async { + final slide = Slide.parse(_slideMap(key: 'slide-1')); + + await store.writeCanonical(slides: [slide], style: null); + + final map = + jsonDecode(await configuration.deckJson.readAsString()) + as Map; + + expect(map.keys.toSet(), {'slides'}); + }); + + test('writes to project-relative superdeck.json', () async { + final slide = Slide.parse(_slideMap(key: 'slide-1')); + await store.writeCanonical(slides: [slide], style: null); + + final expectedPath = p.join(tempDir.path, '.superdeck', 'superdeck.json'); + expect(configuration.deckJson.path, expectedPath); + expect(await File(expectedPath).exists(), isTrue); + }); + + test('throws deck_write_failed when filesystem write fails', () async { + final superdeckPath = p.join(tempDir.path, '.superdeck'); + await File(superdeckPath).writeAsString('block directory creation'); + + final slide = Slide.parse(_slideMap(key: 'slide-1')); + + await expectLater( + store.writeCanonical(slides: [slide], style: null), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + DeckToolErrorCode.deckWriteFailed, + ) + .having( + (error) => error.message, + 'message', + contains('Failed to write deck file'), + ), + ), + ); + }); + }); +} + +Map _slideMap({ + String key = 'slide-1', + String title = 'Slide Title', + String content = 'Hello world', +}) { + return { + 'key': key, + 'options': {'title': title}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + {'type': 'block', 'content': content}, + ], + }, + ], + }; +} + +Map _styleMap() { + return { + 'name': 'Default', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#112233', + 'body': '#445566', + }, + 'fonts': {'headline': 'montserrat', 'body': 'openSans'}, + }; +} diff --git a/packages/genui/test/core/tools/deck_mutation_helpers_test.dart b/packages/genui/test/core/tools/deck_mutation_helpers_test.dart new file mode 100644 index 00000000..8bdd688f --- /dev/null +++ b/packages/genui/test/core/tools/deck_mutation_helpers_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_genui/src/ai/schemas/deck_schemas.dart'; +import 'package:superdeck_genui/src/tools/deck_mutation_helpers.dart'; +import 'package:superdeck_genui/src/tools/errors.dart'; + +void main() { + group('validateReadIndex', () { + test('throws when index is out of range', () { + expect( + () => validateReadIndex(2, 2), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.slideIndexOutOfRange, + ), + ), + ); + }); + + test('accepts valid index', () { + expect(() => validateReadIndex(1, 2), returnsNormally); + }); + }); + + group('validateInsertIndex', () { + test('throws when insert index is invalid', () { + expect( + () => validateInsertIndex(3, 2), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.slideInsertIndexInvalid, + ), + ), + ); + }); + + test('accepts insert at end', () { + expect(() => validateInsertIndex(2, 2), returnsNormally); + }); + }); + + group('list mutations', () { + test('insertSlideAt inserts in middle', () { + final slides = [_slide('a'), _slide('c')]; + + final updated = insertSlideAt(slides, _slide('b'), 1); + + expect(updated.map((slide) => slide.key), ['a', 'b', 'c']); + }); + + test('replaceSlideAt replaces specific index', () { + final slides = [_slide('a', title: 'Old')]; + + final updated = replaceSlideAt(slides, 0, _slide('a', title: 'New')); + + expect(updated.single.options?.title, 'New'); + }); + + test('removeSlideAt removes item and shifts order', () { + final slides = [_slide('a'), _slide('b'), _slide('c')]; + + final updated = removeSlideAt(slides, 1); + + expect(updated.map((slide) => slide.key), ['a', 'c']); + }); + + test('moveSlide handles first to last', () { + final slides = [_slide('a'), _slide('b'), _slide('c')]; + + final updated = moveSlide(slides, 0, 2); + + expect(updated.map((slide) => slide.key), ['b', 'c', 'a']); + }); + + test('moveSlide no-op still returns copied list', () { + final slides = [_slide('a'), _slide('b')]; + + final updated = moveSlide(slides, 1, 1); + + expect(updated.map((slide) => slide.key), ['a', 'b']); + expect(identical(updated, slides), isFalse); + }); + }); + + group('key checks', () { + test('ensureUniqueSlideKeyForCreate throws for duplicate key', () { + final slides = [_slide('a')]; + + expect( + () => ensureUniqueSlideKeyForCreate(slides, 'a'), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.slideKeyConflict, + ), + ), + ); + }); + + test('ensureUniqueSlideKeyForUpdate ignores same index', () { + final slides = [_slide('a')]; + expect( + () => ensureUniqueSlideKeyForUpdate(slides, 0, 'a'), + returnsNormally, + ); + }); + + test('ensureUniqueSlideKeyForUpdate throws for another index', () { + final slides = [_slide('a'), _slide('b')]; + + expect( + () => ensureUniqueSlideKeyForUpdate(slides, 0, 'b'), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.slideKeyConflict, + ), + ), + ); + }); + }); + + group('buildDeckSnapshot', () { + test('builds snapshot with style and title metadata', () { + final slides = [ + _slide('a', title: 'Intro'), + _slide('b', title: 'Agenda'), + ]; + final style = DeckStyleType.parse(_styleMap()); + + final snapshot = buildDeckSnapshot(slides, style: style); + + expect(snapshot.totalSlides, 2); + expect(snapshot.style, isNotNull); + expect(snapshot.slides[0].index, 0); + expect(snapshot.slides[0].key, 'a'); + expect(snapshot.slides[0].title, 'Intro'); + expect(snapshot.slides[1].title, 'Agenda'); + }); + }); +} + +Slide _slide(String key, {String title = 'Title'}) { + return Slide.parse({ + 'key': key, + 'options': {'title': title}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + {'type': 'block', 'content': 'Body'}, + ], + }, + ], + }); +} + +Map _styleMap() { + return { + 'name': 'Default', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#112233', + 'body': '#445566', + }, + 'fonts': {'headline': 'montserrat', 'body': 'openSans'}, + }; +} diff --git a/packages/genui/test/core/tools/deck_tools_schemas_test.dart b/packages/genui/test/core/tools/deck_tools_schemas_test.dart new file mode 100644 index 00000000..5b7239e5 --- /dev/null +++ b/packages/genui/test/core/tools/deck_tools_schemas_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/tools/deck_tools_schemas.dart'; + +void main() { + group('Deck tool request schemas', () { + test('parse valid request payloads', () { + final readRequest = ReadSlideRequestType.parse({'index': 0}); + final createRequest = CreateSlideRequestType.parse({ + 'schema': _slideSchema(), + }); + final createWithoutKey = CreateSlideRequestType.parse({ + 'schema': _slideSchema(includeKey: false), + }); + final updateWithoutKey = UpdateSlideRequestType.parse({ + 'index': 0, + 'schema': _slideSchema(includeKey: false), + }); + final moveRequest = MoveSlideRequestType.parse({ + 'fromIndex': 0, + 'toIndex': 1, + }); + + expect(readRequest.index, 0); + expect(createRequest.schema['key'], 'slide-1'); + expect(createWithoutKey.schema['key'], isNull); + expect(updateWithoutKey.schema['key'], isNull); + expect(moveRequest.toIndex, 1); + }); + + test('reject invalid request payloads', () { + expect(ReadSlideRequestType.safeParse({'index': -1}).getOrNull(), isNull); + expect( + MoveSlideRequestType.safeParse({'fromIndex': 0}).getOrNull(), + isNull, + ); + expect( + CreateSlideRequestType.safeParse({ + 'schema': 'not-an-object', + }).getOrNull(), + isNull, + ); + expect( + UpdateStyleRequestType.safeParse({'style': 'invalid'}).getOrNull(), + isNull, + ); + }); + }); + + group('Deck tool response schemas', () { + test('parse response payloads with typed getters', () { + final snapshot = DeckSnapshotType.parse({ + 'totalSlides': 1, + 'slides': [ + {'index': 0, 'key': 'slide-1', 'title': 'Intro'}, + ], + 'style': _styleMap(), + }); + + final readResult = ReadSlideResultType.parse({ + 'slide': { + 'index': 0, + 'key': 'slide-1', + 'schema': _slideSchema(), + 'thumbnail': 'AAECAw==', + }, + 'deck': snapshot.toJson(), + }); + + final styleResult = StyleUpdateResultType.parse({ + 'style': _styleMap(), + 'deck': snapshot.toJson(), + }); + + expect(snapshot.totalSlides, 1); + expect(snapshot.slides.single.title, 'Intro'); + expect(snapshot.style?['name'], 'Default'); + expect(readResult.slide['key'], 'slide-1'); + expect((readResult.slide['schema'] as Map)['key'], 'slide-1'); + expect(styleResult.style['name'], 'Default'); + }); + }); +} + +Map _slideSchema({bool includeKey = true}) { + return { + if (includeKey) 'key': 'slide-1', + 'options': {'title': 'Intro'}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + {'type': 'block', 'content': 'Body'}, + ], + }, + ], + }; +} + +Map _styleMap() { + return { + 'name': 'Default', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#112233', + 'body': '#445566', + }, + 'fonts': {'headline': 'montserrat', 'body': 'openSans'}, + }; +} diff --git a/packages/genui/test/core/tools/deck_tools_service_test.dart b/packages/genui/test/core/tools/deck_tools_service_test.dart new file mode 100644 index 00000000..8bede0ad --- /dev/null +++ b/packages/genui/test/core/tools/deck_tools_service_test.dart @@ -0,0 +1,518 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_genui/src/ai/schemas/deck_schemas.dart'; +import 'package:superdeck_genui/src/ai/services/slide_key_utils.dart'; +import 'package:superdeck_genui/src/tools/deck_document_store.dart'; +import 'package:superdeck_genui/src/tools/deck_tools_schemas.dart'; +import 'package:superdeck_genui/src/tools/deck_tools_service.dart'; +import 'package:superdeck_genui/src/tools/errors.dart'; +import 'package:superdeck_genui/src/utils/deck_style_service.dart'; + +void main() { + late Directory tempDir; + late DeckConfiguration configuration; + late DeckDocumentStore store; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('deck_tools_service_test_'); + configuration = DeckConfiguration(projectDir: tempDir.path); + store = DeckDocumentStore(configuration: configuration); + DeckStyleService.clearCache(); + }); + + tearDown(() async { + DeckStyleService.clearCache(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('DeckToolsService', () { + test('getDeck returns deck snapshot', () async { + await _writeDeck( + configuration, + slides: [ + _slideSchema(key: 'slide-1', title: 'Intro'), + _slideSchema(key: 'slide-2', title: 'Agenda'), + ], + style: _styleMap(), + ); + + final service = DeckToolsService(documentStore: store); + final result = await service.getDeck(); + + expect(result.totalSlides, 2); + expect(result.slides[0].key, 'slide-1'); + expect(result.style, isNotNull); + }); + + test('createSlide generates key when key is missing', () async { + await _writeDeck( + configuration, + slides: [_slideSchema(key: 'slide-1', title: 'Intro')], + style: _styleMap(), + ); + + final service = DeckToolsService(documentStore: store); + final result = await service.createSlide( + CreateSlideRequestType.parse({ + 'schema': _slideSchema(title: 'Generated Key Slide'), + }), + ); + + expect(result.deck.totalSlides, 2); + expect(result.slide.key, isNotEmpty); + + final persisted = await store.readRequired(); + expect(persisted.slides, hasLength(2)); + expect(persisted.slides.last.key, result.slide.key); + }); + + test( + 'createSlide retries generated key when first candidate collides', + () async { + final incomingSchema = _slideSchema(title: 'Collision Candidate'); + final collidingKey = generateSlideKey( + Map.from(incomingSchema), + 1, + ); + await _writeDeck( + configuration, + slides: [_slideSchema(key: collidingKey, title: 'Existing')], + style: _styleMap(), + ); + + final service = DeckToolsService(documentStore: store); + final result = await service.createSlide( + CreateSlideRequestType.parse({ + 'schema': incomingSchema, + 'atIndex': 1, + }), + ); + + expect(result.slide.key, isNot(collidingKey)); + + final persisted = await store.readRequired(); + expect( + persisted.slides.map((slide) => slide.key).toSet(), + hasLength(2), + ); + }, + ); + + test('createSlide throws slide_key_conflict for duplicate key', () async { + await _writeDeck( + configuration, + slides: [_slideSchema(key: 'slide-1', title: 'Intro')], + ); + + final service = DeckToolsService(documentStore: store); + + await expectLater( + service.createSlide( + CreateSlideRequestType.parse({ + 'schema': _slideSchema(key: 'slide-1', title: 'Dupe'), + }), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.slideKeyConflict, + ), + ), + ); + }); + + test('updateSlide preserves existing key', () async { + await _writeDeck( + configuration, + slides: [_slideSchema(key: 'slide-1', title: 'Original')], + ); + + final service = DeckToolsService(documentStore: store); + final result = await service.updateSlide( + UpdateSlideRequestType.parse({ + 'index': 0, + 'schema': _slideSchema(key: 'new-key', title: 'Updated'), + }), + ); + + expect(result.slide.key, 'slide-1'); + + final persisted = await store.readRequired(); + expect(persisted.slides.single.key, 'slide-1'); + expect(persisted.slides.single.options?.title, 'Updated'); + }); + + test( + 'updateSlide accepts schema without key and preserves existing key', + () async { + await _writeDeck( + configuration, + slides: [_slideSchema(key: 'slide-1', title: 'Original')], + ); + + final service = DeckToolsService(documentStore: store); + final result = await service.updateSlide( + UpdateSlideRequestType.parse({ + 'index': 0, + 'schema': _slideSchema(title: 'Updated without key'), + }), + ); + + expect(result.slide.key, 'slide-1'); + + final persisted = await store.readRequired(); + expect(persisted.slides.single.key, 'slide-1'); + expect(persisted.slides.single.options?.title, 'Updated without key'); + }, + ); + + test('deleteSlide removes a slide by index', () async { + await _writeDeck( + configuration, + slides: [ + _slideSchema(key: 'slide-1', title: 'One'), + _slideSchema(key: 'slide-2', title: 'Two'), + ], + ); + + final service = DeckToolsService(documentStore: store); + final snapshot = await service.deleteSlide( + DeleteSlideRequestType.parse({'index': 0}), + ); + + expect(snapshot.totalSlides, 1); + expect(snapshot.slides.single.key, 'slide-2'); + }); + + test('moveSlide reorders slides', () async { + await _writeDeck( + configuration, + slides: [ + _slideSchema(key: 'slide-1', title: 'One'), + _slideSchema(key: 'slide-2', title: 'Two'), + _slideSchema(key: 'slide-3', title: 'Three'), + ], + ); + + final service = DeckToolsService(documentStore: store); + final result = await service.moveSlide( + MoveSlideRequestType.parse({'fromIndex': 0, 'toIndex': 2}), + ); + + expect(result.slide.key, 'slide-1'); + expect(result.deck.slides.map((slide) => slide.key), [ + 'slide-2', + 'slide-3', + 'slide-1', + ]); + }); + + test('updateStyle validates payload and updates cache', () async { + await _writeDeck( + configuration, + slides: [_slideSchema(key: 'slide-1', title: 'One')], + ); + + final service = DeckToolsService(documentStore: store); + final style = _styleMap(name: 'Updated Style', heading: '#123456'); + + final result = await service.updateStyle( + UpdateStyleRequestType.parse({ + 'style': Map.from(style), + }), + ); + + expect(result.style['name'], 'Updated Style'); + expect(DeckStyleService.readStyleFromCache()?.name, 'Updated Style'); + + final persisted = await store.readRequired(); + expect(persisted.style?.name, 'Updated Style'); + }); + + test( + 'updateStyle rejects invalid payload and preserves existing style', + () async { + await _writeDeck( + configuration, + slides: [_slideSchema(key: 'slide-1')], + style: _styleMap(name: 'Existing Style'), + ); + DeckStyleService.setStyle( + DeckStyleType.parse(_styleMap(name: 'Existing Style')), + ); + + final service = DeckToolsService(documentStore: store); + + await expectLater( + service.updateStyle( + UpdateStyleRequestType({ + 'style': {'name': 'Invalid only'}, + }), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.styleInvalid, + ), + ), + ); + + expect(DeckStyleService.readStyleFromCache()?.name, 'Existing Style'); + }, + ); + + test('getDeck fails fast when deck file is missing', () async { + final service = DeckToolsService(documentStore: store); + + await expectLater( + service.getDeck(), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.deckFileNotFound, + ), + ), + ); + }); + + test('readSlide returns schema and base64 thumbnail', () async { + await _writeDeck( + configuration, + slides: [_slideSchema(key: 'slide-1', title: 'Intro')], + style: _styleMap(), + ); + + final context = _StubBuildContext(); + + final service = DeckToolsService( + documentStore: store, + contextProvider: () => context, + buildReadSlideConfiguration: _fakeReadSlideConfigurationBuilder, + captureSlide: (slide, context) async => Uint8List.fromList([1, 2, 3]), + ); + + final result = await service.readSlide( + ReadSlideRequestType.parse({'index': 0}), + ); + + expect(result.slide['key'], 'slide-1'); + expect((result.slide['schema'] as Map)['key'], 'slide-1'); + expect(result.slide['thumbnail'], base64Encode([1, 2, 3])); + expect(result.deck.totalSlides, 1); + }); + + test('readSlide captures using the requested slide index', () async { + await _writeDeck( + configuration, + slides: [ + _slideSchema(key: 'slide-1', title: 'Intro'), + _slideSchema(key: 'slide-2', title: 'Agenda'), + ], + style: _styleMap(), + ); + + final context = _StubBuildContext(); + SlideConfiguration? capturedConfiguration; + + final service = DeckToolsService( + documentStore: store, + contextProvider: () => context, + buildReadSlideConfiguration: + ({ + required slide, + required configuration, + required style, + required index, + }) { + // Simulate a misbehaving builder that always returns index 0. + return SlideConfiguration( + slideIndex: 0, + style: SlideStyle(), + slide: slide, + thumbnailFile: 'slide-$index.png', + ); + }, + captureSlide: (slide, context) async { + capturedConfiguration = slide; + return Uint8List.fromList([1]); + }, + ); + + final result = await service.readSlide( + ReadSlideRequestType.parse({'index': 1}), + ); + + expect(result.slide['index'], 1); + expect(capturedConfiguration, isNotNull); + expect(capturedConfiguration!.slideIndex, 1); + }); + + test( + 'readSlide throws context_unavailable when no context exists', + () async { + await _writeDeck(configuration, slides: [_slideSchema(key: 'slide-1')]); + + final service = DeckToolsService(documentStore: store); + + await expectLater( + service.readSlide(ReadSlideRequestType.parse({'index': 0})), + throwsA( + isA().having( + (error) => error.code, + 'code', + DeckToolErrorCode.contextUnavailable, + ), + ), + ); + }, + ); + + test('serializes concurrent mutations', () async { + await _writeDeck( + configuration, + slides: [_slideSchema(key: 'slide-1', title: 'Seed')], + ); + + final gate = Completer(); + final gatedStore = _GatedDeckDocumentStore( + configuration: configuration, + firstReadGate: gate, + ); + final service = DeckToolsService(documentStore: gatedStore); + + final firstMutation = service.createSlide( + CreateSlideRequestType.parse({ + 'schema': _slideSchema(title: 'First concurrent'), + }), + ); + await Future.delayed(Duration.zero); + + final secondMutation = service.createSlide( + CreateSlideRequestType.parse({ + 'schema': _slideSchema(title: 'Second concurrent'), + }), + ); + await Future.delayed(const Duration(milliseconds: 20)); + + try { + expect( + gatedStore.readCalls, + 1, + reason: 'Second mutation should not start before the first finishes', + ); + } finally { + if (!gate.isCompleted) { + gate.complete(); + } + } + + await Future.wait([firstMutation, secondMutation]); + + final persisted = await store.readRequired(); + expect(persisted.slides, hasLength(3)); + expect( + persisted.slides.map((slide) => slide.options?.title), + containsAll(['Seed', 'First concurrent', 'Second concurrent']), + ); + }); + }); +} + +SlideConfiguration _fakeReadSlideConfigurationBuilder({ + required Slide slide, + required DeckConfiguration configuration, + required DeckStyleType? style, + required int index, +}) { + final thumbnailNameSuffix = style?.name ?? configuration.projectDir; + return SlideConfiguration( + slideIndex: index, + style: SlideStyle(), + slide: slide, + thumbnailFile: 'slide-$index-$thumbnailNameSuffix.png', + ); +} + +class _StubBuildContext implements BuildContext { + @override + bool get mounted => true; + + @override + dynamic noSuchMethod(Invocation invocation) { + return super.noSuchMethod(invocation); + } +} + +class _GatedDeckDocumentStore extends DeckDocumentStore { + _GatedDeckDocumentStore({ + required super.configuration, + required this.firstReadGate, + }); + + final Completer firstReadGate; + int readCalls = 0; + + @override + Future readRequired() async { + readCalls++; + if (readCalls == 1) { + await firstReadGate.future; + } + return super.readRequired(); + } +} + +Future _writeDeck( + DeckConfiguration configuration, { + required List> slides, + Map? style, +}) async { + final payload = {'slides': slides}; + if (style != null) { + payload['style'] = style; + } + + await configuration.deckJson.parent.create(recursive: true); + await configuration.deckJson.writeAsString(jsonEncode(payload)); +} + +Map _slideSchema({ + String? key, + String title = 'Slide', + String content = 'Body', +}) { + return { + if (key != null) 'key': key, + 'options': {'title': title}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + {'type': 'block', 'content': content}, + ], + }, + ], + }; +} + +Map _styleMap({ + String name = 'Default', + String heading = '#112233', +}) { + return { + 'name': name, + 'colors': {'background': '#FFFFFF', 'heading': heading, 'body': '#445566'}, + 'fonts': {'headline': 'montserrat', 'body': 'openSans'}, + }; +} diff --git a/packages/genui/test/core/utils/color_utils_test.dart b/packages/genui/test/core/utils/color_utils_test.dart new file mode 100644 index 00000000..8b7fa960 --- /dev/null +++ b/packages/genui/test/core/utils/color_utils_test.dart @@ -0,0 +1,209 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/utils/color_utils.dart'; + +void main() { + group('parseHexColor', () { + group('valid 6-digit hex colors', () { + test('parses 6-digit hex with # prefix', () { + final result = parseHexColor('#FF5733'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFFFF5733)); + }); + + test('parses 6-digit hex without # prefix', () { + final result = parseHexColor('FF5733'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFFFF5733)); + }); + + test('handles lowercase hex', () { + final result = parseHexColor('#ff5733'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFFFF5733)); + }); + + test('handles uppercase hex', () { + final result = parseHexColor('#FF5733'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFFFF5733)); + }); + + test('handles mixed case hex', () { + final result = parseHexColor('#Ff5733'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFFFF5733)); + }); + }); + + group('valid 8-digit hex colors (with alpha)', () { + test('parses 8-digit hex with full opacity', () { + final result = parseHexColor('FFFF5733'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFFFF5733)); + }); + + test('parses 8-digit hex with partial opacity', () { + final result = parseHexColor('80FF5733'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0x80FF5733)); + }); + + test('parses 8-digit hex with # prefix', () { + final result = parseHexColor('#80FF5733'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0x80FF5733)); + }); + + test('parses 8-digit hex with zero alpha (transparent)', () { + final result = parseHexColor('00FF5733'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0x00FF5733)); + }); + }); + + group('edge case colors', () { + test('handles black (#000000)', () { + final result = parseHexColor('#000000'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFF000000)); + }); + + test('handles white (#FFFFFF)', () { + final result = parseHexColor('#FFFFFF'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFFFFFFFF)); + }); + + test('handles fully transparent (#00000000)', () { + final result = parseHexColor('#00000000'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0x00000000)); + }); + + test('handles red (#FF0000)', () { + final result = parseHexColor('#FF0000'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFFFF0000)); + }); + + test('handles green (#00FF00)', () { + final result = parseHexColor('#00FF00'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFF00FF00)); + }); + + test('handles blue (#0000FF)', () { + final result = parseHexColor('#0000FF'); + + expect(result.isValid, isTrue); + expect(result.color, const Color(0xFF0000FF)); + }); + }); + + group('invalid hex colors', () { + const fallbackGray = Color(0xFF808080); + + test('returns fallback for empty string', () { + final result = parseHexColor(''); + + expect(result.isValid, isFalse); + expect(result.color, fallbackGray); + }); + + test('returns fallback for invalid characters', () { + final result = parseHexColor('#GGGGGG'); + + expect(result.isValid, isFalse); + expect(result.color, fallbackGray); + }); + + test('returns fallback for too short string (5 chars)', () { + final result = parseHexColor('#12345'); + + expect(result.isValid, isFalse); + expect(result.color, fallbackGray); + }); + + test('returns fallback for too short string (3 chars)', () { + final result = parseHexColor('#FFF'); + + expect(result.isValid, isFalse); + expect(result.color, fallbackGray); + }); + + test('returns fallback for too long string (9 chars)', () { + final result = parseHexColor('#123456789'); + + expect(result.isValid, isFalse); + expect(result.color, fallbackGray); + }); + + test('returns fallback for non-hex string', () { + final result = parseHexColor('invalid'); + + expect(result.isValid, isFalse); + expect(result.color, fallbackGray); + }); + + test('returns fallback for special characters', () { + final result = parseHexColor('#FF!@#%'); + + expect(result.isValid, isFalse); + expect(result.color, fallbackGray); + }); + + test('returns fallback for whitespace', () { + final result = parseHexColor(' '); + + expect(result.isValid, isFalse); + expect(result.color, fallbackGray); + }); + + test('returns fallback for only # symbol', () { + final result = parseHexColor('#'); + + expect(result.isValid, isFalse); + expect(result.color, fallbackGray); + }); + }); + }); + + group('hexToColor', () { + test('returns color from valid hex', () { + final color = hexToColor('#FF5733'); + + expect(color, const Color(0xFFFF5733)); + }); + + test('returns fallback color from invalid hex', () { + final color = hexToColor('invalid'); + + expect(color, const Color(0xFF808080)); + }); + + test('ignores validity status', () { + // hexToColor is a convenience wrapper that only returns the color + final validColor = hexToColor('#FF5733'); + final invalidColor = hexToColor(''); + + expect(validColor, isA()); + expect(invalidColor, isA()); + }); + }); +} diff --git a/packages/genui/test/core/utils/deck_style_service_test.dart b/packages/genui/test/core/utils/deck_style_service_test.dart new file mode 100644 index 00000000..a9b112dd --- /dev/null +++ b/packages/genui/test/core/utils/deck_style_service_test.dart @@ -0,0 +1,309 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:signals/signals_flutter.dart'; +import 'package:superdeck_genui/src/ai/schemas/deck_schemas.dart'; +import 'package:superdeck_genui/src/path_service.dart'; +import 'package:superdeck_genui/src/utils/deck_style_service.dart'; + +void main() { + late Directory tempDir; + late String superdeckPath; + + setUp(() async { + // Create temp directory for tests + tempDir = await Directory.systemTemp.createTemp('deck_style_test_'); + superdeckPath = p.join(tempDir.path, '.superdeck'); + + // Configure PathService to use temp directory + PathService.instance.setBaseDirForTest(superdeckPath); + + // Create .superdeck directory + await Directory(superdeckPath).create(); + + // Clear any cached state + DeckStyleService.clearCache(); + }); + + tearDown(() async { + // Reset PathService + PathService.instance.resetForTest(); + + // Clear cache + DeckStyleService.clearCache(); + + // Clean up temp directory + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + Map buildStyle({ + String name = 'Test Style', + String heading = '#FF0000', + String body = '#00FF00', + String background = '#FFFFFF', + Map fonts = const { + 'headline': 'montserrat', + 'body': 'openSans', + }, + }) { + return { + 'name': name, + 'colors': {'background': background, 'heading': heading, 'body': body}, + 'fonts': fonts, + }; + } + + group('DeckStyleService', () { + group('preloadStyle', () { + test('returns null from cache when file does not exist', () async { + await DeckStyleService.preloadStyle(); + + final result = DeckStyleService.readStyleFromCache(); + expect(result, isNull); + }); + + test('returns null from cache when file has no style key', () async { + final file = File(p.join(superdeckPath, 'superdeck.json')); + await file.writeAsString( + jsonEncode({'slides': [], 'title': 'Test Deck'}), + ); + + await DeckStyleService.preloadStyle(); + + final result = DeckStyleService.readStyleFromCache(); + expect(result, isNull); + }); + + test('returns style from cache when present in file', () async { + final expectedStyle = buildStyle(); + final file = File(p.join(superdeckPath, 'superdeck.json')); + await file.writeAsString( + jsonEncode({'slides': [], 'style': expectedStyle}), + ); + + await DeckStyleService.preloadStyle(); + + final result = DeckStyleService.readStyleFromCache(); + expect(result, isNotNull); + expect(result!.colors.heading, '#FF0000'); + }); + + test('does not reload if already preloaded', () async { + final file = File(p.join(superdeckPath, 'superdeck.json')); + await file.writeAsString( + jsonEncode({'style': buildStyle(heading: '#FF0000')}), + ); + + // First preload + await DeckStyleService.preloadStyle(); + final result1 = DeckStyleService.readStyleFromCache(); + + // Modify file + await file.writeAsString( + jsonEncode({'style': buildStyle(heading: '#00FF00')}), + ); + + // Second preload should be no-op (already preloaded) + await DeckStyleService.preloadStyle(); + final result2 = DeckStyleService.readStyleFromCache(); + + // Should return same cached value + expect(identical(result1, result2), isTrue); + expect(result2!.colors.heading, '#FF0000'); + }); + + test('returns null on JSON parse error', () async { + final file = File(p.join(superdeckPath, 'superdeck.json')); + await file.writeAsString('not valid json {{{'); + + await DeckStyleService.preloadStyle(); + + final result = DeckStyleService.readStyleFromCache(); + expect(result, isNull); + }); + + test('handles nested style object correctly', () async { + final complexStyle = buildStyle( + heading: '#FF5733', + body: '#33FF57', + background: '#FFFFFF', + fonts: {'headline': 'playfairDisplay', 'body': 'openSans'}, + ); + final file = File(p.join(superdeckPath, 'superdeck.json')); + await file.writeAsString( + jsonEncode({'slides': [], 'style': complexStyle}), + ); + + await DeckStyleService.preloadStyle(); + + final result = DeckStyleService.readStyleFromCache(); + expect(result, isNotNull); + expect(result!.colors.heading, '#FF5733'); + expect(result.fonts.headline.name, 'playfairDisplay'); + }); + }); + + group('readStyleFromCache', () { + test('returns null when cache is empty', () { + final result = DeckStyleService.readStyleFromCache(); + expect(result, isNull); + }); + + test('returns cached style after preload', () async { + final file = File(p.join(superdeckPath, 'superdeck.json')); + await file.writeAsString( + jsonEncode({'style': buildStyle(heading: '#FF0000')}), + ); + + await DeckStyleService.preloadStyle(); + + final result = DeckStyleService.readStyleFromCache(); + expect(result, isNotNull); + expect(result!.colors.heading, '#FF0000'); + }); + }); + + group('reactive style API', () { + test('setStyle updates cache and notifies signal subscribers', () { + var notifications = -1; + final dispose = effect(() { + DeckStyleService.style.value; + notifications++; + }); + + DeckStyleService.setStyle( + DeckStyleType.parse(buildStyle(heading: '#ABABAB')), + ); + + dispose(); + + final result = DeckStyleService.readStyleFromCache(); + expect(result, isNotNull); + expect(result!.colors.heading, '#ABABAB'); + expect(notifications, 1); + }); + + test('setStyleFromJson parses and stores valid style payload', () { + final parsed = DeckStyleService.setStyleFromJson(buildStyle()); + + expect(parsed, isNotNull); + expect(DeckStyleService.readStyleFromCache()?.name, 'Test Style'); + }); + + test('setStyleFromJson returns null for invalid payload', () { + final parsed = DeckStyleService.setStyleFromJson({'name': 'invalid'}); + + expect(parsed, isNull); + expect(DeckStyleService.readStyleFromCache(), isNull); + }); + }); + + group('updateCache', () { + test('updates cache with new style', () { + final style = DeckStyleType.parse(buildStyle(heading: '#123456')); + + DeckStyleService.updateCache(style); + + final result = DeckStyleService.readStyleFromCache(); + expect(result, isNotNull); + expect(result!.colors.heading, '#123456'); + }); + + test('can update cache to null', () { + // First set a value + DeckStyleService.updateCache( + DeckStyleType.parse(buildStyle(heading: '#000000')), + ); + expect(DeckStyleService.readStyleFromCache(), isNotNull); + + // Then clear it + DeckStyleService.updateCache(null); + expect(DeckStyleService.readStyleFromCache(), isNull); + }); + + test('marks as preloaded so preloadStyle becomes no-op', () async { + // Update cache directly + DeckStyleService.updateCache( + DeckStyleType.parse(buildStyle(heading: '#DIRECT')), + ); + + // Create file with different content + final file = File(p.join(superdeckPath, 'superdeck.json')); + await file.writeAsString( + jsonEncode({'style': buildStyle(heading: '#FROMFILE')}), + ); + + // preloadStyle should be no-op since cache is already set + await DeckStyleService.preloadStyle(); + + final result = DeckStyleService.readStyleFromCache(); + expect(result!.colors.heading, '#DIRECT'); + }); + }); + + group('clearCache', () { + test('allows fresh preload after clear', () async { + final file = File(p.join(superdeckPath, 'superdeck.json')); + + // Write initial style + await file.writeAsString( + jsonEncode({'style': buildStyle(heading: '#FF0000')}), + ); + + // First preload + await DeckStyleService.preloadStyle(); + final result1 = DeckStyleService.readStyleFromCache(); + expect(result1!.colors.heading, '#FF0000'); + + // Modify file + await file.writeAsString( + jsonEncode({'style': buildStyle(heading: '#00FF00')}), + ); + + // Clear cache + DeckStyleService.clearCache(); + + // Preload again - should read fresh data + await DeckStyleService.preloadStyle(); + final result2 = DeckStyleService.readStyleFromCache(); + expect(result2!.colors.heading, '#00FF00'); + }); + + test('does not throw when called multiple times', () { + expect(() { + DeckStyleService.clearCache(); + DeckStyleService.clearCache(); + DeckStyleService.clearCache(); + }, returnsNormally); + }); + + test('does not throw when cache is already empty', () { + // Cache starts empty + expect(() => DeckStyleService.clearCache(), returnsNormally); + }); + }); + + group('cache behavior', () { + test('cache is shared across calls', () async { + final file = File(p.join(superdeckPath, 'superdeck.json')); + await file.writeAsString( + jsonEncode({'style': buildStyle(heading: '#FF0000')}), + ); + + await DeckStyleService.preloadStyle(); + + // Multiple reads should return same cached instance + final result1 = DeckStyleService.readStyleFromCache(); + final result2 = DeckStyleService.readStyleFromCache(); + final result3 = DeckStyleService.readStyleFromCache(); + + expect(identical(result1, result2), isTrue); + expect(identical(result2, result3), isTrue); + }); + }); + }); +} diff --git a/packages/genui/test/core/utils/hash_utils_test.dart b/packages/genui/test/core/utils/hash_utils_test.dart new file mode 100644 index 00000000..1f01ebb9 --- /dev/null +++ b/packages/genui/test/core/utils/hash_utils_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/utils/hash_utils.dart'; + +void main() { + group('generateValueHash', () { + test('returns 8-character string', () { + final hash = generateValueHash('test input'); + + expect(hash.length, 8); + }); + + test('returns alphanumeric characters only', () { + final hash = generateValueHash('test input'); + final alphanumeric = RegExp(r'^[a-zA-Z0-9]+$'); + + expect(alphanumeric.hasMatch(hash), isTrue); + }); + + test('is deterministic - same input produces same output', () { + const input = 'slide-intro-illustration'; + + final hash1 = generateValueHash(input); + final hash2 = generateValueHash(input); + final hash3 = generateValueHash(input); + + expect(hash1, hash2); + expect(hash2, hash3); + }); + + test('different inputs produce different outputs', () { + final hash1 = generateValueHash('input-a'); + final hash2 = generateValueHash('input-b'); + final hash3 = generateValueHash('input-c'); + + expect(hash1, isNot(hash2)); + expect(hash2, isNot(hash3)); + expect(hash1, isNot(hash3)); + }); + + test('handles empty string', () { + final hash = generateValueHash(''); + + expect(hash.length, 8); + expect(hash, isNotEmpty); + }); + + test('handles long strings', () { + final longInput = 'a' * 10000; + final hash = generateValueHash(longInput); + + expect(hash.length, 8); + }); + + test('handles special characters in input', () { + final hash = generateValueHash('slide-key-#123!@\$%^&*()'); + + expect(hash.length, 8); + expect(RegExp(r'^[a-zA-Z0-9]+$').hasMatch(hash), isTrue); + }); + + test('handles unicode characters', () { + final hash = generateValueHash('スライド-介绍-🎨'); + + expect(hash.length, 8); + expect(RegExp(r'^[a-zA-Z0-9]+$').hasMatch(hash), isTrue); + }); + }); +} diff --git a/packages/genui/test/core/utils/style_builder_test.dart b/packages/genui/test/core/utils/style_builder_test.dart new file mode 100644 index 00000000..294d6daf --- /dev/null +++ b/packages/genui/test/core/utils/style_builder_test.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:mix/mix.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_genui/src/ai/prompts/font_styles.dart'; +import 'package:superdeck_genui/src/ai/schemas/deck_schemas.dart'; +import 'package:superdeck_genui/src/utils/style_builder.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Disable Google Fonts network fetching for tests + setUp(() { + GoogleFonts.config.allowRuntimeFetching = false; + }); + + /// Builds a DeckStyleType for testing. + /// + /// Uses DeckStyleType constructor directly (bypasses parse). + DeckStyleType buildStyle({ + required String heading, + String body = '#00FF00', + String background = '#FFFFFF', + Map? fonts, + String name = 'Test Style', + }) { + return DeckStyleType({ + 'name': name, + 'colors': {'background': background, 'heading': heading, 'body': body}, + 'fonts': + fonts ?? + {'headline': HeadlineFont.montserrat, 'body': BodyFont.openSans}, + }); + } + + TextStyle? propTextStyle(Prop? prop) { + if (prop == null || prop.sources.isEmpty) return null; + final source = prop.sources.last; + return source is ValueSource ? source.value : null; + } + + Color? headingColorFrom(DeckOptions options) => + propTextStyle(options.baseStyle?.$strong)?.color; + + Color? bodyColorFrom(DeckOptions options) => + propTextStyle(options.baseStyle?.$a)?.color; + + group('buildDeckOptionsFromStyle', () { + group('null and empty inputs', () { + test('returns empty DeckOptions for null style', () { + final result = buildDeckOptionsFromStyle(null); + + expect(result.baseStyle, isNull); + }); + + test('returns empty DeckOptions when style fails schema parsing', () { + final style = DeckStyleType.safeParse({ + 'name': 'Invalid Style', + 'colors': {'heading': '#FF0000'}, + }).getOrNull(); + final result = buildDeckOptionsFromStyle(style); + + expect(style, isNull); + expect(result.baseStyle, isNull); + }); + }); + + group('valid color configurations', () { + test( + 'returns DeckOptions with baseStyle when heading color provided', + () { + final result = buildDeckOptionsFromStyle( + buildStyle(heading: '#FF0000'), + ); + + expect(headingColorFrom(result), equals(const Color(0xFFFF0000))); + expect(bodyColorFrom(result), equals(const Color(0xFF00FF00))); + }, + ); + + test('returns DeckOptions with baseStyle for heading and body', () { + final result = buildDeckOptionsFromStyle( + buildStyle(heading: '#FF0000', body: '#00FF00'), + ); + + expect(headingColorFrom(result), equals(const Color(0xFFFF0000))); + expect(bodyColorFrom(result), equals(const Color(0xFF00FF00))); + }); + + test('returns DeckOptions with baseStyle for all colors', () { + final result = buildDeckOptionsFromStyle( + buildStyle( + heading: '#FF0000', + body: '#00FF00', + background: '#FFFFFF', + ), + ); + + expect(headingColorFrom(result), equals(const Color(0xFFFF0000))); + expect(bodyColorFrom(result), equals(const Color(0xFF00FF00))); + }); + + test('handles invalid hex color gracefully (uses fallback)', () { + // Invalid hex should still produce a result with fallback gray + final result = buildDeckOptionsFromStyle( + buildStyle(heading: 'invalid'), + ); + + expect(headingColorFrom(result), equals(const Color(0xFF808080))); + expect(bodyColorFrom(result), equals(const Color(0xFF00FF00))); + }); + + test('handles hex color without # prefix', () { + final result = buildDeckOptionsFromStyle(buildStyle(heading: 'FF0000')); + + expect(headingColorFrom(result), equals(const Color(0xFFFF0000))); + }); + + test('handles hex color with # prefix', () { + final result = buildDeckOptionsFromStyle( + buildStyle(heading: '#FF0000'), + ); + + expect(headingColorFrom(result), equals(const Color(0xFFFF0000))); + }); + + test('handles lowercase hex colors', () { + final result = buildDeckOptionsFromStyle( + buildStyle(heading: '#ff5733'), + ); + + expect(headingColorFrom(result), equals(const Color(0xFFFF5733))); + }); + }); + + group('font configurations', () { + test('handles null fonts gracefully', () { + final result = buildDeckOptionsFromStyle( + buildStyle(heading: '#FF0000'), + ); + + expect(headingColorFrom(result), equals(const Color(0xFFFF0000))); + expect(bodyColorFrom(result), equals(const Color(0xFF00FF00))); + }); + + test('rejects unknown font IDs at schema level', () { + // Font names are validated against HeadlineFont/BodyFont enums, + // so unknown IDs fail schema parsing before reaching style builder. + final style = DeckStyleType.safeParse({ + 'name': 'Test Style', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#FF0000', + 'body': '#00FF00', + }, + 'fonts': { + 'headline': 'CompletelyUnknownFont', + 'body': 'AnotherUnknownFont', + }, + }).getOrNull(); + + expect(style, isNull); + }); + }); + + group('complete style configurations (colors only)', () { + test('handles complete valid style configuration without fonts', () { + final result = buildDeckOptionsFromStyle( + buildStyle( + heading: '#FF5733', + body: '#33FF57', + background: '#FFFFFF', + ), + ); + + expect(headingColorFrom(result), equals(const Color(0xFFFF5733))); + expect(bodyColorFrom(result), equals(const Color(0xFF33FF57))); + }); + + test('handles realistic AI-generated style without fonts', () { + final result = buildDeckOptionsFromStyle( + buildStyle( + heading: '#2C3E50', + body: '#34495E', + background: '#ECF0F1', + ), + ); + + expect(headingColorFrom(result), equals(const Color(0xFF2C3E50))); + expect(bodyColorFrom(result), equals(const Color(0xFF34495E))); + }); + }); + + group('edge cases', () { + test('returns empty DeckOptions when colors have unknown keys', () { + final style = DeckStyleType.safeParse({ + 'name': 'Invalid Style', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#FF0000', + 'body': '#000000', + 'accent': '#FFFF00', + 'highlight': '#00FFFF', + }, + }).getOrNull(); + final result = buildDeckOptionsFromStyle(style); + + expect(style, isNull); + expect(result.baseStyle, isNull); + }); + + test( + 'returns empty DeckOptions when style has unknown top-level keys', + () { + final style = DeckStyleType.safeParse({ + 'name': 'Invalid Style', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#FF0000', + 'body': '#000000', + }, + 'theme': 'dark', + 'animations': true, + }).getOrNull(); + final result = buildDeckOptionsFromStyle(style); + + expect(style, isNull); + expect(result.baseStyle, isNull); + }, + ); + }); + }); +} diff --git a/packages/genui/test/presentation/thumbnail_preview_service_test.dart b/packages/genui/test/presentation/thumbnail_preview_service_test.dart new file mode 100644 index 00000000..00132ff5 --- /dev/null +++ b/packages/genui/test/presentation/thumbnail_preview_service_test.dart @@ -0,0 +1,207 @@ +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_genui/src/presentation/thumbnail_preview_service.dart'; + +void main() { + group('ThumbnailPreviewService', () { + late List<(SlideConfiguration, BuildContext)> capturedCalls; + late ThumbnailPreviewService service; + + /// Fake capture function that records calls and returns deterministic bytes. + Future fakeCapture( + SlideConfiguration slide, + BuildContext context, + ) async { + capturedCalls.add((slide, context)); + return Uint8List.fromList([slide.slideIndex]); + } + + setUp(() { + capturedCalls = []; + service = ThumbnailPreviewService(captureSlide: fakeCapture); + }); + + testWidgets('returns empty list for empty slides', (tester) async { + await tester.pumpWidget( + Builder( + builder: (context) { + return const SizedBox.shrink(); + }, + ), + ); + + final context = tester.element(find.byType(SizedBox)); + final result = await service.generatePreviews( + context: context, + slides: [], + ); + + expect(result, isEmpty); + expect(capturedCalls, isEmpty); + }); + + testWidgets('captures each slide and returns thumbnails', (tester) async { + await tester.pumpWidget( + Builder( + builder: (context) { + return const SizedBox.shrink(); + }, + ), + ); + + final context = tester.element(find.byType(SizedBox)); + final slides = [ + const Slide(key: 'slide_0'), + const Slide(key: 'slide_1'), + const Slide(key: 'slide_2'), + ]; + + final result = await service.generatePreviews( + context: context, + slides: slides, + ); + + expect(result, hasLength(3)); + expect(capturedCalls, hasLength(3)); + // Each thumbnail contains the slide index byte + expect(result[0].$1, 0); + expect(result[0].$2, Uint8List.fromList([0])); + expect(result[1].$1, 1); + expect(result[1].$2, Uint8List.fromList([1])); + expect(result[2].$1, 2); + expect(result[2].$2, Uint8List.fromList([2])); + }); + + testWidgets('calls onThumbnailCaptured for each slide', (tester) async { + await tester.pumpWidget( + Builder( + builder: (context) { + return const SizedBox.shrink(); + }, + ), + ); + + final context = tester.element(find.byType(SizedBox)); + final slides = [const Slide(key: 'slide_0'), const Slide(key: 'slide_1')]; + final captured = <(int, Uint8List)>[]; + + await service.generatePreviews( + context: context, + slides: slides, + onThumbnailCaptured: (index, bytes) => captured.add((index, bytes)), + ); + + expect(captured, hasLength(2)); + expect(captured[0].$1, 0); + expect(captured[0].$2, Uint8List.fromList([0])); + expect(captured[1].$1, 1); + expect(captured[1].$2, Uint8List.fromList([1])); + }); + + testWidgets('stops when isCancelled returns true', (tester) async { + await tester.pumpWidget( + Builder( + builder: (context) { + return const SizedBox.shrink(); + }, + ), + ); + + final context = tester.element(find.byType(SizedBox)); + final slides = [ + const Slide(key: 'slide_0'), + const Slide(key: 'slide_1'), + const Slide(key: 'slide_2'), + ]; + + var cancelAfter = 1; + final result = await service.generatePreviews( + context: context, + slides: slides, + isCancelled: () => capturedCalls.length >= cancelAfter, + ); + + // Only first slide captured before cancellation check fires + expect(result, hasLength(1)); + expect(capturedCalls, hasLength(1)); + }); + + testWidgets('continues past failed captures', (tester) async { + await tester.pumpWidget( + Builder( + builder: (context) { + return const SizedBox.shrink(); + }, + ), + ); + + final context = tester.element(find.byType(SizedBox)); + final slides = [ + const Slide(key: 'slide_0'), + const Slide(key: 'slide_1'), + const Slide(key: 'slide_2'), + ]; + + var callCount = 0; + final failingService = ThumbnailPreviewService( + captureSlide: (slide, ctx) async { + callCount++; + if (slide.slideIndex == 1) { + throw Exception('Capture failed'); + } + return Uint8List.fromList([slide.slideIndex]); + }, + ); + + final result = await failingService.generatePreviews( + context: context, + slides: slides, + ); + + // Slide 1 failed but 0 and 2 succeeded + expect(result, hasLength(2)); + expect(callCount, 3); + expect(result[0].$1, 0); + expect(result[0].$2, Uint8List.fromList([0])); + expect(result[1].$1, 2); + expect(result[1].$2, Uint8List.fromList([2])); + }); + + testWidgets('does not call onThumbnailCaptured for failed captures', ( + tester, + ) async { + await tester.pumpWidget( + Builder( + builder: (context) { + return const SizedBox.shrink(); + }, + ), + ); + + final context = tester.element(find.byType(SizedBox)); + final slides = [const Slide(key: 'slide_0'), const Slide(key: 'slide_1')]; + final captured = <(int, Uint8List)>[]; + + final failingService = ThumbnailPreviewService( + captureSlide: (slide, ctx) async { + if (slide.slideIndex == 0) throw Exception('Fail'); + return Uint8List.fromList([slide.slideIndex]); + }, + ); + + await failingService.generatePreviews( + context: context, + slides: slides, + onThumbnailCaptured: (index, bytes) => captured.add((index, bytes)), + ); + + // Only slide 1 was captured successfully + expect(captured, hasLength(1)); + expect(captured[0].$1, 1); + expect(captured[0].$2, Uint8List.fromList([1])); + }); + }); +} diff --git a/packages/genui/test/presentation/view/presentation_deck_host_test.dart b/packages/genui/test/presentation/view/presentation_deck_host_test.dart new file mode 100644 index 00000000..8b12b5d9 --- /dev/null +++ b/packages/genui/test/presentation/view/presentation_deck_host_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_genui/src/ai/schemas/deck_schemas.dart'; +import 'package:superdeck_genui/src/utils/deck_style_service.dart'; +import 'package:superdeck_genui/src/presentation/view/presentation_deck_host.dart'; + +void main() { + setUp(() { + DeckStyleService.clearCache(); + }); + + tearDown(() { + DeckStyleService.clearCache(); + }); + + testWidgets('rebuilds deck options when style notifier changes', ( + tester, + ) async { + final seenOptions = []; + + await tester.pumpWidget( + MaterialApp( + home: PresentationDeckHost( + deckAppBuilder: (options) { + seenOptions.add(options); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(seenOptions, hasLength(1)); + expect(seenOptions.last.baseStyle, isNull); + + DeckStyleService.setStyle(DeckStyleType.parse(_styleMap())); + await tester.pump(); + + expect(seenOptions.length, greaterThan(1)); + expect(seenOptions.last.baseStyle, isNotNull); + }); +} + +Map _styleMap() { + return { + 'name': 'Updated', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#112233', + 'body': '#445566', + }, + 'fonts': {'headline': 'montserrat', 'body': 'openSans'}, + }; +} diff --git a/packages/genui/test/presentation/viewmodel/presentation_viewmodel_test.dart b/packages/genui/test/presentation/viewmodel/presentation_viewmodel_test.dart new file mode 100644 index 00000000..5d7c98a7 --- /dev/null +++ b/packages/genui/test/presentation/viewmodel/presentation_viewmodel_test.dart @@ -0,0 +1,642 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck_genui/src/ai/schemas/deck_schemas.dart'; +import 'package:superdeck_genui/src/ai/services/generation_progress.dart'; +import 'package:superdeck_genui/src/ai/wizard_context.dart'; +import 'package:superdeck_genui/src/ai/services/deck_generator_service.dart'; +import 'package:superdeck_genui/src/presentation/presentation_viewmodel.dart'; + +void main() { + late PresentationViewModel viewModel; + + setUp(() { + viewModel = PresentationViewModel(); + }); + + tearDown(() { + viewModel.dispose(); + }); + + // Helper to create a test callback that ignores progress + GenerationCallback cb( + Future Function(WizardContext) fn, + ) => + (context, onProgress) => fn(context); + + group('PresentationViewModel', () { + group('initial state', () { + test('status should be idle', () { + expect(viewModel.status.value, GenerationStatus.idle); + }); + + test('result should be null', () { + expect(viewModel.result.value, isNull); + }); + + test('error should be null', () { + expect(viewModel.error.value, isNull); + }); + }); + + group('generate', () { + test('sets status to generating immediately', () async { + final future = viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb((_) async { + // Delay to allow checking intermediate state + await Future.delayed(const Duration(milliseconds: 10)); + return DeckGenerationResult.success( + path: '/test/path', + slideCount: 5, + ); + }), + ); + + // Check status is generating before completion + expect(viewModel.status.value, GenerationStatus.generating); + + await future; + }); + + test('sets status to preview on successful callback', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/test/path', slideCount: 5), + ), + ); + + expect(viewModel.status.value, GenerationStatus.preview); + }); + + test('stores result on success', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => DeckGenerationResult.success( + path: '/test/path', + slideCount: 5, + style: DeckStyleType.parse({ + 'name': 'Test Style', + 'colors': { + 'background': '#FFFFFF', + 'heading': '#FF0000', + 'body': '#000000', + }, + 'fonts': {'headline': 'montserrat', 'body': 'inter'}, + }), + ), + ), + ); + + expect(viewModel.result.value, isNotNull); + expect(viewModel.result.value!.success, isTrue); + expect(viewModel.result.value!.path, '/test/path'); + expect(viewModel.result.value!.slideCount, 5); + }); + + test('sets status to error when result.success is false', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb((_) async => DeckGenerationResult.failure('API error')), + ); + + expect(viewModel.status.value, GenerationStatus.error); + }); + + test('stores error message from failed result', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb((_) async => DeckGenerationResult.failure('API error')), + ); + + expect(viewModel.error.value, 'API error'); + }); + + test('sets status to error on callback exception', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb((_) async => throw Exception('Network failure')), + ); + + expect(viewModel.status.value, GenerationStatus.error); + }); + + test('stores error message from exception', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb((_) async => throw Exception('Network failure')), + ); + + expect(viewModel.error.value, contains('Network failure')); + }); + + test('clears previous result before generating', () async { + // First generate a success + await viewModel.generate( + context: const WizardContext(topic: 'First'), + callback: cb( + (_) async => DeckGenerationResult.success( + path: '/first/path', + slideCount: 3, + ), + ), + ); + expect(viewModel.result.value, isNotNull); + + // Start a new generation + final future = viewModel.generate( + context: const WizardContext(topic: 'Second'), + callback: cb((_) async { + await Future.delayed(const Duration(milliseconds: 10)); + return DeckGenerationResult.success( + path: '/second/path', + slideCount: 5, + ); + }), + ); + + // Result should be cleared during generation + expect(viewModel.result.value, isNull); + await future; + }); + + test('clears previous error before generating', () async { + // First generate an error + await viewModel.generate( + context: const WizardContext(topic: 'First'), + callback: cb( + (_) async => DeckGenerationResult.failure('First error'), + ), + ); + expect(viewModel.error.value, isNotNull); + + // Start a new generation + final future = viewModel.generate( + context: const WizardContext(topic: 'Second'), + callback: cb((_) async { + await Future.delayed(const Duration(milliseconds: 10)); + return DeckGenerationResult.success(path: '/path', slideCount: 5); + }), + ); + + // Error should be cleared during generation + expect(viewModel.error.value, isNull); + await future; + }); + + test('passes context to callback', () async { + WizardContext? receivedContext; + + await viewModel.generate( + context: const WizardContext(topic: 'Test', slideCount: 10), + callback: cb((ctx) async { + receivedContext = ctx; + return DeckGenerationResult.success(path: '/path', slideCount: 10); + }), + ); + + expect(receivedContext?.topic, 'Test'); + expect(receivedContext?.slideCount, 10); + }); + + test('stores context for retry', () async { + var callCount = 0; + + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb((_) async { + callCount++; + return DeckGenerationResult.success(path: '/path', slideCount: 5); + }), + ); + + expect(callCount, 1); + + // Retry should use stored context + await viewModel.retry(); + expect(callCount, 2); + }); + }); + + group('retry', () { + test('does nothing when no previous context exists', () async { + // No previous generation + await viewModel.retry(); + + expect(viewModel.status.value, GenerationStatus.idle); + expect(viewModel.result.value, isNull); + }); + + test('regenerates with stored context', () async { + WizardContext? lastContext; + + await viewModel.generate( + context: const WizardContext(topic: 'Original Topic'), + callback: cb((ctx) async { + lastContext = ctx; + return DeckGenerationResult.failure('Intentional failure'); + }), + ); + + expect(viewModel.status.value, GenerationStatus.error); + + // Retry should use same context + await viewModel.retry(); + + expect(lastContext?.topic, 'Original Topic'); + }); + + test('uses stored callback', () async { + var specificCallbackCalled = false; + + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb((_) async { + specificCallbackCalled = true; + return DeckGenerationResult.success(path: '/path', slideCount: 5); + }), + ); + + specificCallbackCalled = false; + + await viewModel.retry(); + + expect(specificCallbackCalled, isTrue); + }); + + test('can recover from error on retry', () async { + var attemptCount = 0; + + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb((_) async { + attemptCount++; + if (attemptCount == 1) { + return DeckGenerationResult.failure('First attempt failed'); + } + return DeckGenerationResult.success(path: '/path', slideCount: 5); + }), + ); + + expect(viewModel.status.value, GenerationStatus.error); + + await viewModel.retry(); + + expect(viewModel.status.value, GenerationStatus.preview); + }); + }); + + group('preview flow', () { + test('sets phase to generatingThumbnails on success', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + + expect(viewModel.status.value, GenerationStatus.preview); + expect(viewModel.phase.value, GenerationPhase.generatingThumbnails); + }); + + test( + 'addThumbnailPreview accumulates thumbnails with matching epoch', + () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + + final epoch = viewModel.thumbnailEpoch; + expect(viewModel.thumbnailPreviews.value, isEmpty); + + viewModel.addThumbnailPreview( + 2, + Uint8List.fromList([1, 2, 3]), + epoch: epoch, + ); + expect(viewModel.thumbnailPreviews.value, hasLength(1)); + expect(viewModel.thumbnailPreviews.value[0].$1, 2); + + viewModel.addThumbnailPreview( + 7, + Uint8List.fromList([4, 5, 6]), + epoch: epoch, + ); + expect(viewModel.thumbnailPreviews.value, hasLength(2)); + expect(viewModel.thumbnailPreviews.value[1].$1, 7); + }, + ); + + test('addThumbnailPreview ignores stale epoch', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + + final staleEpoch = viewModel.thumbnailEpoch; + + // Start a new generation which increments the epoch + await viewModel.generate( + context: const WizardContext(topic: 'Test 2'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path2', slideCount: 3), + ), + ); + + // Try adding with stale epoch — should be ignored + viewModel.addThumbnailPreview( + 0, + Uint8List.fromList([1, 2, 3]), + epoch: staleEpoch, + ); + expect(viewModel.thumbnailPreviews.value, isEmpty); + + // Adding with current epoch should work + viewModel.addThumbnailPreview( + 1, + Uint8List.fromList([4, 5, 6]), + epoch: viewModel.thumbnailEpoch, + ); + expect(viewModel.thumbnailPreviews.value, hasLength(1)); + }); + + test( + 'finishThumbnailGeneration resets phase to idle and keeps preview when thumbnails exist', + () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + + final epoch = viewModel.thumbnailEpoch; + viewModel.addThumbnailPreview( + 0, + Uint8List.fromList([1, 2, 3]), + epoch: epoch, + ); + expect(viewModel.phase.value, GenerationPhase.generatingThumbnails); + + viewModel.finishThumbnailGeneration(epoch: epoch); + expect(viewModel.phase.value, GenerationPhase.idle); + // Status remains preview until user proceeds + expect(viewModel.status.value, GenerationStatus.preview); + }, + ); + + test( + 'finishThumbnailGeneration auto-proceeds when all thumbnails fail', + () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + + final epoch = viewModel.thumbnailEpoch; + expect(viewModel.thumbnailPreviews.value, isEmpty); + + viewModel.finishThumbnailGeneration(epoch: epoch); + + expect(viewModel.phase.value, GenerationPhase.idle); + expect(viewModel.status.value, GenerationStatus.success); + }, + ); + + test('finishThumbnailGeneration ignores stale epoch', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + + final staleEpoch = viewModel.thumbnailEpoch; + + // Start a new generation + await viewModel.generate( + context: const WizardContext(topic: 'Test 2'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path2', slideCount: 3), + ), + ); + + // Finish with stale epoch — should be ignored + viewModel.finishThumbnailGeneration(epoch: staleEpoch); + expect(viewModel.phase.value, GenerationPhase.generatingThumbnails); + }); + + test('proceedToPresentation transitions to success', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + + final epoch = viewModel.thumbnailEpoch; + viewModel.addThumbnailPreview( + 0, + Uint8List.fromList([1, 2, 3]), + epoch: epoch, + ); + viewModel.finishThumbnailGeneration(epoch: epoch); + viewModel.proceedToPresentation(); + + expect(viewModel.status.value, GenerationStatus.success); + expect(viewModel.phase.value, GenerationPhase.idle); + }); + + test('thumbnails cleared on new generation', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + + viewModel.addThumbnailPreview( + 0, + Uint8List.fromList([1, 2, 3]), + epoch: viewModel.thumbnailEpoch, + ); + expect(viewModel.thumbnailPreviews.value, hasLength(1)); + + // Start new generation - thumbnails should be cleared + await viewModel.generate( + context: const WizardContext(topic: 'Test 2'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path2', slideCount: 3), + ), + ); + + expect(viewModel.thumbnailPreviews.value, isEmpty); + }); + + test('thumbnailEpoch increments on each generation', () async { + final initialEpoch = viewModel.thumbnailEpoch; + + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + + expect(viewModel.thumbnailEpoch, greaterThan(initialEpoch)); + + final secondEpoch = viewModel.thumbnailEpoch; + + await viewModel.generate( + context: const WizardContext(topic: 'Test 2'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path2', slideCount: 3), + ), + ); + + expect(viewModel.thumbnailEpoch, greaterThan(secondEpoch)); + }); + }); + + group('reset', () { + test('sets status to idle', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + expect(viewModel.status.value, GenerationStatus.preview); + + viewModel.reset(); + + expect(viewModel.status.value, GenerationStatus.idle); + }); + + test('clears result', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + expect(viewModel.result.value, isNotNull); + + viewModel.reset(); + + expect(viewModel.result.value, isNull); + }); + + test('clears error', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb((_) async => DeckGenerationResult.failure('Error')), + ); + expect(viewModel.error.value, isNotNull); + + viewModel.reset(); + + expect(viewModel.error.value, isNull); + }); + + test('clears thumbnails and increments epoch', () async { + await viewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + + final epochBeforeReset = viewModel.thumbnailEpoch; + viewModel.addThumbnailPreview( + 0, + Uint8List.fromList([1, 2, 3]), + epoch: epochBeforeReset, + ); + expect(viewModel.thumbnailPreviews.value, hasLength(1)); + + viewModel.reset(); + + expect(viewModel.thumbnailPreviews.value, isEmpty); + expect(viewModel.thumbnailEpoch, greaterThan(epochBeforeReset)); + }); + + test('from idle keeps state clean', () { + viewModel.reset(); + expect(viewModel.status.value, GenerationStatus.idle); + expect(viewModel.result.value, isNull); + expect(viewModel.error.value, isNull); + }); + }); + + group('dispose', () { + test('is idempotent after generation', () async { + final localViewModel = PresentationViewModel(); + await localViewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb( + (_) async => + DeckGenerationResult.success(path: '/path', slideCount: 5), + ), + ); + expect(localViewModel.status.value, GenerationStatus.preview); + + localViewModel.dispose(); + localViewModel.dispose(); + }); + + test('is idempotent after error path', () async { + final localViewModel = PresentationViewModel(); + await localViewModel.generate( + context: const WizardContext(topic: 'Test'), + callback: cb((_) async => DeckGenerationResult.failure('Error')), + ); + expect(localViewModel.status.value, GenerationStatus.error); + + localViewModel.dispose(); + localViewModel.dispose(); + }); + }); + + group('GenerationStatus enum', () { + test('has all expected values', () { + expect( + GenerationStatus.values, + containsAll([ + GenerationStatus.idle, + GenerationStatus.generating, + GenerationStatus.preview, + GenerationStatus.success, + GenerationStatus.error, + ]), + ); + }); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 528e9548..6c547878 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -146,10 +146,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" melos: dependency: "direct main" description: From 43b6bf67e4f118cd753599803b61a6d6df0fbefa Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 26 Feb 2026 00:07:04 -0500 Subject: [PATCH 2/7] fix(genui): add bootstrap initialization and route host overrides --- packages/genui/README.md | 25 ++- .../lib/src/ai/prompts/examples_loader.dart | 6 +- .../lib/src/ai/prompts/prompt_registry.dart | 6 +- .../lib/src/bootstrap/genui_bootstrap.dart | 182 ++++++++++++++++++ packages/genui/lib/src/routes.dart | 29 ++- packages/genui/lib/superdeck_genui.dart | 1 + 6 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 packages/genui/lib/src/bootstrap/genui_bootstrap.dart diff --git a/packages/genui/README.md b/packages/genui/README.md index 3b956b2d..601ed6e8 100644 --- a/packages/genui/README.md +++ b/packages/genui/README.md @@ -20,20 +20,39 @@ Set your Gemini API key via either method: 1. **Build-time** (recommended): `--dart-define=GOOGLE_AI_API_KEY=xxx` 2. **Runtime** (dev only): Create a `.env` file with `GOOGLE_AI_API_KEY=xxx` +`genUiRoutes()` automatically initializes GenUI runtime dependencies +(paths, prompt assets, examples, and optional `.env` loading). + ## Usage ```dart import 'package:superdeck_genui/superdeck_genui.dart'; +// Optional (recommended for custom/manual integration) +await initializeGenUi(); + // Add routes to your GoRouter final router = GoRouter( routes: [...genUiRoutes()], ); +// Optional: override default route screens (for custom host integration) +final customRouter = GoRouter( + routes: [ + ...genUiRoutes( + presentationBuilder: (context, state) { + return PresentationDeckHost( + deckAppBuilder: (options) => MyPresentationApp(options: options), + ); + }, + ), + ], +); + // Or use individual screens directly -const ChatScreen(); -const CreatingPresentationScreen(); -const PresentationDeckHost(); +const GenUiBootstrapScope(child: ChatScreen()); +const GenUiBootstrapScope(child: CreatingPresentationScreen()); +const GenUiBootstrapScope(child: PresentationDeckHost()); ``` ## Related packages diff --git a/packages/genui/lib/src/ai/prompts/examples_loader.dart b/packages/genui/lib/src/ai/prompts/examples_loader.dart index 9475e4f9..ac01419c 100644 --- a/packages/genui/lib/src/ai/prompts/examples_loader.dart +++ b/packages/genui/lib/src/ai/prompts/examples_loader.dart @@ -27,7 +27,11 @@ class ExamplesLoader { Future load() { if (_loaded) return Future.value(); if (_loading != null) return _loading!; - _loading = _loadInternal(); + + _loading = _loadInternal().catchError((error) { + _loading = null; + throw error; + }); return _loading!; } diff --git a/packages/genui/lib/src/ai/prompts/prompt_registry.dart b/packages/genui/lib/src/ai/prompts/prompt_registry.dart index dd955689..05f3bd0b 100644 --- a/packages/genui/lib/src/ai/prompts/prompt_registry.dart +++ b/packages/genui/lib/src/ai/prompts/prompt_registry.dart @@ -54,7 +54,11 @@ class PromptRegistry { if (_loading != null) { return _loading!; } - _loading = _loadInternal(); + + _loading = _loadInternal().catchError((error) { + _loading = null; + throw error; + }); return _loading!; } diff --git a/packages/genui/lib/src/bootstrap/genui_bootstrap.dart b/packages/genui/lib/src/bootstrap/genui_bootstrap.dart new file mode 100644 index 00000000..3d958770 --- /dev/null +++ b/packages/genui/lib/src/bootstrap/genui_bootstrap.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +import '../ai/prompts/examples_loader.dart'; +import '../ai/prompts/prompt_registry.dart'; +import '../debug_logger.dart'; +import '../env_config.dart'; +import '../path_service.dart'; + +/// Public initializer for the GenUI package. +/// +/// Call this at app startup when integrating screens manually. +/// Route-based integrations via `genUiRoutes()` initialize automatically. +Future initializeGenUi({ + bool loadDotEnv = true, + String dotenvFileName = '.env', +}) { + return GenUiBootstrap.ensureInitialized( + loadDotEnv: loadDotEnv, + dotenvFileName: dotenvFileName, + ); +} + +/// Internal bootstrap coordinator for runtime dependencies. +/// +/// Initializes: +/// - [PathService] runtime paths +/// - Optional `.env` loading (for development) +/// - Prompt and example asset registries +/// - Debug logger +abstract final class GenUiBootstrap { + static bool _initialized = false; + static Future? _initializing; + + static bool get isInitialized => _initialized; + + static Future ensureInitialized({ + bool loadDotEnv = true, + String dotenvFileName = '.env', + }) { + if (_initialized) { + return Future.value(); + } + + if (_initializing case final initializing?) { + return initializing; + } + + final future = _initialize( + loadDotEnv: loadDotEnv, + dotenvFileName: dotenvFileName, + ); + + _initializing = future; + return future; + } + + static Future _initialize({ + required bool loadDotEnv, + required String dotenvFileName, + }) async { + try { + await PathService.instance.initialize(); + + if (loadDotEnv) { + await _loadDotEnvIfNeeded(dotenvFileName: dotenvFileName); + } + + await PromptRegistry.instance.load(); + await ExamplesLoader.instance.load(); + await debugLog.init(); + + _initialized = true; + _initializing = null; + } catch (error) { + _initialized = false; + _initializing = null; + rethrow; + } + } + + static Future _loadDotEnvIfNeeded({ + required String dotenvFileName, + }) async { + if (EnvConfig.hasGeminiApiKey) { + return; + } + + try { + await dotenv.load(fileName: dotenvFileName); + } catch (_) { + // Dotenv is optional; missing files should not block startup. + } + } + + @visibleForTesting + static void resetForTest() { + _initialized = false; + _initializing = null; + } +} + +/// Async bootstrap guard for embedding GenUI screens directly. +class GenUiBootstrapScope extends StatefulWidget { + final Widget child; + final bool loadDotEnv; + final String dotenvFileName; + final Widget? loading; + + const GenUiBootstrapScope({ + super.key, + required this.child, + this.loadDotEnv = true, + this.dotenvFileName = '.env', + this.loading, + }); + + @override + State createState() => _GenUiBootstrapScopeState(); +} + +class _GenUiBootstrapScopeState extends State { + late Future _bootstrapFuture; + + @override + void initState() { + super.initState(); + _bootstrapFuture = _bootstrap(); + } + + Future _bootstrap() { + return GenUiBootstrap.ensureInitialized( + loadDotEnv: widget.loadDotEnv, + dotenvFileName: widget.dotenvFileName, + ); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _bootstrapFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return widget.loading ?? + const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + if (snapshot.hasError) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Failed to initialize SuperDeck GenUI.'), + const SizedBox(height: 12), + Text( + snapshot.error.toString(), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + setState(() { + _bootstrapFuture = _bootstrap(); + }); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + + return widget.child; + }, + ); + } +} diff --git a/packages/genui/lib/src/routes.dart b/packages/genui/lib/src/routes.dart index 703c081f..9666456a 100644 --- a/packages/genui/lib/src/routes.dart +++ b/packages/genui/lib/src/routes.dart @@ -1,8 +1,10 @@ +import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'chat/view/chat_screen.dart'; import 'presentation/view/creating_presentation_screen.dart'; import 'presentation/view/presentation_deck_host.dart'; +import 'bootstrap/genui_bootstrap.dart'; import 'utils/deck_style_service.dart'; void _applyStyleFromExtra(Object? extra) { @@ -31,21 +33,40 @@ abstract final class GenUiRoutes { /// ], /// ); /// ``` -List genUiRoutes() => [ +/// +/// Optional builders let hosts override the default screens while preserving +/// GenUI bootstrap initialization. +List genUiRoutes({ + Widget Function(BuildContext context, GoRouterState state)? chatBuilder, + Widget Function(BuildContext context, GoRouterState state)? creatingBuilder, + Widget Function(BuildContext context, GoRouterState state)? + presentationBuilder, +}) => [ GoRoute( path: GenUiRoutes.chat, - builder: (context, state) => const ChatScreen(), + builder: (context, state) { + final child = chatBuilder?.call(context, state) ?? const ChatScreen(); + return GenUiBootstrapScope(child: child); + }, ), GoRoute( path: GenUiRoutes.presentationCreating, - builder: (context, state) => const CreatingPresentationScreen(), + builder: (context, state) { + final child = + creatingBuilder?.call(context, state) ?? + const CreatingPresentationScreen(); + return GenUiBootstrapScope(child: child); + }, ), GoRoute( path: GenUiRoutes.presentation, builder: (context, state) { _applyStyleFromExtra(state.extra); - return const PresentationDeckHost(); + final child = + presentationBuilder?.call(context, state) ?? + const PresentationDeckHost(); + return GenUiBootstrapScope(child: child); }, ), ]; diff --git a/packages/genui/lib/superdeck_genui.dart b/packages/genui/lib/superdeck_genui.dart index 7913c779..b7c4d09e 100644 --- a/packages/genui/lib/superdeck_genui.dart +++ b/packages/genui/lib/superdeck_genui.dart @@ -23,6 +23,7 @@ export 'src/presentation/view/presentation_deck_host.dart'; // Routes export 'src/routes.dart'; +export 'src/bootstrap/genui_bootstrap.dart'; // UI Components export 'src/ui/ui.dart'; From 8dfa59da1e89fe87cc600e6e8df5d817208016fa Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 26 Feb 2026 00:15:43 -0500 Subject: [PATCH 3/7] fix(ci): align workspace sdk constraints for bootstrap --- demo/pubspec.yaml | 4 ++-- melos.yaml | 4 ++-- packages/builder/pubspec.yaml | 2 +- packages/cli/pubspec.yaml | 2 +- packages/core/pubspec.yaml | 2 +- packages/superdeck/pubspec.yaml | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/demo/pubspec.yaml b/demo/pubspec.yaml index d75ffce6..cf1eddb5 100644 --- a/demo/pubspec.yaml +++ b/demo/pubspec.yaml @@ -3,8 +3,8 @@ description: An example presentation for SuperDeck publish_to: none version: 1.0.0+1 environment: - sdk: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + sdk: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" dependencies: flutter: sdk: flutter diff --git a/melos.yaml b/melos.yaml index 44f3e02b..af4cac3a 100644 --- a/melos.yaml +++ b/melos.yaml @@ -8,8 +8,8 @@ packages: command: bootstrap: environment: - sdk: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + sdk: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" dependencies: collection: ^1.18.0 mix: ^2.0.0-rc.0 diff --git a/packages/builder/pubspec.yaml b/packages/builder/pubspec.yaml index 0fab6267..f615ffbc 100644 --- a/packages/builder/pubspec.yaml +++ b/packages/builder/pubspec.yaml @@ -4,7 +4,7 @@ version: 1.0.0 homepage: https://github.com/leoafarias/superdeck environment: - sdk: ">=3.9.0 <4.0.0" + sdk: ">=3.10.0 <4.0.0" dependencies: diff --git a/packages/cli/pubspec.yaml b/packages/cli/pubspec.yaml index d4de8902..6079342f 100644 --- a/packages/cli/pubspec.yaml +++ b/packages/cli/pubspec.yaml @@ -6,7 +6,7 @@ homepage: https://github.com/leoafarias/superdeck executables: superdeck: main environment: - sdk: ">=3.9.0 <4.0.0" + sdk: ">=3.10.0 <4.0.0" dependencies: diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 25485485..e287b36b 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://github.com/leoafarias/superdeck environment: - sdk: ">=3.9.0 <4.0.0" + sdk: ">=3.10.0 <4.0.0" # Add regular dependencies here. diff --git a/packages/superdeck/pubspec.yaml b/packages/superdeck/pubspec.yaml index 46bfcad8..8aa5bfa4 100644 --- a/packages/superdeck/pubspec.yaml +++ b/packages/superdeck/pubspec.yaml @@ -4,8 +4,8 @@ version: 1.0.0 homepage: https://github.com/leoafarias/superdeck environment: - sdk: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + sdk: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" dependencies: From 96d60c883ec07d74ad81b1e0cdc52e1c42aadb85 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 26 Feb 2026 08:11:43 -0500 Subject: [PATCH 4/7] fix(genui): harden runtime safety and address PR review feedback - Guard JSON decode with type check instead of unsafe cast - Use pattern matching for imageRequirement map access - Wrap image generation in try/finally to ensure dispose on error - Log dotenv loading errors instead of silently swallowing - Replace assert with FlutterError in ViewModelScope for release builds - Use is-checks instead of unsafe as-casts in slide key extraction - Switch color_utils logging to debugLog (debug-gated) - Clarify sanitize docstring to not overstate security guarantee - Include raw style value in schema parse error messages - Replace GestureDetector with InkWell for accessibility --- .../ai/services/deck_generator_pipeline.dart | 139 ++++++++++-------- .../lib/src/ai/services/prompt_builder.dart | 2 +- .../lib/src/ai/services/slide_key_utils.dart | 6 +- .../lib/src/bootstrap/genui_bootstrap.dart | 4 +- .../src/chat/view/widgets/empty_state.dart | 10 +- .../lib/src/tools/deck_document_store.dart | 13 +- packages/genui/lib/src/utils/color_utils.dart | 5 +- packages/genui/lib/src/viewmodel_scope.dart | 6 +- 8 files changed, 104 insertions(+), 81 deletions(-) diff --git a/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart b/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart index 8a58d8d7..384c59f6 100644 --- a/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart +++ b/packages/genui/lib/src/ai/services/deck_generator_pipeline.dart @@ -128,74 +128,78 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { // Process in batches of 3 to avoid rate limits const batchSize = 3; - for (var i = 0; i < requirements.length; i += batchSize) { - final batch = requirements.skip(i).take(batchSize).toList(); - debugLog.log( - 'IMG', - 'Processing batch ${i ~/ batchSize + 1}: ' - '${batch.map((r) => r.slideKey).join(', ')}', - ); - - await Future.wait( - batch.map((req) async { - final safeKey = _fileSafeKey(req.slideKey, requirements.indexOf(req)); - final filename = 'slide-$safeKey-illustration.png'; - final outputPath = p.join(Paths.superdeckAssetsPath, filename); - - try { - // Build prompt with style (if present) and always wrap with - // ImageGeneratorService.buildPrompt for presentation constraints - final basePrompt = style != null - ? style.buildPrompt(req.subject) - : req.subject; - final prompt = ImageGeneratorService.buildPrompt( - basePrompt, - backgroundColor: backgroundColor, - ); - debugLog.log( - 'IMG', - '[${req.slideKey}] Generating with prompt ' - '(${prompt.length} chars):\n$prompt', - ); - - final imgStart = DateTime.now(); - final result = await imageService.generateImage(prompt); - final imgMs = DateTime.now().difference(imgStart).inMilliseconds; - - if (result.success && result.bytes != null) { - final bytes = result.bytes as Uint8List; - await File(outputPath).writeAsBytes(bytes, flush: true); - successes[req.slideKey] = '.superdeck/assets/$filename'; - completed++; + try { + for (var i = 0; i < requirements.length; i += batchSize) { + final batch = requirements.skip(i).take(batchSize).toList(); + debugLog.log( + 'IMG', + 'Processing batch ${i ~/ batchSize + 1}: ' + '${batch.map((r) => r.slideKey).join(', ')}', + ); + + await Future.wait( + batch.map((req) async { + final safeKey = + _fileSafeKey(req.slideKey, requirements.indexOf(req)); + final filename = 'slide-$safeKey-illustration.png'; + final outputPath = p.join(Paths.superdeckAssetsPath, filename); + + try { + // Build prompt with style (if present) and always wrap with + // ImageGeneratorService.buildPrompt for presentation constraints + final basePrompt = style != null + ? style.buildPrompt(req.subject) + : req.subject; + final prompt = ImageGeneratorService.buildPrompt( + basePrompt, + backgroundColor: backgroundColor, + ); debugLog.log( 'IMG', - '[${req.slideKey}] OK in ${imgMs}ms - ' - '${bytes.length} bytes → $outputPath', + '[${req.slideKey}] Generating with prompt ' + '(${prompt.length} chars):\n$prompt', ); - } else { - failures[req.slideKey] = result.error ?? 'Unknown error'; + + final imgStart = DateTime.now(); + final result = await imageService.generateImage(prompt); + final imgMs = + DateTime.now().difference(imgStart).inMilliseconds; + + if (result.success && result.bytes != null) { + final bytes = result.bytes as Uint8List; + await File(outputPath).writeAsBytes(bytes, flush: true); + successes[req.slideKey] = '.superdeck/assets/$filename'; + completed++; + debugLog.log( + 'IMG', + '[${req.slideKey}] OK in ${imgMs}ms - ' + '${bytes.length} bytes → $outputPath', + ); + } else { + failures[req.slideKey] = result.error ?? 'Unknown error'; + failed++; + debugLog.log( + 'IMG', + '[${req.slideKey}] FAILED in ${imgMs}ms: ${result.error}', + ); + } + } catch (e, stack) { + failures[req.slideKey] = e.toString(); failed++; - debugLog.log( + debugLog.error( 'IMG', - '[${req.slideKey}] FAILED in ${imgMs}ms: ${result.error}', + '[${req.slideKey}] EXCEPTION: ${e.runtimeType}', + stack, ); } - } catch (e, stack) { - failures[req.slideKey] = e.toString(); - failed++; - debugLog.error( - 'IMG', - '[${req.slideKey}] EXCEPTION: ${e.runtimeType}', - stack, - ); - } - - onProgress?.call(completed, failed); - }), - ); - } - imageService.dispose(); + onProgress?.call(completed, failed); + }), + ); + } + } finally { + imageService.dispose(); + } return _ImageGenerationResults(successes: successes, failures: failures); } @@ -314,9 +318,8 @@ $outlineContext buffer.writeln('- **${slide['key']}**: ${slide['title']}'); buffer.writeln(' Layout: ${slide['layoutHint']}'); buffer.writeln(' Purpose: ${slide['purpose']}'); - if (slide['imageRequirement'] != null) { - final subject = (slide['imageRequirement'] as Map)['subject']; - buffer.writeln(' Image: $subject'); + if (slide['imageRequirement'] case Map imageReq) { + buffer.writeln(' Image: ${imageReq['subject']}'); } buffer.writeln(''); } @@ -354,7 +357,15 @@ $outlineContext final jsonText = textParts.join(''); try { - return jsonDecode(jsonText) as Map; + final decoded = jsonDecode(jsonText); + if (decoded is! Map) { + debugLog.error( + 'DECK_GEN', + 'Expected JSON map for $context, got ${decoded.runtimeType}', + ); + return null; + } + return decoded; } catch (e) { debugLog.error('DECK_GEN', 'JSON parse failed for $context: $e'); return null; diff --git a/packages/genui/lib/src/ai/services/prompt_builder.dart b/packages/genui/lib/src/ai/services/prompt_builder.dart index 11ab241d..bb75dade 100644 --- a/packages/genui/lib/src/ai/services/prompt_builder.dart +++ b/packages/genui/lib/src/ai/services/prompt_builder.dart @@ -22,7 +22,7 @@ String buildPromptFromWizardContext(WizardContext context) { /// Maximum length for user-provided text fields. const int _maxFieldLength = 500; -/// Sanitizes user input to prevent prompt injection. +/// Sanitizes user input to reduce prompt injection risk. /// /// - Truncates to max length /// - Removes control characters (except newlines/tabs) diff --git a/packages/genui/lib/src/ai/services/slide_key_utils.dart b/packages/genui/lib/src/ai/services/slide_key_utils.dart index 989d1bdb..ac7d13ae 100644 --- a/packages/genui/lib/src/ai/services/slide_key_utils.dart +++ b/packages/genui/lib/src/ai/services/slide_key_utils.dart @@ -21,11 +21,13 @@ String? _extractFirstContent(Map slide) { if (sections == null || sections.isEmpty) return null; for (final section in sections) { - final blocks = (section as Map?)?['blocks'] as List?; + if (section is! Map) continue; + final blocks = section['blocks'] as List?; if (blocks == null) continue; for (final block in blocks) { - final content = (block as Map?)?['content'] as String?; + if (block is! Map) continue; + final content = block['content'] as String?; if (content != null && content.isNotEmpty) { return content; } diff --git a/packages/genui/lib/src/bootstrap/genui_bootstrap.dart b/packages/genui/lib/src/bootstrap/genui_bootstrap.dart index 3d958770..10105e8a 100644 --- a/packages/genui/lib/src/bootstrap/genui_bootstrap.dart +++ b/packages/genui/lib/src/bootstrap/genui_bootstrap.dart @@ -88,8 +88,8 @@ abstract final class GenUiBootstrap { try { await dotenv.load(fileName: dotenvFileName); - } catch (_) { - // Dotenv is optional; missing files should not block startup. + } catch (e) { + debugLog.log('BOOTSTRAP', 'Dotenv loading skipped: $e'); } } diff --git a/packages/genui/lib/src/chat/view/widgets/empty_state.dart b/packages/genui/lib/src/chat/view/widgets/empty_state.dart index 0ebbc04c..359e958a 100644 --- a/packages/genui/lib/src/chat/view/widgets/empty_state.dart +++ b/packages/genui/lib/src/chat/view/widgets/empty_state.dart @@ -72,13 +72,11 @@ class EmptyState extends StatelessWidget { 'Sales report Q4', 'Team onboarding', ]) - GestureDetector( + InkWell( onTap: () => onSuggestionTap?.call(suggestion), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: suggestionChip( - child: suggestionText('"$suggestion"'), - ), + borderRadius: BorderRadius.circular(12), + child: suggestionChip( + child: suggestionText('"$suggestion"'), ), ), ], diff --git a/packages/genui/lib/src/tools/deck_document_store.dart b/packages/genui/lib/src/tools/deck_document_store.dart index 57c560a2..498a0b65 100644 --- a/packages/genui/lib/src/tools/deck_document_store.dart +++ b/packages/genui/lib/src/tools/deck_document_store.dart @@ -67,9 +67,18 @@ class DeckDocumentStore { DeckStyleType? style; if (map.containsKey('style') && map['style'] != null) { - style = DeckStyleType.safeParse(map['style']).getOrNull(); + final rawStyle = map['style']; + style = DeckStyleType.safeParse(rawStyle).getOrNull(); if (style == null) { - throw DeckToolException.deckSchemaInvalid('Invalid "style" object'); + String styleDetails; + try { + styleDetails = jsonEncode(rawStyle); + } catch (_) { + styleDetails = rawStyle.toString(); + } + throw DeckToolException.deckSchemaInvalid( + 'Invalid "style" object: failed to parse value $styleDetails', + ); } } diff --git a/packages/genui/lib/src/utils/color_utils.dart b/packages/genui/lib/src/utils/color_utils.dart index 96fc4414..47091aff 100644 --- a/packages/genui/lib/src/utils/color_utils.dart +++ b/packages/genui/lib/src/utils/color_utils.dart @@ -1,6 +1,7 @@ -import 'dart:developer' as developer; import 'dart:ui'; +import '../debug_logger.dart'; + /// Result of parsing a hex color string. typedef ColorParseResult = ({Color color, bool isValid}); @@ -32,7 +33,7 @@ ColorParseResult parseHexColor(String hex) { } return (color: Color(int.parse(hex, radix: 16)), isValid: true); } catch (e) { - developer.log('Invalid hex color: $hex', name: 'ColorUtils', error: e); + debugLog.log('ColorUtils', 'Invalid hex color: $hex ($e)'); return (color: _fallbackGray, isValid: false); } } diff --git a/packages/genui/lib/src/viewmodel_scope.dart b/packages/genui/lib/src/viewmodel_scope.dart index a68d3461..c236ea92 100644 --- a/packages/genui/lib/src/viewmodel_scope.dart +++ b/packages/genui/lib/src/viewmodel_scope.dart @@ -24,8 +24,10 @@ class ViewModelScope extends StatefulWidget { final element = context .getElementForInheritedWidgetOfExactType<_ViewModelInherited>(); final widget = element?.widget as _ViewModelInherited?; - assert(widget != null, 'No ViewModelScope<$T> found in context'); - return widget!.viewModel; + if (widget == null) { + throw FlutterError('No ViewModelScope<$T> found in context'); + } + return widget.viewModel; } @override From d17c377cda2a46bce4b2c44995f4518735238e40 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 26 Feb 2026 09:25:19 -0500 Subject: [PATCH 5/7] feat: add superdeck plugin system and genui integration --- demo/lib/main.dart | 10 ++--- demo/pubspec.yaml | 1 + packages/genui/lib/src/genui_plugin.dart | 29 +++++++++++++ packages/genui/lib/superdeck_genui.dart | 1 + .../lib/src/deck/deck_controller.dart | 20 +++++++-- .../superdeck/lib/src/deck/deck_options.dart | 11 ++++- .../lib/src/deck/navigation_service.dart | 6 ++- .../lib/src/deck/superdeck_plugin.dart | 22 ++++++++++ packages/superdeck/lib/src/ui/app_shell.dart | 41 +++++++++++++++++-- .../lib/src/ui/panels/bottom_bar.dart | 4 ++ .../superdeck/lib/src/ui/superdeck_app.dart | 8 +++- packages/superdeck/lib/superdeck.dart | 1 + 12 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 packages/genui/lib/src/genui_plugin.dart create mode 100644 packages/superdeck/lib/src/deck/superdeck_plugin.dart diff --git a/demo/lib/main.dart b/demo/lib/main.dart index bb06545d..afab1232 100644 --- a/demo/lib/main.dart +++ b/demo/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:signals_flutter/signals_flutter.dart'; import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_genui/superdeck_genui.dart'; import 'src/parts/background.dart'; import 'src/parts/footer.dart'; @@ -11,6 +12,7 @@ import 'src/widgets/demo_widgets.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + const plugins = [GenUiPlugin()]; // Disable signals logging to reduce console noise SignalsObserver.instance = null; @@ -18,17 +20,14 @@ void main() async { // Enable semantics for testing WidgetsBinding.instance.ensureSemantics(); - await SuperDeckApp.initialize(); + await SuperDeckApp.initialize(plugins: plugins); runApp( SuperDeckApp( options: DeckOptions( baseStyle: borderedStyle(), widgets: {...demoWidgets, 'twitter': const _TwitterWidgetDefinition()}, // debug: true, - styles: { - 'announcement': announcementStyle(), - 'quote': quoteStyle(), - }, + styles: {'announcement': announcementStyle(), 'quote': quoteStyle()}, templates: { 'corporate': corporateTemplate(), 'minimal': minimalTemplate(), @@ -39,6 +38,7 @@ void main() async { background: BackgroundPart(), ), watchForChanges: true, + plugins: plugins, ), ), ); diff --git a/demo/pubspec.yaml b/demo/pubspec.yaml index cf1eddb5..1ae144c9 100644 --- a/demo/pubspec.yaml +++ b/demo/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: mix: ^2.0.0-rc.0 superdeck_core: ^1.0.0 superdeck: ^1.0.0 + superdeck_genui: ^1.0.0 signals_flutter: ^6.0.4 naked_ui: ^0.2.0-beta.7 remix: ^0.1.0-beta.1 diff --git a/packages/genui/lib/src/genui_plugin.dart b/packages/genui/lib/src/genui_plugin.dart new file mode 100644 index 00000000..17f7d2ed --- /dev/null +++ b/packages/genui/lib/src/genui_plugin.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart' show Icon, IconButton, Icons; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:superdeck/superdeck.dart'; + +import 'bootstrap/genui_bootstrap.dart'; +import 'routes.dart'; + +/// SuperDeck plugin that installs GenUI routes, initialization, and actions. +class GenUiPlugin extends SuperDeckPlugin { + const GenUiPlugin(); + + @override + String get name => 'genui'; + + @override + Future initialize() => initializeGenUi(); + + @override + List buildRoutes() => genUiRoutes(); + + @override + List buildActions(BuildContext context) => [ + IconButton( + icon: const Icon(Icons.auto_awesome), + onPressed: () => GoRouter.of(context).go(GenUiRoutes.chat), + ), + ]; +} diff --git a/packages/genui/lib/superdeck_genui.dart b/packages/genui/lib/superdeck_genui.dart index b7c4d09e..e19ab5ea 100644 --- a/packages/genui/lib/superdeck_genui.dart +++ b/packages/genui/lib/superdeck_genui.dart @@ -24,6 +24,7 @@ export 'src/presentation/view/presentation_deck_host.dart'; // Routes export 'src/routes.dart'; export 'src/bootstrap/genui_bootstrap.dart'; +export 'src/genui_plugin.dart'; // UI Components export 'src/ui/ui.dart'; diff --git a/packages/superdeck/lib/src/deck/deck_controller.dart b/packages/superdeck/lib/src/deck/deck_controller.dart index d5eea850..4982feb2 100644 --- a/packages/superdeck/lib/src/deck/deck_controller.dart +++ b/packages/superdeck/lib/src/deck/deck_controller.dart @@ -16,6 +16,7 @@ import 'navigation_events.dart'; import 'navigation_service.dart'; import 'slide_configuration.dart'; import 'slide_configuration_builder.dart'; +import 'superdeck_plugin.dart'; /// Loading state for the deck enum DeckLoadingState { idle, loading, loaded, error } @@ -34,6 +35,7 @@ class DeckController { final NavigationService _navigationService; final ThumbnailService _thumbnailService; final bool _enableDeckStream; + final List _plugins; // Disposal guard to prevent accessing disposed signals // ignore: prefer_final_fields @@ -97,6 +99,7 @@ class DeckController { ReadonlySignal get isMenuOpen => _isMenuOpen; ReadonlySignal get isNotesOpen => _isNotesOpen; ReadonlySignal get isRebuilding => _isRebuilding; + List get plugins => _plugins; // Navigation computeds ReadonlySignal get currentIndex => _currentIndex; @@ -136,12 +139,17 @@ class DeckController { configuration: deckService.configuration, ), ), - _enableDeckStream = enableDeckStream { - _options.value = options; + _enableDeckStream = enableDeckStream, + _plugins = options.plugins { + _options.value = options.copyWith(plugins: _plugins); + final pluginRoutes = _plugins + .expand((plugin) => plugin.buildRoutes()) + .toList(growable: false); // Create router with index change callback router = _navigationService.createRouter( onIndexChanged: (index) => _updateCurrentIndex(index), + additionalRoutes: pluginRoutes, ); // Clamp index when slide count changes (e.g., deck reloads with fewer slides) @@ -224,8 +232,12 @@ class DeckController { @internal void updateOptions(DeckOptions newOptions) { if (_disposed) return; - if (_options.value != newOptions) { - _options.value = newOptions; + final normalizedOptions = identical(newOptions.plugins, _plugins) + ? newOptions + : newOptions.copyWith(plugins: _plugins); + + if (_options.value != normalizedOptions) { + _options.value = normalizedOptions; } } diff --git a/packages/superdeck/lib/src/deck/deck_options.dart b/packages/superdeck/lib/src/deck/deck_options.dart index 6b4ee690..ef2a46e8 100644 --- a/packages/superdeck/lib/src/deck/deck_options.dart +++ b/packages/superdeck/lib/src/deck/deck_options.dart @@ -1,6 +1,7 @@ import '../rendering/slides/slide_parts.dart'; import '../styling/styling.dart'; import 'slide_template.dart'; +import 'superdeck_plugin.dart'; import 'widget_definition.dart'; class DeckOptions { @@ -26,6 +27,9 @@ class DeckOptions { /// Defaults to `false`. final bool watchForChanges; + /// Optional plugin descriptors that extend deck behavior. + final List plugins; + const DeckOptions({ this.baseStyle, this.styles = const {}, @@ -35,6 +39,7 @@ class DeckOptions { this.templates = const {}, this.defaultTemplate, this.watchForChanges = false, + this.plugins = const [], }); DeckOptions copyWith({ @@ -46,6 +51,7 @@ class DeckOptions { Map? templates, SlideTemplate? defaultTemplate, bool? watchForChanges, + List? plugins, }) { return DeckOptions( baseStyle: baseStyle ?? this.baseStyle, @@ -56,6 +62,7 @@ class DeckOptions { templates: templates ?? this.templates, defaultTemplate: defaultTemplate ?? this.defaultTemplate, watchForChanges: watchForChanges ?? this.watchForChanges, + plugins: plugins ?? this.plugins, ); } @@ -71,7 +78,8 @@ class DeckOptions { debug == other.debug && templates == other.templates && defaultTemplate == other.defaultTemplate && - watchForChanges == other.watchForChanges; + watchForChanges == other.watchForChanges && + plugins == other.plugins; @override int get hashCode => Object.hash( @@ -83,5 +91,6 @@ class DeckOptions { templates, defaultTemplate, watchForChanges, + plugins, ); } diff --git a/packages/superdeck/lib/src/deck/navigation_service.dart b/packages/superdeck/lib/src/deck/navigation_service.dart index 7a349c74..3fbd651b 100644 --- a/packages/superdeck/lib/src/deck/navigation_service.dart +++ b/packages/superdeck/lib/src/deck/navigation_service.dart @@ -24,7 +24,10 @@ class NavigationService { /// /// The [onIndexChanged] callback is invoked on every page build. /// Deduplication of redundant calls is handled by the controller. - GoRouter createRouter({required void Function(int) onIndexChanged}) { + GoRouter createRouter({ + required void Function(int) onIndexChanged, + List additionalRoutes = const [], + }) { return GoRouter( initialLocation: '/slides/0', // Handle root path - can occur on initial load or direct URL access @@ -51,6 +54,7 @@ class NavigationService { ); }, ), + ...additionalRoutes, ], ); } diff --git a/packages/superdeck/lib/src/deck/superdeck_plugin.dart b/packages/superdeck/lib/src/deck/superdeck_plugin.dart new file mode 100644 index 00000000..e2977bc6 --- /dev/null +++ b/packages/superdeck/lib/src/deck/superdeck_plugin.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; + +/// Describes deck extensions that can be installed by host applications. +abstract class SuperDeckPlugin { + const SuperDeckPlugin(); + + /// Unique plugin name. + String get name; + + /// Extra routes contributed by this plugin. + List buildRoutes() => const []; + + /// Inline action widgets rendered in the deck bottom bar. + List buildActions(BuildContext context) => const []; + + /// Optional floating action shown when the menu is closed. + Widget? buildFloatingAction(BuildContext context) => null; + + /// One-time async initialization hook called at app startup. + Future initialize() async {} +} diff --git a/packages/superdeck/lib/src/ui/app_shell.dart b/packages/superdeck/lib/src/ui/app_shell.dart index e4c4f87d..9cfb96f2 100644 --- a/packages/superdeck/lib/src/ui/app_shell.dart +++ b/packages/superdeck/lib/src/ui/app_shell.dart @@ -159,6 +159,39 @@ class _SplitViewState extends State }); } + Widget? _buildFloatingAction({ + required BuildContext context, + required DeckController deckController, + required bool isMenuOpen, + }) { + if (isMenuOpen) { + return null; + } + + final menuButton = SDIconButton( + icon: Icons.menu, + onPressed: deckController.openMenu, + ); + Widget? pluginAction; + for (final plugin in deckController.plugins) { + final action = plugin.buildFloatingAction(context); + if (action != null) { + pluginAction = action; + break; + } + } + + if (pluginAction == null) { + return menuButton; + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [pluginAction, const SizedBox(height: 12), menuButton], + ); + } + @override Widget build(BuildContext context) { final deckController = DeckController.of(context); @@ -173,9 +206,11 @@ class _SplitViewState extends State return Scaffold( backgroundColor: const Color.fromARGB(255, 9, 9, 9), floatingActionButtonLocation: FloatingActionButtonLocation.miniEndFloat, - floatingActionButton: !isMenuOpen - ? SDIconButton(icon: Icons.menu, onPressed: deckController.openMenu) - : null, + floatingActionButton: _buildFloatingAction( + context: context, + deckController: deckController, + isMenuOpen: isMenuOpen, + ), // Only show bottom bar on small layout (uncomment if needed): bottomNavigationBar: SizeTransition( diff --git a/packages/superdeck/lib/src/ui/panels/bottom_bar.dart b/packages/superdeck/lib/src/ui/panels/bottom_bar.dart index 9f275b9d..fbb83e97 100644 --- a/packages/superdeck/lib/src/ui/panels/bottom_bar.dart +++ b/packages/superdeck/lib/src/ui/panels/bottom_bar.dart @@ -26,6 +26,9 @@ class DeckBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { final deck = DeckController.of(context); + final pluginActions = deck.plugins + .expand((plugin) => plugin.buildActions(context)) + .toList(growable: false); return FlexBox( style: _bottomBarContainer, @@ -49,6 +52,7 @@ class DeckBottomBar extends StatelessWidget { icon: Icons.replay_circle_filled_rounded, onPressed: () => deck.generateThumbnails(context, force: true), ), + ...pluginActions, const Spacer(), SDIconButton(icon: Icons.arrow_back, onPressed: deck.previousSlide), SDIconButton(icon: Icons.arrow_forward, onPressed: deck.nextSlide), diff --git a/packages/superdeck/lib/src/ui/superdeck_app.dart b/packages/superdeck/lib/src/ui/superdeck_app.dart index 4eb50fe7..1a8e84b2 100644 --- a/packages/superdeck/lib/src/ui/superdeck_app.dart +++ b/packages/superdeck/lib/src/ui/superdeck_app.dart @@ -6,6 +6,7 @@ import 'package:superdeck/src/ui/tokens/colors.dart'; import '../deck/deck_controller_builder.dart'; import '../deck/deck_options.dart'; +import '../deck/superdeck_plugin.dart'; import '../utils/app_initialization.dart'; import 'app_shell.dart'; import 'theme.dart'; @@ -16,8 +17,13 @@ class SuperDeckApp extends StatelessWidget { final DeckOptions options; final DeckConfiguration? configuration; - static Future initialize() async { + static Future initialize({ + List plugins = const [], + }) async { await initializeDependencies(); + for (final plugin in plugins) { + await plugin.initialize(); + } } @override diff --git a/packages/superdeck/lib/superdeck.dart b/packages/superdeck/lib/superdeck.dart index b260c5fa..7c2f141a 100644 --- a/packages/superdeck/lib/superdeck.dart +++ b/packages/superdeck/lib/superdeck.dart @@ -24,6 +24,7 @@ export 'package:superdeck/src/deck/deck_options.dart'; export 'package:superdeck/src/deck/deck_controller_builder.dart'; export 'package:superdeck/src/deck/slide_configuration.dart'; export 'package:superdeck/src/deck/slide_template.dart'; +export 'package:superdeck/src/deck/superdeck_plugin.dart'; export 'package:superdeck/src/deck/template_exception.dart'; export 'package:superdeck/src/deck/widget_definition.dart'; From ea146d01f30e4f8d8c12a5e53126089f92008048 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 26 Feb 2026 09:48:19 -0500 Subject: [PATCH 6/7] fix: harden plugin init, lifecycle safety, and mutation error visibility - Wrap per-plugin initialization in try-catch to prevent one failing plugin from blocking others - Cancel existing stream subscription before creating new one in ChatViewModel._bindOnSubmit() to prevent leaks - Add mounted check in timer callback to prevent setState after dispose - Log mutation queue errors instead of silently dropping them --- packages/genui/lib/src/chat/chat_viewmodel.dart | 1 + .../presentation/view/creating_presentation_screen.dart | 8 +++++--- packages/genui/lib/src/tools/deck_tools_service.dart | 4 +++- packages/superdeck/lib/src/ui/superdeck_app.dart | 8 +++++++- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/genui/lib/src/chat/chat_viewmodel.dart b/packages/genui/lib/src/chat/chat_viewmodel.dart index d5c22ec2..5cb15f8e 100644 --- a/packages/genui/lib/src/chat/chat_viewmodel.dart +++ b/packages/genui/lib/src/chat/chat_viewmodel.dart @@ -155,6 +155,7 @@ class ChatViewModel implements Disposable { } void _bindOnSubmit(A2uiMessageProcessor processor) { + _onSubmitSubscription?.cancel(); _onSubmitSubscription = processor.onSubmit.listen((message) { final parsed = UserActionPayload.tryParse(message.text); if (parsed == null) { diff --git a/packages/genui/lib/src/presentation/view/creating_presentation_screen.dart b/packages/genui/lib/src/presentation/view/creating_presentation_screen.dart index 08ed13e7..601c89f6 100644 --- a/packages/genui/lib/src/presentation/view/creating_presentation_screen.dart +++ b/packages/genui/lib/src/presentation/view/creating_presentation_screen.dart @@ -63,9 +63,11 @@ class _CreatingPresentationScreenState void _startPhraseTimer() { _timer = Timer.periodic(const Duration(seconds: 3), (_) { - setState(() { - _currentPhraseIndex++; - }); + if (mounted) { + setState(() { + _currentPhraseIndex++; + }); + } }); } diff --git a/packages/genui/lib/src/tools/deck_tools_service.dart b/packages/genui/lib/src/tools/deck_tools_service.dart index c22225a4..88bc3d5f 100644 --- a/packages/genui/lib/src/tools/deck_tools_service.dart +++ b/packages/genui/lib/src/tools/deck_tools_service.dart @@ -307,7 +307,9 @@ class DeckToolsService { Future _runSerializedMutation(Future Function() operation) { final completer = Completer(); _mutationQueue = _mutationQueue - .catchError((Object ignoredError, StackTrace ignoredStackTrace) {}) + .catchError((Object error, StackTrace stackTrace) { + debugPrint('DeckToolsService: previous mutation failed: $error'); + }) .then((_) async { try { completer.complete(await operation()); diff --git a/packages/superdeck/lib/src/ui/superdeck_app.dart b/packages/superdeck/lib/src/ui/superdeck_app.dart index 1a8e84b2..242574a8 100644 --- a/packages/superdeck/lib/src/ui/superdeck_app.dart +++ b/packages/superdeck/lib/src/ui/superdeck_app.dart @@ -22,7 +22,13 @@ class SuperDeckApp extends StatelessWidget { }) async { await initializeDependencies(); for (final plugin in plugins) { - await plugin.initialize(); + try { + await plugin.initialize(); + } catch (e, stack) { + debugPrint( + 'SuperDeckPlugin "${plugin.name}" failed to initialize: $e\n$stack', + ); + } } } From 5fc2298e7580b9ef47d45cc29bc5a801d42bd622 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 3 Mar 2026 18:18:24 -0500 Subject: [PATCH 7/7] chore: set fvm channel to stable --- .fvmrc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.fvmrc b/.fvmrc index 8a35cfec..c300356c 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.38.9" -} + "flutter": "stable" +} \ No newline at end of file