diff --git a/.vscode/launch.json b/.vscode/launch.json index 58e3b0a1..b4c1f2e4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,17 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Superdeck Playground (macOS)", + "cwd": "packages/playground", + "request": "launch", + "type": "dart", + "program": "lib/main.dart", + "deviceId": "macos", + "toolArgs": [ + "--dart-define-from-file=../../.env" + ] + }, { "name": "Superdeck Demo", "cwd": "demo", diff --git a/AGENTS.md b/AGENTS.md index cfbdc0e2..60e90c46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,9 +73,14 @@ fvm flutter test # Run specific test file ### Running Apps & Live Debugging ```bash cd packages/playground -fvm flutter run -d macos -t lib/main.dart +fvm flutter run -d macos -t lib/main.dart --dart-define-from-file=../../.env ``` +The playground reads `GOOGLE_AI_API_KEY` from the ignored repository-root +`.env` file through Flutter's compile-time define-file option. Without that +flag, the Wizard intentionally shows a configuration error before accepting +input. + When running an app to reproduce or diagnose a UI/runtime issue, launch it with `fvm flutter run` and keep that process attached for the entire reproduction. Continue reading its output after each UI interaction so Dart exceptions, diff --git a/packages/playground/README.md b/packages/playground/README.md index 0a252696..be9215fe 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -36,9 +36,14 @@ lib/ ## Run ```bash -fvm flutter run -t lib/main.dart +fvm flutter run -d macos -t lib/main.dart --dart-define-from-file=../../.env ``` +Set `GOOGLE_AI_API_KEY` in the ignored repository-root `.env` file. Flutter +does not load `.env` automatically; the define-file flag injects it at build +time. The Wizard shows a configuration error immediately when the key is +missing. + ## Deck files On first launch, choose a parent directory for deck storage. The app creates a diff --git a/packages/playground/lib/app/router.dart b/packages/playground/lib/app/router.dart index 41804b49..91399845 100644 --- a/packages/playground/lib/app/router.dart +++ b/packages/playground/lib/app/router.dart @@ -1,15 +1,13 @@ import 'package:go_router/go_router.dart'; +import '../features/ai/wizard/routes/routes.dart'; import '../features/editor/routes/routes.dart'; import '../features/presentation/routes/routes.dart'; /// The app's [GoRouter], composing each feature's routes. -GoRouter createRouter() { +GoRouter createRouter({String initialLocation = '/'}) { return GoRouter( - initialLocation: '/', - routes: [ - ...editorRoutes(), - ...presentationRoutes(), - ], + initialLocation: initialLocation, + routes: [...wizardRoutes(), ...editorRoutes(), ...presentationRoutes()], ); } diff --git a/packages/playground/lib/features/ai/quick_agent/core/env_config.dart b/packages/playground/lib/features/ai/quick_agent/core/env_config.dart index 0869a635..ed9af127 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/env_config.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/env_config.dart @@ -1,7 +1,7 @@ /// Centralized access to environment configuration. /// -/// API keys are injected at build time with -/// `--dart-define=GOOGLE_AI_API_KEY=xxx`. +/// API keys are injected at build time with `--dart-define` or +/// `--dart-define-from-file`. abstract final class EnvConfig { /// API key from --dart-define (compile-time injection). static const _dartDefineKey = String.fromEnvironment('GOOGLE_AI_API_KEY'); @@ -14,7 +14,8 @@ abstract final class EnvConfig { throw StateError( 'GOOGLE_AI_API_KEY not configured. ' - 'Use --dart-define=GOOGLE_AI_API_KEY=xxx.', + 'Launch the playground with ' + '--dart-define-from-file=../../.env.', ); } diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart new file mode 100644 index 00000000..e41a4a62 --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:provider/provider.dart'; + +import '../../../editor/domain/stores/deck_document_store.dart'; +import '../../quick_agent/core/env_config.dart'; +import '../../quick_agent/domain/commands/generate_deck_command.dart'; +import 'wizard_view.dart'; + +/// Isolated host for exercising the conversational Wizard without the editor. +/// +/// Generated Markdown is kept in memory so opening this screen never requests a +/// deck-storage folder. The production integration can provide its own document +/// destination when the Wizard is embedded outside the playground. +class WizardPage extends StatelessWidget { + const WizardPage({this.isConfigured, super.key}); + + /// Overrides environment detection in tests and previews. + final bool? isConfigured; + + @override + Widget build(BuildContext context) { + final hasApiKey = isConfigured ?? EnvConfig.hasGeminiApiKey; + + if (!hasApiKey) { + return const _MissingApiKeyView(); + } + + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => DeckDocumentStore(markdown: '')), + ListenableProvider( + create: (context) => GenerateDeckCommand( + documentStore: context.read(), + ), + dispose: (_, command) => command.dispose(), + ), + ], + child: Scaffold( + backgroundColor: $background.resolve(context), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: const Padding( + padding: EdgeInsets.all(32), + child: WizardView(), + ), + ), + ), + ), + ), + ); + } +} + +class _MissingApiKeyView extends StatelessWidget { + const _MissingApiKeyView(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: $background.resolve(context), + body: const SafeArea( + child: Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 40), + SizedBox(height: 16), + Text( + 'Google AI API key is not configured', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + ), + SizedBox(height: 8), + Text( + 'Add GOOGLE_AI_API_KEY to the repository .env file, then ' + 'launch with --dart-define-from-file=../../.env.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart index 53157730..a3a2b24e 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart @@ -10,13 +10,12 @@ import '../chat/view/widgets/empty_state.dart'; import '../core/ai/services/ai_conversation_viewmodel.dart'; import '../core/viewmodel_scope.dart'; -/// Single-column conversational wizard, hosted inside the editor's customization -/// sidebar. +/// Single-column conversational Wizard hosted by its standalone playground page. /// /// Owns an [AiConversationViewModel] (via [ViewModelScope]) driving the GenUI /// catalog: the model asks one question at a time, each rendered as a catalog -/// surface. The terminal summary card routes generation through -/// `GenerateDeckCommand`, which loads the resulting markdown into the editor. +/// surface. The host supplies the `GenerateDeckCommand` used by the terminal +/// summary card. class WizardView extends StatelessWidget { const WizardView({super.key}); diff --git a/packages/playground/lib/features/ai/wizard/routes/routes.dart b/packages/playground/lib/features/ai/wizard/routes/routes.dart new file mode 100644 index 00000000..d946be08 --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/routes/routes.dart @@ -0,0 +1,8 @@ +import 'package:go_router/go_router.dart'; + +import '../presentation/wizard_page.dart'; + +/// Standalone Wizard playground route. +List wizardRoutes() => [ + GoRoute(path: '/', builder: (context, state) => const WizardPage()), +]; diff --git a/packages/playground/lib/features/editor/presentation/widgets/customization_sidebar.dart b/packages/playground/lib/features/editor/presentation/widgets/customization_sidebar.dart index 795fe682..26311cb9 100644 --- a/packages/playground/lib/features/editor/presentation/widgets/customization_sidebar.dart +++ b/packages/playground/lib/features/editor/presentation/widgets/customization_sidebar.dart @@ -8,7 +8,6 @@ import 'package:remix/remix.dart'; import '../../../../core/domain/stores/deck_customization_store.dart'; import '../../../ai/quick_agent/domain/commands/generate_deck_command.dart'; import '../../../ai/quick_agent/presentation/widgets/agent_generate_panel.dart'; -import '../../../ai/wizard/presentation/wizard_view.dart'; import '../../domain/stores/editor_store.dart'; import 'color_control.dart'; import 'committed_text_field.dart'; @@ -53,41 +52,7 @@ class _CustomizationSidebarState extends State { child: const HeroDivider(), ), Expanded( - child: HeroTabs( - initialId: _SidebarTab.wizard.name, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - HeroTabBar( - children: [ - HeroTab(label: 'Wizard', tabId: _SidebarTab.wizard.name), - HeroTab(label: 'Editor', tabId: _SidebarTab.editor.name), - ], - ), - const SizedBox(height: 16), - Expanded( - child: Stack( - children: [ - Positioned.fill( - child: HeroTabPanel( - tabId: _SidebarTab.wizard.name, - child: const WizardView(), - ), - ), - Positioned.fill( - child: HeroTabPanel( - tabId: _SidebarTab.editor.name, - child: _EditorTab( - accordionController: _accordionController, - ), - ), - ), - ], - ), - ), - ], - ), - ), + child: _EditorTab(accordionController: _accordionController), ), ], ), @@ -326,8 +291,8 @@ class _Toolbar extends StatelessWidget { style: FlexBoxStyler().spacing(8), children: [ const Spacer(), - // TODO(ai): re-introduce the remaining AI entry points (deck-edit, - // wizard) once those features are ported. + // TODO(ai): re-introduce the remaining deck-edit entry points once + // those features are ported. SizedBox( width: 48, child: HeroIconButton( @@ -354,6 +319,3 @@ class _Toolbar extends StatelessWidget { ); } } - -/// The two views the customization sidebar can show. -enum _SidebarTab { wizard, editor } diff --git a/packages/playground/lib/features/editor/routes/routes.dart b/packages/playground/lib/features/editor/routes/routes.dart index 9829acc6..3fde271b 100644 --- a/packages/playground/lib/features/editor/routes/routes.dart +++ b/packages/playground/lib/features/editor/routes/routes.dart @@ -12,5 +12,8 @@ import '../presentation/pages/editor_bootstrap.dart'; /// auto-save), `EditorStore` (nav state), `TextEditorController` (the /// super_editor adapter), and `GenerateDeckCommand` (AI generation). List editorRoutes() => [ - GoRoute(path: '/', builder: (context, state) => const EditorBootstrap()), + GoRoute( + path: '/editor', + builder: (context, state) => const EditorBootstrap(), + ), ]; diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart new file mode 100644 index 00000000..e6d0fc6e --- /dev/null +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:playground/app/router.dart'; +import 'package:playground/features/ai/wizard/presentation/wizard_page.dart'; +import 'package:playground/features/ai/wizard/presentation/wizard_view.dart'; +import 'package:playground/features/editor/presentation/pages/editor_page.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); + + testWidgets('default route reports a missing API key immediately', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp.router( + routerConfig: createRouter(), + builder: (context, child) => + HeroTheme(data: HeroThemeData.light(), child: child!), + ), + ); + await tester.pump(); + + expect(find.byType(WizardPage), findsOneWidget); + expect(find.byType(WizardView), findsNothing); + expect(find.byType(EditorPage), findsNothing); + expect(find.text('Google AI API key is not configured'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + + testWidgets('configured page opens the isolated Wizard', (tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + HeroTheme(data: HeroThemeData.light(), child: child!), + home: const WizardPage(isConfigured: true), + ), + ); + await tester.pump(); + + expect(find.byType(WizardView), findsOneWidget); + expect(find.byType(EditorPage), findsNothing); + expect(find.text('Startup pitch deck'), findsOneWidget); + }); +} diff --git a/packages/playground/test/features/editor/editor_page_test.dart b/packages/playground/test/features/editor/editor_page_test.dart index 1069eed1..adab2988 100644 --- a/packages/playground/test/features/editor/editor_page_test.dart +++ b/packages/playground/test/features/editor/editor_page_test.dart @@ -27,7 +27,7 @@ void main() { // In-memory repository so the file-backed bootstrap resolves without // touching disk or spinning a real file watcher. return MaterialApp.router( - routerConfig: createRouter(), + routerConfig: createRouter(initialLocation: '/editor'), builder: (context, child) => _Theme( child: AppProviders( deckFileRepository: deckFileRepository ?? FakeDeckFileRepository(), @@ -47,12 +47,7 @@ void main() { expect(find.byType(EditorPage), findsOneWidget); expect(find.byType(PreviewSidebar), findsOneWidget); expect(find.byType(CustomizationSidebar), findsOneWidget); - - // The customization sidebar defaults to the Wizard tab; switch to the - // Editor tab so its 'Background' section label renders. - await tester.tap(find.text('Editor')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Wizard'), findsNothing); expect(find.text('Background'), findsOneWidget); // Unmount so the editor's controllers/coordinator dispose and cancel their