diff --git a/pkgs/dartpad_ui/.firebaserc b/pkgs/dartpad_ui/.firebaserc index f3578d6fd..35b59a2e4 100644 --- a/pkgs/dartpad_ui/.firebaserc +++ b/pkgs/dartpad_ui/.firebaserc @@ -1,15 +1,10 @@ { - "projects": { - "default": "dart-pad" - }, + "projects": {}, "targets": { - "dart-pad": { + "genui-dartpad": { "hosting": { - "dartpad": [ - "dart-pad-a3c64" - ], - "preview": [ - "sketch-pad-1cebb" + "genui-dartpad": [ + "genui-dartpad" ] } } diff --git a/pkgs/dartpad_ui/assets/gemini_sparkle_192.png b/pkgs/dartpad_ui/assets/gemini_sparkle_192.png index 07e81893b..2ad2dd9b2 100644 Binary files a/pkgs/dartpad_ui/assets/gemini_sparkle_192.png and b/pkgs/dartpad_ui/assets/gemini_sparkle_192.png differ diff --git a/pkgs/dartpad_ui/assets/prompt_suggestion_icon_darkmode.png b/pkgs/dartpad_ui/assets/prompt_suggestion_icon_darkmode.png new file mode 100644 index 000000000..95b670c5f Binary files /dev/null and b/pkgs/dartpad_ui/assets/prompt_suggestion_icon_darkmode.png differ diff --git a/pkgs/dartpad_ui/assets/prompt_suggestion_icon_lightmode.png b/pkgs/dartpad_ui/assets/prompt_suggestion_icon_lightmode.png new file mode 100644 index 000000000..9eb10ff13 Binary files /dev/null and b/pkgs/dartpad_ui/assets/prompt_suggestion_icon_lightmode.png differ diff --git a/pkgs/dartpad_ui/firebase.json b/pkgs/dartpad_ui/firebase.json index 19127e00f..e481b97ed 100644 --- a/pkgs/dartpad_ui/firebase.json +++ b/pkgs/dartpad_ui/firebase.json @@ -1,100 +1,7 @@ { "hosting": [ { - "target": "dartpad", - "public": "build/web", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**" - ], - "redirects": [ - { - "source": "/dart", - "destination": "/?sample=dart", - "type": 301 - }, - { - "source": "/flutter", - "destination": "/?sample=flutter", - "type": 301 - }, - { - "source": "/embed-dart?(.html)", - "destination": "/?embed=true", - "type": 301 - }, - { - "source": "/embed-flutter?(.html)", - "destination": "/?embed=true", - "type": 301 - }, - { - "source": "/embed-flutter_showcase?(.html)", - "destination": "/?embed=true", - "type": 301 - }, - { - "source": "/embed-html?(.html)", - "destination": "/?embed=true", - "type": 301 - }, - { - "source": "/embed-inline?(.html)", - "destination": "/?embed=true", - "type": 301 - }, - { - "source": "/workshops?(.html)", - "destination": "https://github.com/dart-lang/dart-pad/wiki/Workshop-authoring-guide", - "type": 301 - } - ], - "rewrites": [ - { - "source": "**", - "destination": "/index.html" - } - ], - "headers": [ - { - "source": "/frame/assets/**", - "headers": [ - { - "key": "Access-Control-Allow-Origin", - "value": "*" - } - ] - }, - { - "source": "**", - "headers": [ - { - "key": "Cross-Origin-Opener-Policy", - "value": "same-origin" - }, - { - "key": "Cross-Origin-Embedder-Policy", - "value": "credentialless" - }, - { - "key": "Cross-Origin-Resource-Policy", - "value": "cross-origin" - }, - { - "key": "X-Content-Type-Options", - "value": "nosniff" - }, - { - "key": "Referrer-Policy", - "value": "strict-origin-when-cross-origin" - } - ] - } - ] - }, - { - "target": "preview", + "target": "genui-dartpad", "public": "build/web", "ignore": [ "firebase.json", @@ -187,4 +94,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/pkgs/dartpad_ui/lib/editor/editor.dart b/pkgs/dartpad_ui/lib/editor/editor.dart index 071bed7d1..6f037b606 100644 --- a/pkgs/dartpad_ui/lib/editor/editor.dart +++ b/pkgs/dartpad_ui/lib/editor/editor.dart @@ -622,19 +622,16 @@ class _ReadOnlyCodeWidgetState extends State { Widget build(BuildContext context) { return Focus( autofocus: true, - child: SizedBox( - height: 500, - child: TextField( - controller: _textController, - readOnly: true, - maxLines: null, - style: GoogleFonts.robotoMono( - fontSize: 14, - fontWeight: FontWeight.w400, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - decoration: const InputDecoration(border: InputBorder.none), + child: TextField( + controller: _textController, + readOnly: true, + maxLines: null, + style: GoogleFonts.robotoMono( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).textTheme.bodyMedium?.color, ), + decoration: const InputDecoration(border: InputBorder.none), ), ); } @@ -656,27 +653,24 @@ class ReadOnlyDiffWidget extends StatelessWidget { Widget build(BuildContext context) { return Focus( autofocus: true, - child: SizedBox( - height: 500, - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: PrettyDiffText( - oldText: existingSource, - newText: newSource, - defaultTextStyle: GoogleFonts.robotoMono( - fontSize: 14, - fontWeight: FontWeight.w400, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - addedTextStyle: const TextStyle( - color: Colors.black, - backgroundColor: Color.fromARGB(255, 201, 255, 201), - ), - deletedTextStyle: const TextStyle( - color: Colors.black, - backgroundColor: Color.fromARGB(255, 249, 199, 199), - decoration: TextDecoration.lineThrough, - ), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: PrettyDiffText( + oldText: existingSource, + newText: newSource, + defaultTextStyle: GoogleFonts.robotoMono( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + addedTextStyle: const TextStyle( + color: Colors.black, + backgroundColor: Color.fromARGB(255, 201, 255, 201), + ), + deletedTextStyle: const TextStyle( + color: Colors.black, + backgroundColor: Color.fromARGB(255, 249, 199, 199), + decoration: TextDecoration.lineThrough, ), ), ), diff --git a/pkgs/dartpad_ui/lib/enable_gen_ai.dart b/pkgs/dartpad_ui/lib/enable_gen_ai.dart index 7998e75a6..e08f1d645 100644 --- a/pkgs/dartpad_ui/lib/enable_gen_ai.dart +++ b/pkgs/dartpad_ui/lib/enable_gen_ai.dart @@ -3,7 +3,7 @@ // BSD-style license that can be found in the LICENSE file. // Turn on or off gen-ai features in the client. -const bool genAiEnabled = false; +const bool genAiEnabled = true; /* diff --git a/pkgs/dartpad_ui/lib/main.dart b/pkgs/dartpad_ui/lib/main.dart index beca94860..316c2f2b2 100644 --- a/pkgs/dartpad_ui/lib/main.dart +++ b/pkgs/dartpad_ui/lib/main.dart @@ -130,7 +130,7 @@ class _DartPadAppState extends State { final channelParam = state.uri.queryParameters['channel']; final embedMode = state.uri.queryParameters['embed'] == 'true'; final runOnLoad = state.uri.queryParameters['run'] == 'true'; - final useGenui = state.uri.queryParameters['genui'] == 'true'; + final useGenui = state.uri.queryParameters['genui'] != 'false'; return DartPadMainPage( initialChannel: channelParam, @@ -169,6 +169,7 @@ class _DartPadAppState extends State { minimumSize: const Size.fromHeight(56), ), ), + hintColor: Colors.black.withAlpha(128), ), darkTheme: ThemeData( useMaterial3: true, @@ -200,6 +201,7 @@ class _DartPadAppState extends State { minimumSize: const Size.fromHeight(56), ), ), + hintColor: Colors.white.withAlpha(128), ), ); } @@ -557,22 +559,31 @@ class LoadingOverlay extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return ValueListenableBuilder( - valueListenable: appModel.compilingState, - builder: (_, compilingState, __) { - final color = theme.colorScheme.surface; - final compiling = compilingState == CompilingState.restarting; - - // If reloading, show a progress spinner. If restarting, also display a - // semi-opaque overlay. - return AnimatedContainer( - color: color.withValues(alpha: compiling ? 0.8 : 0), - duration: animationDelay, - curve: animationCurve, - child: - compiling - ? const GoldenRatioCenter(child: CircularProgressIndicator()) - : const SizedBox(width: 1), + return ValueListenableBuilder( + valueListenable: appModel.genAiManager.state, + builder: (BuildContext context, GenAiState genAiState, Widget? child) { + return ValueListenableBuilder( + valueListenable: appModel.compilingState, + builder: (_, compilingState, __) { + final color = theme.colorScheme.surface; + final loading = + compilingState == CompilingState.restarting || + genAiState == GenAiState.generating; + + // If reloading, show a progress spinner. If restarting, also display a + // semi-opaque overlay. + return AnimatedContainer( + color: color.withValues(alpha: loading ? 0.8 : 0), + duration: animationDelay, + curve: animationCurve, + child: + loading + ? const GoldenRatioCenter( + child: CircularProgressIndicator(), + ) + : const SizedBox(width: 1), + ); + }, ); }, ); @@ -600,6 +611,19 @@ class DartPadAppBar extends StatelessWidget implements PreferredSizeWidget { return LayoutBuilder( builder: (context, constraints) { final wideLayout = constraints.maxWidth > smallScreenWidth; + final resolvedGeminiMenu = + genAiEnabled + ? [ + const SizedBox(width: denseSpacing), + GeminiMenu( + generateDartCode: + () => _generateNewCode(context, AppType.dart), + generateFlutterCode: + () => _generateNewCode(context, AppType.flutter), + hideLabel: false, // !wideLayout, + ), + ] + : []; return AppBar( backgroundColor: theme.colorScheme.surface, @@ -620,23 +644,16 @@ class DartPadAppBar extends StatelessWidget implements PreferredSizeWidget { const SizedBox(width: defaultSpacing * 4), NewSnippetWidget(appServices: appServices), const SizedBox(width: denseSpacing), + ...resolvedGeminiMenu, const ListSamplesWidget(), ] else ...[ const SizedBox(width: defaultSpacing), NewSnippetWidget(appServices: appServices, hideLabel: true), const SizedBox(width: defaultSpacing), + ...resolvedGeminiMenu, const ListSamplesWidget(hideLabel: true), ], - if (genAiEnabled) ...[ - const SizedBox(width: denseSpacing), - GeminiMenu( - generateNewCode: () => _generateNewCode(context), - updateExistingCode: () => _updateExistingCode(context), - hideLabel: !wideLayout, - ), - ], - const SizedBox(width: defaultSpacing), // Hide the snippet title when the screen width is too small. if (wideLayout) @@ -682,17 +699,20 @@ class DartPadAppBar extends StatelessWidget implements PreferredSizeWidget { url_launcher.launchUrl(Uri.parse(response.idxUrl)); } - Future _generateNewCode(BuildContext context) async { + Future _generateNewCode(BuildContext context, AppType appType) async { final appModel = Provider.of(context, listen: false); final appServices = Provider.of(context, listen: false); final lastPrompt = LocalStorage.instance.getLastCreateCodePrompt(); + final resolvedDialogTitle = + 'New ${appType == AppType.dart ? 'Dart' : 'Flutter'} Project via Gemini'; final promptResponse = await showDialog( context: context, builder: (context) => PromptDialog( - title: 'Generate New Code', - hint: 'Describe the code you want to generate', - initialAppType: LocalStorage.instance.getLastCreateCodeAppType(), + title: resolvedDialogTitle, + hint: + 'Describe what kind of code, features, and/or UI you want Gemini to create.', + initialAppType: appType, flutterPromptButtons: { 'to-do app': 'Generate a Flutter to-do app with add, remove, and complete task functionality', @@ -710,128 +730,49 @@ class DartPadAppBar extends StatelessWidget implements PreferredSizeWidget { 'Generate a Dart program that prints the factorial of 5', if (lastPrompt != null) 'your last prompt': lastPrompt, }, + promptTextController: appModel.genAiManager.newCodePromptController, + imageAttachmentsManager: + appModel.genAiManager.newCodeImageAttachmentsManager, ), ); if (!context.mounted || promptResponse == null || - promptResponse.prompt.isEmpty) { + promptResponse.promptTextController.text.isEmpty) { return; } LocalStorage.instance.saveLastCreateCodeAppType(promptResponse.appType); - LocalStorage.instance.saveLastCreateCodePrompt(promptResponse.prompt); + LocalStorage.instance.saveLastCreateCodePrompt( + promptResponse.promptTextController.text, + ); + + appModel.genAiManager.preGenAiSourceCode.value = + appModel.sourceCodeController.text; + appModel.genAiManager.enterGeneratingNew(); try { - final Stream stream; if (widget.useGenui) { - stream = appServices.generateUi( - GenerateUiRequest(prompt: promptResponse.prompt), + appModel.genAiManager.startStream( + appServices.generateUi( + GenerateUiRequest(prompt: promptResponse.promptTextController.text), + ), ); } else { - stream = appServices.generateCode( - GenerateCodeRequest( - appType: promptResponse.appType, - prompt: promptResponse.prompt, - attachments: promptResponse.attachments, + appModel.genAiManager.startStream( + appServices.generateCode( + GenerateCodeRequest( + appType: appType, + prompt: promptResponse.promptTextController.text, + attachments: promptResponse.imageAttachmentsManager.attachments, + ), ), ); } - - final generateResponse = await showDialog( - context: context, - builder: - (context) => GeneratingCodeDialog( - stream: stream, - title: 'Generating New Code', - ), - ); - - if (!context.mounted || - generateResponse == null || - generateResponse.isEmpty) { - return; - } - - appModel.sourceCodeController.textNoScroll = generateResponse; - appServices.editorService!.focus(); - appServices.performCompileAndReloadOrRun(); } catch (error) { appModel.editorStatus.showToast('Error generating code'); appModel.appendError('Generating code issue: $error'); - } - } - - Future _updateExistingCode(BuildContext context) async { - final appModel = Provider.of(context, listen: false); - final appServices = Provider.of(context, listen: false); - final lastPrompt = LocalStorage.instance.getLastUpdateCodePrompt(); - final promptResponse = await showDialog( - context: context, - builder: - (context) => PromptDialog( - title: 'Update Existing Code', - hint: 'Describe the updates you\'d like to make to the code', - initialAppType: appModel.appType, - flutterPromptButtons: { - 'pretty': - 'Make the app pretty by improving the visual design - add proper spacing, consistent typography, a pleasing color scheme, and ensure the overall layout follows Material Design principles', - 'fancy': - 'Make the app fancy by adding rounded corners where appropriate, subtle shadows and animations for interactivity; make tasteful use of gradients and images', - 'emoji': - 'Make the app use emojis by adding appropriate emoji icons and text', - if (lastPrompt != null) 'your last prompt': lastPrompt, - }, - dartPromptButtons: { - 'pretty': 'Make the app pretty', - 'fancy': 'Make the app fancy', - 'emoji': 'Make the app use emojis', - if (lastPrompt != null) 'your last prompt': lastPrompt, - }, - ), - ); - - if (!context.mounted || - promptResponse == null || - promptResponse.prompt.isEmpty) { - return; - } - - LocalStorage.instance.saveLastUpdateCodePrompt(promptResponse.prompt); - - try { - final source = appModel.sourceCodeController.text; - final stream = appServices.updateCode( - UpdateCodeRequest( - appType: promptResponse.appType, - source: source, - prompt: promptResponse.prompt, - attachments: promptResponse.attachments, - ), - ); - - final generateResponse = await showDialog( - context: context, - builder: - (context) => GeneratingCodeDialog( - stream: stream, - title: 'Updating Existing Code', - existingSource: source, - ), - ); - - if (!context.mounted || - generateResponse == null || - generateResponse.isEmpty) { - return; - } - - appModel.sourceCodeController.textNoScroll = generateResponse; - appServices.editorService!.focus(); - appServices.performCompileAndReloadOrRun(); - } catch (error) { - appModel.editorStatus.showToast('Error updating code'); - appModel.appendError('Updating code issue: $error'); + appModel.genAiManager.enterStandby(); } } } @@ -852,108 +793,206 @@ class EditorWithButtons extends StatelessWidget { final VoidCallback onCompileAndRun; final VoidCallback onCompileAndReload; + Future _requestGeminiCodeUpdate( + BuildContext context, + PromptDialogResponse promptInfo, + ) async { + appModel.genAiManager.preGenAiSourceCode.value = + appModel.sourceCodeController.text; + appModel.genAiManager.enterGeneratingEdit(); + try { + final source = appModel.sourceCodeController.text; + appModel.genAiManager.startStream( + appServices.updateCode( + UpdateCodeRequest( + appType: promptInfo.appType, + source: source, + prompt: promptInfo.promptTextController.text, + attachments: promptInfo.imageAttachmentsManager.attachments, + ), + ), + ); + } catch (error) { + appModel.editorStatus.showToast('Error updating code'); + appModel.appendError('Updating code issue: $error'); + appModel.genAiManager.enterStandby(); + } + } + + void onAcceptUpdateCode() { + assert(appModel.genAiManager.streamIsDone.value); + appModel.genAiManager.resetInputs(); + appModel.genAiManager.enterStandby(); + } + + void onEditUpdateCodePrompt() { + appModel.sourceCodeController.textNoScroll = + appModel.genAiManager.preGenAiSourceCode.value; + appServices.performCompileAndRun(); + appModel.genAiManager.enterStandby(); + } + + void onCancelUpdateCode() { + appModel.genAiManager.resetInputs(); + appModel.genAiManager.enterStandby(); + // TODO(alsobrian) 3/11/25: Clean up stream, buffer etc.? + } + + void onRejectSuggestedCode() { + appModel.genAiManager.resetInputs(); + appModel.genAiManager.enterStandby(); + appModel.sourceCodeController.textNoScroll = + appModel.genAiManager.preGenAiSourceCode.value; + appServices.performCompileAndRun(); + } + @override Widget build(BuildContext context) { - return Column( - children: [ - Expanded( - child: SectionWidget( - child: Stack( - children: [ - EditorWidget(appModel: appModel, appServices: appServices), - Padding( - padding: const EdgeInsets.symmetric( - vertical: denseSpacing, - horizontal: defaultSpacing, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - // We use explicit directionality here in order to have the - // format and run buttons on the right hand side of the - // editing area. - textDirection: TextDirection.ltr, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Dartdoc help button - ValueListenableBuilder( - valueListenable: appModel.docHelpBusy, - builder: (_, bool value, __) { - return PointerInterceptor( - child: MiniIconButton( - icon: const Icon(Icons.help_outline), - tooltip: 'Show docs', - // small: true, - onPressed: - value ? null : () => _showDocs(context), - ), - ); - }, + return ValueListenableBuilder( + valueListenable: appModel.genAiManager.currentState, + builder: (BuildContext context, GenAiState genAiState, Widget? child) { + return Column( + children: [ + Expanded( + child: SectionWidget( + child: Stack( + children: [ + if (genAiState == GenAiState.standby) ...[ + EditorWidget( + appModel: appModel, + appServices: appServices, ), - const SizedBox(width: denseSpacing), - // Format action - ValueListenableBuilder( - valueListenable: appModel.formattingBusy, - builder: (_, bool value, __) { - return PointerInterceptor( - child: MiniIconButton( - icon: const Icon(Icons.format_align_left), - tooltip: 'Format', - small: true, - onPressed: value ? null : onFormat, - ), - ); - }, + ], + Padding( + padding: const EdgeInsets.symmetric( + vertical: denseSpacing, + horizontal: defaultSpacing, ), - const SizedBox(width: defaultSpacing), - // Run action - ValueListenableBuilder( - valueListenable: appModel.showReload, - builder: (_, bool value, __) { - if (!value) return const SizedBox(); - return ValueListenableBuilder( - valueListenable: appModel.canReload, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + // We use explicit directionality here in order to have the + // format and run buttons on the right hand side of the + // editing area. + textDirection: TextDirection.ltr, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Dartdoc help button + ValueListenableBuilder( + valueListenable: appModel.docHelpBusy, + builder: (_, bool value, __) { + return PointerInterceptor( + child: MiniIconButton( + icon: const Icon(Icons.help_outline), + tooltip: 'Show docs', + // small: true, + onPressed: + value ? null : () => _showDocs(context), + ), + ); + }, + ), + const SizedBox(width: denseSpacing), + // Format action + ValueListenableBuilder( + valueListenable: appModel.formattingBusy, builder: (_, bool value, __) { return PointerInterceptor( - child: ReloadButton( - onPressed: value ? onCompileAndReload : null, + child: MiniIconButton( + icon: const Icon(Icons.format_align_left), + tooltip: 'Format', + small: true, + onPressed: value ? null : onFormat, ), ); }, - ); - }, + ), + const SizedBox(width: defaultSpacing), + // Run action + ValueListenableBuilder( + valueListenable: appModel.showReload, + builder: (_, bool value, __) { + if (!value) return const SizedBox(); + return ValueListenableBuilder( + valueListenable: appModel.canReload, + builder: (_, bool value, __) { + return PointerInterceptor( + child: ReloadButton( + onPressed: + value ? onCompileAndReload : null, + ), + ); + }, + ); + }, + ), + const SizedBox(width: defaultSpacing), + // Run action + ValueListenableBuilder( + valueListenable: appModel.compilingState, + builder: (_, compiling, __) { + return PointerInterceptor( + child: RunButton( + onPressed: + compiling.busy ? null : onCompileAndRun, + ), + ); + }, + ), + ], ), - const SizedBox(width: defaultSpacing), - // Run action - ValueListenableBuilder( - valueListenable: appModel.compilingState, - builder: (_, compiling, __) { - return PointerInterceptor( - child: RunButton( - onPressed: - compiling.busy ? null : onCompileAndRun, - ), - ); - }, + ), + Container( + alignment: Alignment.bottomRight, + padding: const EdgeInsets.all(denseSpacing), + child: StatusWidget(status: appModel.editorStatus), + ), + + if (genAiState == GenAiState.standby) ...[ + SizedBox(width: 0, height: 0), + ] else ...[ + Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + ), + alignment: Alignment.topLeft, + padding: const EdgeInsets.all(denseSpacing), + child: GeneratingCodePanel( + appModel: appModel, + appServices: appServices, + ), ), ], - ), - ), - Container( - alignment: Alignment.bottomRight, - padding: const EdgeInsets.all(denseSpacing), - child: StatusWidget(status: appModel.editorStatus), + ], ), - ], + ), ), - ), - ), - ValueListenableBuilder>( - valueListenable: appModel.analysisIssues, - builder: (context, issues, _) { - return ProblemsTableWidget(problems: issues); - }, - ), - ], + GeminiCodeEditTool( + appModel: appModel, + enabled: appModel.genAiManager.state.value == GenAiState.standby, + onUpdateCode: _requestGeminiCodeUpdate, + onAcceptUpdateCode: onAcceptUpdateCode, + onCancelUpdateCode: onCancelUpdateCode, + onEditUpdateCodePrompt: onEditUpdateCodePrompt, + onRejectSuggestedCode: onRejectSuggestedCode, + ), + ValueListenableBuilder>( + valueListenable: appModel.analysisIssues, + builder: (context, issues, _) { + return ValueListenableBuilder( + valueListenable: appModel.genAiManager.state, + builder: (_, genAiState, __) { + if (genAiState != GenAiState.awaitingAcceptReject && + genAiState != GenAiState.generating) { + return ProblemsTableWidget(problems: issues); + } + return SizedBox(width: 0, height: 0); + }, + ); + }, + ), + ], + ); + }, ); } @@ -1155,7 +1194,7 @@ class NewSnippetWidget extends StatelessWidget { builder: (_, MenuController controller, _) => CollapsibleIconToggleButton( icon: const Icon(Icons.add_circle), - label: const Text('New'), + label: const Text('Create New'), tooltip: 'Create a new snippet', hideLabel: hideLabel, onToggle: controller.toggleMenuState, @@ -1350,15 +1389,15 @@ class ContinueInMenu extends StatelessWidget { class GeminiMenu extends StatelessWidget { const GeminiMenu({ - required this.generateNewCode, - required this.updateExistingCode, required this.hideLabel, + required this.generateDartCode, + required this.generateFlutterCode, super.key, }); final bool hideLabel; - final VoidCallback generateNewCode; - final VoidCallback updateExistingCode; + final VoidCallback generateDartCode; + final VoidCallback generateFlutterCode; @override Widget build(BuildContext context) { @@ -1372,8 +1411,8 @@ class GeminiMenu extends StatelessWidget { builder: (_, MenuController controller, _) => CollapsibleIconToggleButton( icon: image, - label: const Text('Gemini'), - tooltip: 'Generate code with Gemini', + label: const Text('Gemini New'), + tooltip: 'Create a new snippet with code generated by Gemini', hideLabel: hideLabel, onToggle: controller.toggleMenuState, ), @@ -1381,18 +1420,18 @@ class GeminiMenu extends StatelessWidget { ...[ MenuItemButton( leadingIcon: image, - onPressed: generateNewCode, + onPressed: generateDartCode, child: const Padding( padding: EdgeInsets.only(right: 32), - child: Text('Generate Code'), + child: Text('Dart Snippet'), ), ), MenuItemButton( leadingIcon: image, - onPressed: updateExistingCode, + onPressed: generateFlutterCode, child: const Padding( padding: EdgeInsets.only(right: 32), - child: Text('Update Code'), + child: Text('Flutter Snippet'), ), ), ].map((widget) => PointerInterceptor(child: widget)), diff --git a/pkgs/dartpad_ui/lib/model.dart b/pkgs/dartpad_ui/lib/model.dart index 3d9ed9360..4b1764bd0 100644 --- a/pkgs/dartpad_ui/lib/model.dart +++ b/pkgs/dartpad_ui/lib/model.dart @@ -14,6 +14,7 @@ import 'flutter_samples.dart'; import 'gists.dart'; import 'samples.g.dart'; import 'utils.dart'; +import 'widgets.dart'; // TODO: make sure that calls have built-in timeouts (10s, 60s, ...) @@ -90,6 +91,8 @@ class AppModel { final ValueNotifier useNewDDC = ValueNotifier(false); final ValueNotifier currentDeltaDill = ValueNotifier(null); + final GenAiManager genAiManager = GenAiManager(); + AppModel() { consoleNotifier.addListener(_recalcLayout); void updateCanReload() => @@ -662,13 +665,13 @@ enum CompilingState { class PromptDialogResponse { const PromptDialogResponse({ required this.appType, - required this.prompt, - this.attachments = const [], + required this.promptTextController, + required this.imageAttachmentsManager, }); final AppType appType; - final String prompt; - final List attachments; + final TextEditingController promptTextController; + final ImageAttachmentsManager imageAttachmentsManager; } class ConsoleNotifier extends ChangeNotifier { @@ -700,3 +703,87 @@ class ConsoleNotifier extends ChangeNotifier { bool get hasError => _error.isNotEmpty; String get valueToDisplay => hasError ? _error : _output; } + +enum GenAiState { standby, generating, awaitingAcceptReject } + +class GenAiManager { + final ValueNotifier state = ValueNotifier(GenAiState.standby); + final ValueNotifier> stream = ValueNotifier( + Stream.empty(), + ); + final ValueNotifier streamBuffer = ValueNotifier( + StringBuffer(), + ); + final ValueNotifier streamIsDone = ValueNotifier(true); + TextEditingController? activePromptTextController; + ImageAttachmentsManager? activeImageAttachmentsManager; + final TextEditingController newCodePromptController = TextEditingController(); + final TextEditingController codeEditPromptController = + TextEditingController(); + final ImageAttachmentsManager newCodeImageAttachmentsManager = + ImageAttachmentsManager(); + final ImageAttachmentsManager codeEditImageAttachmentsManager = + ImageAttachmentsManager(); + final ValueNotifier isGeneratingNewProject = ValueNotifier(true); + final ValueNotifier preGenAiSourceCode = ValueNotifier(''); + + GenAiManager(); + + ValueNotifier get currentState { + return state; + } + + void enterGeneratingNew() { + state.value = GenAiState.generating; + isGeneratingNewProject.value = true; + activePromptTextController = newCodePromptController; + activeImageAttachmentsManager = newCodeImageAttachmentsManager; + } + + void enterGeneratingEdit() { + state.value = GenAiState.generating; + isGeneratingNewProject.value = false; + activePromptTextController = codeEditPromptController; + activeImageAttachmentsManager = codeEditImageAttachmentsManager; + } + + void enterStandby() { + state.value = GenAiState.standby; + streamIsDone.value = true; + streamBuffer.value.clear(); + } + + void enterAwaitingAcceptReject() { + state.value = GenAiState.awaitingAcceptReject; + } + + void startStream(Stream newStream, [VoidCallback? onDone]) { + stream.value = newStream; + } + + void setStreamIsDone(bool which) { + streamIsDone.value = which; + } + + void resetInputs() { + activePromptTextController?.text = ''; + activeImageAttachmentsManager?.attachments.clear(); + } + + String generatedCode() { + return streamBuffer.value.toString(); + } + + void setEditPromptText(String newPrompt) { + codeEditPromptController.text = newPrompt; + } + + void writeToStreamBuffer(String text) { + streamBuffer.value.write(text); + } + + void setStreamBufferValue(String text) { + streamBuffer.value.clear(); + streamBuffer.value.write(text); + } +} diff --git a/pkgs/dartpad_ui/lib/suggest_fix.dart b/pkgs/dartpad_ui/lib/suggest_fix.dart index 975f21340..35aadb984 100644 --- a/pkgs/dartpad_ui/lib/suggest_fix.dart +++ b/pkgs/dartpad_ui/lib/suggest_fix.dart @@ -7,8 +7,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'model.dart'; -import 'utils.dart'; -import 'widgets.dart'; Future suggestFix({ required BuildContext context, @@ -23,39 +21,45 @@ Future suggestFix({ final appServices = Provider.of(context, listen: false); final existingSource = appModel.sourceCodeController.text; + appModel.genAiManager.enterGeneratingEdit(); + try { - final stream = appServices.suggestFix( - SuggestFixRequest( - appType: appType, - errorMessage: errorMessage, - line: line, - column: column, - source: existingSource, + appModel.genAiManager.startStream( + appServices.suggestFix( + SuggestFixRequest( + appType: appType, + errorMessage: errorMessage, + line: line, + column: column, + source: existingSource, + ), ), ); - final result = await showDialog( - context: context, - builder: - (context) => GeneratingCodeDialog( - stream: stream, - title: 'Generating Fix Suggestion', - existingSource: existingSource, - ), - ); + // final result = await showDialog( + // context: context, + // builder: + // (context) => GeneratingCodeDialog( + // stream: stream, + // title: 'Generating Fix Suggestion', + // existingSource: existingSource, + // ), + // ); - if (!context.mounted || result == null || result.isEmpty) return; + // if (!context.mounted || result == null || result.isEmpty) return; - if (result == existingSource) { - appModel.editorStatus.showToast('No suggested fix'); - } else { - appModel.editorStatus.showToast('Fix suggested'); - appModel.sourceCodeController.textNoScroll = result; - appServices.editorService!.focus(); - appServices.performCompileAndReloadOrRun(); - } + // if (result == existingSource) { + // appModel.editorStatus.showToast('No suggested fix'); + // appModel.genAiManager.enterStandby(); + // } else { + // appModel.editorStatus.showToast('Fix suggested'); + // appModel.sourceCodeController.textNoScroll = result; + // appServices.editorService!.focus(); + // appServices.performCompileAndReloadOrRun(); + // } } catch (error) { appModel.editorStatus.showToast('Error suggesting fix'); appModel.appendLineToConsole('Suggesting fix issue: $error'); + appModel.genAiManager.enterStandby(); } } diff --git a/pkgs/dartpad_ui/lib/widgets.dart b/pkgs/dartpad_ui/lib/widgets.dart index a981d2736..5c1b62dfb 100644 --- a/pkgs/dartpad_ui/lib/widgets.dart +++ b/pkgs/dartpad_ui/lib/widgets.dart @@ -15,6 +15,7 @@ import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'editor/editor.dart'; +import 'main.dart'; import 'model.dart'; import 'theme.dart'; import 'utils.dart'; @@ -296,8 +297,26 @@ final class Logo extends StatelessWidget { } } -bool get _nonMac => defaultTargetPlatform != TargetPlatform.macOS; -bool get _mac => defaultTargetPlatform == TargetPlatform.macOS; +class ImageAttachmentsManager { + final attachments = List.empty(growable: true); + + void removeAttachment(int index) => attachments.removeAt(index); + + Future addAttachment() async { + final pic = await ImagePicker().pickImage(source: ImageSource.gallery); + + if (pic == null) return; + + final bytes = await pic.readAsBytes(); + attachments.add( + Attachment.fromBytes( + name: pic.name, + bytes: bytes, + mimeType: pic.mimeType ?? lookupMimeType(pic.name) ?? 'image', + ), + ); + } +} class PromptDialog extends StatefulWidget { const PromptDialog({ @@ -306,6 +325,8 @@ class PromptDialog extends StatefulWidget { required this.flutterPromptButtons, required this.dartPromptButtons, required this.initialAppType, + required this.promptTextController, + required this.imageAttachmentsManager, super.key, }); @@ -314,26 +335,18 @@ class PromptDialog extends StatefulWidget { final Map flutterPromptButtons; final Map dartPromptButtons; final AppType initialAppType; + final TextEditingController promptTextController; + final ImageAttachmentsManager imageAttachmentsManager; @override State createState() => _PromptDialogState(); } class _PromptDialogState extends State { - final _controller = TextEditingController(); - final _attachments = List.empty(growable: true); final _focusNode = FocusNode(); - late AppType _appType; - - @override - void initState() { - super.initState(); - _appType = widget.initialAppType; - } @override void dispose() { - _controller.dispose(); super.dispose(); } @@ -355,79 +368,65 @@ class _PromptDialogState extends State { width: 700, child: CallbackShortcuts( bindings: { - SingleActivator( - LogicalKeyboardKey.enter, - meta: _mac, - control: _nonMac, - ): () { - if (_controller.text.isNotEmpty) _onGenerate(); + SingleActivator(LogicalKeyboardKey.enter): () { + if (widget.promptTextController.text.isNotEmpty) _onGenerate(); }, }, child: Column( mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(height: 12), + TextField( + controller: widget.promptTextController, + focusNode: _focusNode, + autofocus: true, + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: TextStyle(color: Theme.of(context).hintColor), + labelText: 'Code generation prompt', + alignLabelWithHint: true, + border: const OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 18), Row( children: [ Expanded( child: OverflowBar( - spacing: 8, - alignment: MainAxisAlignment.start, + alignment: MainAxisAlignment.center, + spacing: 12, children: [ for (final entry - in _appType == AppType.flutter + in widget.initialAppType == AppType.flutter ? widget.flutterPromptButtons.entries : widget.dartPromptButtons.entries) - TextButton( + OutlinedButton.icon( + icon: PromptSuggestionIcon(), onPressed: () { - _controller.text = entry.value; + widget.promptTextController.text = entry.value; _focusNode.requestFocus(); }, - child: Text(entry.key), + label: Text(entry.key), ), ], ), ), - SegmentedButton( - showSelectedIcon: false, - segments: const [ - ButtonSegment( - value: AppType.dart, - label: Text('Dart'), - tooltip: 'Generate Dart code', - ), - ButtonSegment( - value: AppType.flutter, - label: Text('Flutter'), - tooltip: 'Generate Flutter code', - ), - ], - selected: {_appType}, - onSelectionChanged: (selected) { - setState(() => _appType = selected.first); - _focusNode.requestFocus(); - }, - ), ], ), - const SizedBox(height: 8), - TextField( - controller: _controller, - focusNode: _focusNode, - autofocus: true, - decoration: InputDecoration( - labelText: widget.hint, - alignLabelWithHint: true, - border: const OutlineInputBorder(), - ), - maxLines: 3, - ), - const SizedBox(height: 8), + const SizedBox(height: 28), SizedBox( - height: 128, + height: 64, child: EditableImageList( - attachments: _attachments, - onRemove: _removeAttachment, - onAdd: _addAttachment, + attachments: widget.imageAttachmentsManager.attachments, + onRemove: (int index) { + widget.imageAttachmentsManager.removeAttachment(index); + setState(() {}); + }, + onAdd: () async { + await widget.imageAttachmentsManager.addAttachment(); + setState(() {}); + }, maxAttachments: 3, ), ), @@ -441,7 +440,7 @@ class _PromptDialogState extends State { child: const Text('Cancel'), ), ValueListenableBuilder( - valueListenable: _controller, + valueListenable: widget.promptTextController, builder: (context, controller, _) => TextButton( onPressed: controller.text.isEmpty ? null : _onGenerate, @@ -460,73 +459,67 @@ class _PromptDialogState extends State { } void _onGenerate() { - assert(_controller.text.isNotEmpty); + assert(widget.promptTextController.text.isNotEmpty); Navigator.pop( context, PromptDialogResponse( - appType: _appType, - prompt: _controller.text, - attachments: _attachments, - ), - ); - } - - void _removeAttachment(int index) => - setState(() => _attachments.removeAt(index)); - - Future _addAttachment() async { - final pic = await ImagePicker().pickImage(source: ImageSource.gallery); - - if (pic == null) return; - - final bytes = await pic.readAsBytes(); - setState( - () => _attachments.add( - Attachment.fromBytes( - name: pic.name, - bytes: bytes, - mimeType: pic.mimeType ?? lookupMimeType(pic.name) ?? 'image', - ), + appType: widget.initialAppType, + imageAttachmentsManager: widget.imageAttachmentsManager, + promptTextController: widget.promptTextController, ), ); } } -class GeneratingCodeDialog extends StatefulWidget { - const GeneratingCodeDialog({ - required this.stream, - required this.title, - this.existingSource, +class GeneratingCodePanel extends StatefulWidget { + const GeneratingCodePanel({ + required this.appModel, + required this.appServices, super.key, }); - final Stream stream; - final String title; - final String? existingSource; + final AppModel appModel; + final AppServices appServices; + @override - State createState() => _GeneratingCodeDialogState(); + State createState() => _GeneratingCodePanelState(); } -class _GeneratingCodeDialogState extends State { - final _generatedCode = StringBuffer(); +class _GeneratingCodePanelState extends State { final _focusNode = FocusNode(); - bool _done = false; StreamSubscription? _subscription; @override void initState() { super.initState(); - _subscription = widget.stream.listen( - (text) => setState(() => _generatedCode.write(text)), - onDone: - () => setState(() { - final source = _generatedCode.toString().trim(); - _generatedCode.clear(); - _generatedCode.write(source); - _done = true; - _focusNode.requestFocus(); - }), + final genAiManager = widget.appModel.genAiManager; + + final stream = genAiManager.stream; + + _subscription = stream.value.listen( + (text) => setState(() { + genAiManager.writeToStreamBuffer(text); + }), + onDone: () { + setState(() { + final generatedCode = genAiManager.generatedCode().trim(); + if (generatedCode.isEmpty) { + widget.appModel.editorStatus.showToast('Error generating code'); + widget.appModel.appendError( + 'There was an error generating your code, please try again.', + ); + widget.appModel.genAiManager.enterStandby(); + return; + } + genAiManager.setStreamBufferValue(generatedCode); + genAiManager.setStreamIsDone(true); + genAiManager.enterAwaitingAcceptReject(); + _focusNode.requestFocus(); + widget.appModel.sourceCodeController.textNoScroll = generatedCode; + widget.appServices.performCompileAndRun(); + }); + }, ); } @@ -538,104 +531,74 @@ class _GeneratingCodeDialogState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - - return PointerInterceptor( - child: CallbackShortcuts( - bindings: { - SingleActivator( - LogicalKeyboardKey.enter, - meta: _mac, - control: _nonMac, - ): () { - if (_done) _onAcceptAndRun(); - }, - }, - child: AlertDialog( - backgroundColor: theme.scaffoldBackgroundColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - side: BorderSide(color: theme.colorScheme.outline), - ), - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(widget.title), - if (!_done) const CircularProgressIndicator(), - ], - ), - contentTextStyle: theme.textTheme.bodyMedium, - contentPadding: const EdgeInsets.fromLTRB(24, defaultSpacing, 24, 8), - content: SizedBox( - width: 700, - child: Focus( - autofocus: true, - focusNode: _focusNode, - child: - widget.existingSource == null - ? ReadOnlyCodeWidget(_generatedCode.toString()) - : ReadOnlyDiffWidget( - existingSource: widget.existingSource!, - newSource: _generatedCode.toString(), - ), - ), - ), - actions: [ - Row( - children: [ - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: RichText( - text: TextSpan( - text: 'Powered by ', - style: DefaultTextStyle.of(context).style, - children: [ - TextSpan( - text: 'Google AI', - style: TextStyle(color: theme.colorScheme.primary), - recognizer: - TapGestureRecognizer() - ..onTap = () { - url_launcher.launchUrl( - Uri.parse('https://ai.google.dev/'), - ); - }, - ), - TextSpan( - text: ' and the Gemini API', - style: DefaultTextStyle.of(context).style, - ), - ], - ), - ), + final genAiManager = widget.appModel.genAiManager; + return ValueListenableBuilder( + valueListenable: genAiManager.streamIsDone, + builder: ( + BuildContext context, + bool genAiCodeStreamIsDone, + Widget? child, + ) { + final resolvedSpinner = + genAiCodeStreamIsDone + ? SizedBox(width: 0, height: 0) + : Positioned( + top: 10, + right: 10, + child: AnimatedContainer( + duration: animationDelay, + curve: animationCurve, + child: CircularProgressIndicator(), ), - ), - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: _done ? _onAcceptAndRun : null, - child: Text( - 'Accept', - style: TextStyle( - color: !_done ? theme.disabledColor : null, - ), + ); + return Stack( + children: [ + resolvedSpinner, + ValueListenableBuilder( + valueListenable: genAiManager.streamBuffer, + builder: ( + BuildContext context, + StringBuffer genAiCodeStreamBuffer, + Widget? child, + ) { + return Focus( + autofocus: true, + focusNode: _focusNode, + child: ValueListenableBuilder( + valueListenable: + widget.appModel.genAiManager.isGeneratingNewProject, + builder: ( + BuildContext context, + bool genAiGeneratingNewProject, + Widget? child, + ) { + return ValueListenableBuilder( + valueListenable: genAiManager.preGenAiSourceCode, + builder: ( + BuildContext context, + String existingSource, + Widget? child, + ) { + return genAiGeneratingNewProject + ? ReadOnlyCodeWidget( + genAiCodeStreamBuffer.toString(), + ) + : ReadOnlyDiffWidget( + existingSource: existingSource, + newSource: genAiCodeStreamBuffer.toString(), + ); + }, + ); + }, ), - ), - ], + ); + }, ), ], - ), - ), + ); + }, ); } - - void _onAcceptAndRun() { - assert(_done); - Navigator.pop(context, _generatedCode.toString()); - } } class EditableImageList extends StatelessWidget { @@ -643,6 +606,7 @@ class EditableImageList extends StatelessWidget { final void Function(int index) onRemove; final void Function() onAdd; final int maxAttachments; + final bool compactDisplay; const EditableImageList({ super.key, @@ -650,25 +614,30 @@ class EditableImageList extends StatelessWidget { required this.onRemove, required this.onAdd, required this.maxAttachments, + this.compactDisplay = false, }); @override Widget build(BuildContext context) { return ListView.builder( - reverse: true, scrollDirection: Axis.horizontal, // First item is the "Add Attachment" button itemCount: attachments.length + 1, itemBuilder: (context, index) { if (index == 0) { + if (compactDisplay) { + return SizedBox(height: 0, width: 0); + } return _AddImageWidget( onAdd: attachments.length < maxAttachments ? onAdd : null, + hasAttachments: attachments.isNotEmpty, ); } else { final attachmentIndex = index - 1; return _ImageAttachmentWidget( attachment: attachments[attachmentIndex], onRemove: () => onRemove(attachmentIndex), + compactDisplay: compactDisplay, ); } }, @@ -679,14 +648,21 @@ class EditableImageList extends StatelessWidget { class _ImageAttachmentWidget extends StatelessWidget { final Attachment attachment; final void Function() onRemove; + final bool compactDisplay; const _ImageAttachmentWidget({ required this.attachment, required this.onRemove, + required this.compactDisplay, }); + final double regularThumbnailSize = 64; + final double compactThumbnailSize = 32; + @override Widget build(BuildContext context) { + final resolvedThumbnailEdgeInsets = + compactDisplay ? EdgeInsets.fromLTRB(0, 4, 4, 0) : EdgeInsets.all(8); return Stack( children: [ GestureDetector( @@ -712,31 +688,35 @@ class _ImageAttachmentWidget extends StatelessWidget { ); }, child: Container( - margin: const EdgeInsets.all(8), - width: 128, - height: 128, + margin: resolvedThumbnailEdgeInsets, + width: compactDisplay ? compactThumbnailSize : regularThumbnailSize, + height: + compactDisplay ? compactThumbnailSize : regularThumbnailSize, decoration: BoxDecoration( image: DecorationImage( image: MemoryImage(attachment.bytes), - fit: BoxFit.contain, + fit: BoxFit.cover, ), ), ), ), Positioned( - top: 4, - right: 12, - child: InkWell( - onTap: onRemove, - child: Tooltip( - message: 'Remove Image', - child: CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.secondary, - radius: 12, - child: Icon( - Icons.close, - size: 16, - color: Theme.of(context).colorScheme.onSecondary, + top: compactDisplay ? 2 : 4, + right: compactDisplay ? 2 : 4, + child: Transform.scale( + scale: compactDisplay ? 0.7 : 1, + child: InkWell( + onTap: onRemove, + child: Tooltip( + message: 'Remove Image', + child: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + radius: 12, + child: Icon( + Icons.close, + size: 16, + color: Theme.of(context).colorScheme.onSecondary, + ), ), ), ), @@ -749,34 +729,484 @@ class _ImageAttachmentWidget extends StatelessWidget { class _AddImageWidget extends StatelessWidget { final void Function()? onAdd; - const _AddImageWidget({required this.onAdd}); + final bool hasAttachments; + const _AddImageWidget({required this.onAdd, required this.hasAttachments}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: IconButton.filledTonal( + icon: Icon(Icons.add), + onPressed: onAdd, + ), + ), + if (!hasAttachments) + Text('Add image(s) to support your prompt. (optional)'), + ], + ); + } +} + +class PromptSuggestionIcon extends StatelessWidget { + const PromptSuggestionIcon({super.key, this.height = 18, this.width = 18}); + + final double height; + final double width; + + @override + Widget build(BuildContext context) { + return Theme.of(context).brightness == Brightness.light + ? Opacity( + opacity: 0.75, + child: Image.asset( + 'prompt_suggestion_icon_lightmode.png', + height: height, + width: width, + ), + ) + : Image.asset( + 'prompt_suggestion_icon_darkmode.png', + height: height, + width: width, + ); + } +} + +class GeminiCodeEditTool extends StatefulWidget { + const GeminiCodeEditTool({ + super.key, + required this.appModel, + required this.onUpdateCode, + required this.onCancelUpdateCode, + required this.onRejectSuggestedCode, + required this.onEditUpdateCodePrompt, + required this.onAcceptUpdateCode, + required this.enabled, + }); + + final AppModel appModel; + final Future Function(BuildContext, PromptDialogResponse) onUpdateCode; + final VoidCallback onCancelUpdateCode; + final VoidCallback onEditUpdateCodePrompt; + final VoidCallback onAcceptUpdateCode; + final VoidCallback onRejectSuggestedCode; + final bool enabled; + + @override + State createState() => _GeminiCodeEditToolState(); +} + +class _GeminiCodeEditToolState extends State { + bool _textInputIsFocused = false; + late GenAiManager genAiManager; + + @override + void initState() { + super.initState(); + genAiManager = widget.appModel.genAiManager; + } + + @override + void dispose() { + super.dispose(); + } + + AppType analyzedAppTypeFromSource(AppModel appModel) { + if (appModel.sourceCodeController.text.contains( + """import 'package:flutter""", + )) { + return AppType.flutter; + } + return AppType.dart; + } + + void handlePromptSuggestion(String promptText) { + genAiManager.setEditPromptText(promptText); + } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8), - child: Align( - alignment: Alignment.topCenter, - child: SizedBox( - width: 128, - height: 128, - child: FittedBox( - fit: BoxFit.contain, - child: SizedBox.square( - dimension: 128, - child: ElevatedButton( - onPressed: onAdd, - style: ElevatedButton.styleFrom( - shape: const RoundedRectangleBorder(), + final theme = Theme.of(context); + final appType = analyzedAppTypeFromSource(widget.appModel); + final promptController = genAiManager.codeEditPromptController; + final imageAttachmentsManager = + genAiManager.codeEditImageAttachmentsManager; + + final textInputBlock = Container( + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + border: Border( + top: Divider.createBorderSide( + context, + width: 8.0, + color: theme.colorScheme.surface, + ), + ), + ), + padding: const EdgeInsets.all(denseSpacing), + child: Focus( + onFocusChange: + (value) => setState(() { + _textInputIsFocused = value; + }), + child: Column( + children: [ + CallbackShortcuts( + bindings: { + SingleActivator(LogicalKeyboardKey.enter): () { + if (promptController.text.isNotEmpty) { + widget.onUpdateCode( + context, + PromptDialogResponse( + appType: appType, + imageAttachmentsManager: imageAttachmentsManager, + promptTextController: promptController, + ), + ); + setState(() {}); + } + }, + }, + child: TextField( + enabled: widget.enabled, + controller: promptController, + decoration: InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.fromLTRB(20, 10, 20, 10), + hintText: + widget.enabled + ? 'Ask Gemini to change your code or app!' + : '', + hintStyle: TextStyle(color: Theme.of(context).hintColor), + prefixIcon: GeminiEditPrefixIcon( + enabled: widget.enabled, + textFieldIsFocused: _textInputIsFocused, + handlePromptSuggestion: handlePromptSuggestion, + appType: appType, + onAddImage: () async { + await imageAttachmentsManager.addAttachment(); + setState(() {}); + }, + ), + suffixIcon: GeminiEditSuffixIcon( + textFieldIsFocused: _textInputIsFocused, + onGenerate: () { + widget.onUpdateCode( + context, + PromptDialogResponse( + appType: appType, + imageAttachmentsManager: imageAttachmentsManager, + promptTextController: promptController, + ), + ); + setState(() {}); + }, + ), ), - child: const Center( - child: Text('Add\nImage', textAlign: TextAlign.center), + maxLines: 8, + minLines: 1, + ), + ), + if (imageAttachmentsManager.attachments.isNotEmpty) + SizedBox( + height: 32, + child: EditableImageList( + compactDisplay: true, + attachments: imageAttachmentsManager.attachments, + onRemove: (int index) { + imageAttachmentsManager.removeAttachment(index); + setState(() {}); + }, + onAdd: () => {}, // the Add button isn't shown here + maxAttachments: 3, ), ), + ], + ), + ), + ); + + final acceptRejectBlock = ValueListenableBuilder( + valueListenable: genAiManager.currentState, + builder: (BuildContext context, GenAiState genAiState, Widget? child) { + if (genAiState == GenAiState.standby) { + return SizedBox(width: 0, height: 0); + } + final geminiIcon = Image.asset( + 'assets/gemini_sparkle_192.png', + width: 16, + height: 16, + ); + final GeminiMessageTextTheme = TextStyle( + color: Color.fromARGB(255, 60, 60, 60), + ); + // TODO(alsobrian) 3/11/25: ExpectNever? + final resolvedStatusMessage = + genAiState == GenAiState.generating + ? Text('Generating your code', style: GeminiMessageTextTheme) + : Text( + 'Gemini proposed the above', + style: GeminiMessageTextTheme, + ); + final resolvedButtons = + genAiState == GenAiState.generating + ? [ + TextButton( + onPressed: widget.onCancelUpdateCode, + child: Text('Cancel', style: GeminiMessageTextTheme), + ), + ] + : [ + TextButton( + onPressed: widget.onRejectSuggestedCode, + child: Text('Cancel', style: GeminiMessageTextTheme), + ), + OutlinedButton( + onPressed: widget.onEditUpdateCodePrompt, + child: Text('Change Prompt', style: GeminiMessageTextTheme), + ), + FilledButton( + onPressed: widget.onAcceptUpdateCode, + style: FilledButton.styleFrom( + backgroundColor: Color(0xff2e64de), + ), + child: Text( + 'Accept', + style: TextStyle(color: Colors.white), + ), + ), + ]; + return Container( + height: 56, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFD7E6FF), + Color(0xFFC7E4FF), + Color(0xFFDCE2FF), + // Color(0xFF2E64De), + // Color(0xFF3C8FE3), + // Color(0xFF987BE9), + ], + ), + ), //Color.fromRGBO(200, 230, 255, 1.0)), + child: Padding( + padding: const EdgeInsets.fromLTRB(8.0, 0, 0, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + geminiIcon, + SizedBox(width: 8), + resolvedStatusMessage, + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 8.0, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: 12, + children: resolvedButtons, + ), + ), + ), + ], ), ), + ); + }, + ); + + return Column(children: [acceptRejectBlock, textInputBlock]); + } +} + +class GeminiEditPrefixIcon extends StatelessWidget { + const GeminiEditPrefixIcon({ + super.key, + required this.textFieldIsFocused, + required this.appType, + required this.handlePromptSuggestion, + required this.onAddImage, + required this.enabled, + }); + + final bool textFieldIsFocused; + final AppType appType; + final void Function(String) handlePromptSuggestion; + final void Function() onAddImage; + final bool enabled; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: textFieldIsFocused ? 12 : 8), + ...[ + textFieldIsFocused + ? GeminiCodeEditMenu( + currentAppType: appType, + handlePromptSuggestion: handlePromptSuggestion, + onAddImage: onAddImage, + ) + : SizedBox( + width: 29, + child: Align( + alignment: Alignment.centerRight, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Image.asset( + 'assets/gemini_sparkle_192.png', + fit: BoxFit.contain, + height: 24, + width: 24, + ), + ), + ), + ), + ], + SizedBox(width: textFieldIsFocused ? 4 : 5), + ], + ); + } +} + +class GeminiEditSuffixIcon extends StatelessWidget { + const GeminiEditSuffixIcon({ + super.key, + required this.textFieldIsFocused, + required this.onGenerate, + }); + + final bool textFieldIsFocused; + final void Function() onGenerate; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 30, + child: + textFieldIsFocused + ? Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: 26, + height: 26, + child: IconButton( + padding: EdgeInsets.all(0), + onPressed: onGenerate, + icon: const Icon(Icons.send), + iconSize: 14, + ), + ), + ) + : null, + ); + } +} + +class GeminiCodeEditMenu extends StatelessWidget { + final Map> promptSuggestions; + final AppType currentAppType; + final void Function(String) handlePromptSuggestion; + final void Function() onAddImage; + + const GeminiCodeEditMenu({ + super.key, + required this.currentAppType, + required this.handlePromptSuggestion, + required this.onAddImage, + + this.promptSuggestions = const { + AppType.dart: { + 'pretty-dart': 'Make the app pretty', + 'fancy-dart': 'Make the app fancy', + 'emoji-dart': 'Make the app use emojis', + }, + AppType.flutter: { + 'pretty': + 'Make the app pretty by improving the visual design - add proper spacing, consistent typography, a pleasing color scheme, and ensure the overall layout follows Material Design principles', + 'fancy': + 'Make the app fancy by adding rounded corners where appropriate, subtle shadows and animations for interactivity; make tasteful use of gradients and images', + 'emoji': + 'Make the app use emojis by adding appropriate emoji icons and text', + }, + }, + }); + + @override + Widget build(BuildContext context) { + final List resolvedPromptSuggestions = + promptSuggestions[currentAppType]?.entries.map((entry) { + final String promptName = entry.key; + final String promptText = entry.value; + return GeminiCodeEditMenuPromptSuggestion( + displayName: promptName, + promptText: promptText, + handlePromptSuggestion: () => handlePromptSuggestion(promptText), + ); + }).toList() ?? + []; + final List resolvedMenuItems = [ + ...resolvedPromptSuggestions, + MenuItemButton( + leadingIcon: const Icon(Icons.image, size: 16), + onPressed: onAddImage, + child: Padding( + padding: EdgeInsets.only(right: 32), + child: Text('Add image'), ), ), + ]; + + return MenuAnchor( + builder: (context, MenuController menuController, Widget? child) { + return SizedBox( + height: 26, + width: 26, + child: IconButton.filledTonal( + onPressed: () => menuController.toggleMenuState(), + padding: EdgeInsets.all(0.0), + icon: const Icon(Icons.add), + iconSize: 16, + ), + ); + }, + alignmentOffset: Offset(0, 10), + menuChildren: [ + ...resolvedMenuItems.map((widget) => PointerInterceptor(child: widget)), + ], + ); + } +} + +class GeminiCodeEditMenuPromptSuggestion extends StatelessWidget { + const GeminiCodeEditMenuPromptSuggestion({ + super.key, + required this.displayName, + required this.promptText, + required this.handlePromptSuggestion, + }); + + final String displayName; + final String promptText; + final VoidCallback handlePromptSuggestion; + + @override + Widget build(BuildContext context) { + return MenuItemButton( + leadingIcon: PromptSuggestionIcon(), + onPressed: handlePromptSuggestion, + child: Padding( + padding: EdgeInsets.only(right: 32), + child: Text(displayName), + ), ); } }