Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,14 @@ fvm flutter test <path> # 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,
Expand Down
7 changes: 6 additions & 1 deletion packages/playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions packages/playground/lib/app/router.dart
Original file line number Diff line number Diff line change
@@ -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()],
);
}
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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.',
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<GenerateDeckCommand>(
create: (context) => GenerateDeckCommand(
documentStore: context.read<DeckDocumentStore>(),
),
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,
),
],
),
),
),
),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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});

Expand Down
8 changes: 8 additions & 0 deletions packages/playground/lib/features/ai/wizard/routes/routes.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:go_router/go_router.dart';

import '../presentation/wizard_page.dart';

/// Standalone Wizard playground route.
List<RouteBase> wizardRoutes() => [
GoRoute(path: '/', builder: (context, state) => const WizardPage()),
];
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -53,41 +52,7 @@ class _CustomizationSidebarState extends State<CustomizationSidebar> {
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),
),
],
),
Expand Down Expand Up @@ -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(
Expand All @@ -354,6 +319,3 @@ class _Toolbar extends StatelessWidget {
);
}
}

/// The two views the customization sidebar can show.
enum _SidebarTab { wizard, editor }
5 changes: 4 additions & 1 deletion packages/playground/lib/features/editor/routes/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouteBase> editorRoutes() => [
GoRoute(path: '/', builder: (context, state) => const EditorBootstrap()),
GoRoute(
path: '/editor',
builder: (context, state) => const EditorBootstrap(),
),
];
Original file line number Diff line number Diff line change
@@ -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);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand Down
Loading