diff --git a/pkgs/dart_services/lib/src/common_server.dart b/pkgs/dart_services/lib/src/common_server.dart index 0dfc136f4..96d6847e6 100644 --- a/pkgs/dart_services/lib/src/common_server.dart +++ b/pkgs/dart_services/lib/src/common_server.dart @@ -15,6 +15,7 @@ import 'package:shelf_router/shelf_router.dart'; import 'analysis.dart'; import 'caching.dart'; import 'compiling.dart'; +import 'generative_ai.dart'; import 'project_templates.dart'; import 'pub.dart'; import 'sdk.dart'; @@ -36,6 +37,7 @@ class CommonServerImpl { late Analyzer analyzer; late Compiler compiler; + final ai = GenerativeAI(); CommonServerImpl( this.sdk, @@ -82,6 +84,9 @@ class CommonServerApi { router.post(r'/api//format', handleFormat); router.post(r'/api//document', handleDocument); router.post(r'/api//openInIDX', handleOpenInIdx); + router.post(r'/api//generateCode', generateCode); + router.post(r'/api//updateCode', updateCode); + router.post(r'/api//suggestFix', suggestFix); return router; }(); @@ -236,6 +241,85 @@ class CommonServerApi { } } + @Route.post('$apiPrefix/suggestFix') + Future suggestFix(Request request, String apiVersion) async { + if (apiVersion != api3) return unhandledVersion(apiVersion); + + final suggestFixRequest = + api.SuggestFixRequest.fromJson(await request.readAsJson()); + + return _streamResponse( + 'suggestFix', + impl.ai.suggestFix( + message: suggestFixRequest.errorMessage, + line: suggestFixRequest.line, + column: suggestFixRequest.column, + source: suggestFixRequest.source, + ), + ); + } + + @Route.post('$apiPrefix/generateCode') + Future generateCode(Request request, String apiVersion) async { + if (apiVersion != api3) return unhandledVersion(apiVersion); + + final generateCodeRequest = + api.GenerateCodeRequest.fromJson(await request.readAsJson()); + + return _streamResponse( + 'generateCode', + impl.ai.generateCode( + appType: generateCodeRequest.appType, + prompt: generateCodeRequest.prompt, + attachments: generateCodeRequest.attachments, + ), + ); + } + + @Route.post('$apiPrefix/updateCode') + Future updateCode(Request request, String apiVersion) async { + if (apiVersion != api3) return unhandledVersion(apiVersion); + + final updateCodeRequest = + api.UpdateCodeRequest.fromJson(await request.readAsJson()); + + return _streamResponse( + 'updateCode', + impl.ai.updateCode( + appType: updateCodeRequest.appType, + prompt: updateCodeRequest.prompt, + source: updateCodeRequest.source, + attachments: updateCodeRequest.attachments, + ), + ); + } + + Future _streamResponse( + String action, + Stream inputStream, + ) async { + try { + // NOTE: disabling gzip so that the client gets the data in the same + // chunks that the LLM is providing it to us. With gzip, the client + // receives the data all at once at the end of the stream. + final outputStream = inputStream.transform(utf8.encoder); + return Response.ok( + outputStream, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', // describe our bytes + 'Content-Encoding': 'identity', // disable gzip + }, + context: {'shelf.io.buffer_output': false}, // disable buffering + ); + } catch (e) { + final logger = Logger(action); + logger.severe('$action error: $e'); + return Response.internalServerError( + body: 'Failed to $action. Error: $e', + ); + } + } + Response ok(Map json) { return Response.ok( _jsonEncoder.convert(json), diff --git a/pkgs/dart_services/lib/src/generative_ai.dart b/pkgs/dart_services/lib/src/generative_ai.dart new file mode 100644 index 000000000..88d7628a7 --- /dev/null +++ b/pkgs/dart_services/lib/src/generative_ai.dart @@ -0,0 +1,526 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:dartpad_shared/model.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:logging/logging.dart'; + +import 'project_templates.dart'; +import 'pub.dart'; + +final _logger = Logger('gen-ai'); + +class GenerativeAI { + static const _apiKeyVarName = 'PK_GEMINI_API_KEY'; + static const _geminiModel = 'gemini-2.0-flash'; + late final String? _geminiApiKey; + + GenerativeAI() { + final geminiApiKey = Platform.environment[_apiKeyVarName]; + if (geminiApiKey == null || geminiApiKey.isEmpty) { + _logger.warning('$_apiKeyVarName not set; gen-ai features DISABLED'); + } else { + _logger.info('$_apiKeyVarName set; gen-ai features ENABLED'); + _geminiApiKey = geminiApiKey; + } + } + + bool get _canGenAI => _geminiApiKey != null; + + late final _fixModel = _canGenAI + ? GenerativeModel( + apiKey: _geminiApiKey!, + model: _geminiModel, + systemInstruction: _flutterSystemInstructions( + ''' +You will be given an error message in provided Dart source code along with an +optional line and column number where the error appears. Please fix the code and +return it in it's entirety. The response should be the same program as the input +with the error fixed. +''', + ), + ) + : null; + + Stream suggestFix({ + required String message, + required int? line, + required int? column, + required String source, + }) async* { + _checkCanAI(); + assert(_fixModel != null); + + final prompt = ''' +ERROR MESSAGE: $message +${line != null ? 'LINE: $line\n' : ''} +${column != null ? 'COLUMN: $column\n' : ''} +SOURCE CODE: +$source +'''; + final stream = _fixModel!.generateContentStream([Content.text(prompt)]); + yield* cleanCode(_textOnly(stream)); + } + + late final _newFlutterCodeModel = _canGenAI + ? GenerativeModel( + apiKey: _geminiApiKey!, + model: _geminiModel, + systemInstruction: _flutterSystemInstructions( + ''' +Generate a Flutter program that satisfies the provided description. +''', + ), + ) + : null; + + late final _newDartCodeModel = _canGenAI + ? GenerativeModel( + apiKey: _geminiApiKey!, + model: _geminiModel, + systemInstruction: _dartSystemInstructions( + ''' +Generate a Dart program that satisfies the provided description. +''', + ), + ) + : null; + + Stream generateCode({ + required AppType appType, + required String prompt, + required List attachments, + }) async* { + _checkCanAI(); + assert(_newFlutterCodeModel != null); + assert(_newDartCodeModel != null); + + final model = switch (appType) { + AppType.flutter => _newFlutterCodeModel!, + AppType.dart => _newDartCodeModel!, + }; + + final stream = model.generateContentStream([ + Content.text(prompt), + ...attachments.map((a) => Content.data(a.mimeType, a.bytes)), + ]); + + yield* cleanCode(_textOnly(stream)); + } + + late final _updateFlutterCodeModel = _canGenAI + ? GenerativeModel( + apiKey: _geminiApiKey!, + model: _geminiModel, + systemInstruction: _flutterSystemInstructions( + ''' +You will be given an existing Flutter program and a description of a change to +be made to it. Generate an updated Flutter program that satisfies the +description. +''', + ), + ) + : null; + + late final _updateDartCodeModel = _canGenAI + ? GenerativeModel( + apiKey: _geminiApiKey!, + model: _geminiModel, + systemInstruction: _dartSystemInstructions( + ''' +You will be given an existing Dart program and a description of a change to +be made to it. Generate an updated Dart program that satisfies the +description. +''', + ), + ) + : null; + + Stream updateCode({ + required AppType appType, + required String prompt, + required String source, + required List attachments, + }) async* { + _checkCanAI(); + assert(_updateFlutterCodeModel != null); + assert(_updateDartCodeModel != null); + + final model = switch (appType) { + AppType.flutter => _updateFlutterCodeModel!, + AppType.dart => _updateDartCodeModel!, + }; + + final completePrompt = ''' +EXISTING SOURCE CODE: +$source + +CHANGE DESCRIPTION: +$prompt +'''; + + final stream = model.generateContentStream([ + Content.text(completePrompt), + ...attachments.map((a) => Content.data(a.mimeType, a.bytes)), + ]); + + yield* cleanCode(_textOnly(stream)); + } + + void _checkCanAI() { + if (!_canGenAI) throw Exception('Gemini API key not set'); + } + + static Stream _textOnly(Stream stream) => + stream + .map((response) => response.text ?? '') + .where((text) => text.isNotEmpty); + + static const startCodeBlock = '```dart\n'; + static const endCodeBlock = '```'; + + static Stream cleanCode(Stream stream) async* { + var foundFirstLine = false; + final buffer = StringBuffer(); + await for (final chunk in stream) { + // looking for the start of the code block (if there is one) + if (!foundFirstLine) { + buffer.write(chunk); + if (chunk.contains('\n')) { + foundFirstLine = true; + final text = buffer.toString().replaceFirst(startCodeBlock, ''); + buffer.clear(); + if (text.isNotEmpty) yield text; + continue; + } + + // still looking for the start of the first line + continue; + } + + // looking for the end of the code block (if there is one) + assert(foundFirstLine); + String processedChunk; + if (chunk.endsWith(endCodeBlock)) { + processedChunk = chunk.substring(0, chunk.length - endCodeBlock.length); + } else if (chunk.endsWith('$endCodeBlock\n')) { + processedChunk = + '${chunk.substring(0, chunk.length - endCodeBlock.length - 1)}\n'; + } else { + processedChunk = chunk; + } + + if (processedChunk.isNotEmpty) yield processedChunk; + } + + // if we're still in the first line, yield it + if (buffer.isNotEmpty) yield buffer.toString(); + } +} + +final _cachedAllowedFlutterPackages = List.empty(growable: true); +List _allowedFlutterPackages() { + if (_cachedAllowedFlutterPackages.isEmpty) { + final versions = getPackageVersions(); + for (final MapEntry(key: name, value: version) in versions.entries) { + if (isSupportedPackage(name)) { + _cachedAllowedFlutterPackages.add('$name: $version'); + } + } + } + + return _cachedAllowedFlutterPackages; +} + +final _cachedAllowedDartPackages = List.empty(growable: true); +List _allowedDartPackages() { + if (_cachedAllowedDartPackages.isEmpty) { + final versions = getPackageVersions(); + for (final MapEntry(key: name, value: version) in versions.entries) { + if (isSupportedDartPackage(name)) { + _cachedAllowedDartPackages.add('$name: $version'); + } + } + } + + return _cachedAllowedDartPackages; +} + +Content _flutterSystemInstructions(String modelSpecificInstructions) => + Content.text(''' +You're an expert Flutter developer and UI designer creating Custom User +Interfaces: generated, bespoke, interactive interfaces created on-the-fly using +the Flutter SDK API. You will produce a professional, release-ready Flutter +application. All of the instructions below are required to be rigorously +followed. + +Custom user interfaces add capabilities to apps so they can construct +just-in-time user interfaces that utilize design aesthetics, meaningful +information hierarchies, rich visual media, and allow for direct graphical +interactions. + +Custom user interfaces shall be designed to help the user achieve a specific +goal, as expressed or implied by their prompt. They scale from simple text +widgets to rich interactive experiences. Custom user interfaces shall prioritize +clarity and ease of use. Include only the essential elements needed for the user +to achieve their goal, and present information in a clear and concise manner. +Design bespoke UIs to guide the user towards their desired outcome. + +You're using the following process to systematically build the UI (each of the +numbered steps in the process is a separate part of the process, and can be +considered separate prompts; later steps have access to the output of the +earlier steps): + +1. PRD: plan how to build a rich UI that fully satisfies the user's needs. +2. APP_PRODUCTION: integrate all the data from the previous steps and generate + the principal widget for a Flutter app (the one that should be supplied as + the home widget for MaterialApp), including the DATA_MODEL. +3. OUTPUT: output the finished application code only, with no explanations or + commentary. + +After each step in the process, integrate the information you have collected in +the previous step and move to the next step without stopping for verification +from the user. The only output shall be the result of the OUTPUT step. + +# Requirements for Generating UI Code +All portions of the UI shall be implemented and functional, without TODO items +or placeholders. + +All necessary UI component callbacks shall be hooked up appropriately and modify +state when the user interacts with them. + +Initial UI data shall be initialized when constructing the data model, not in +the build function(s). Do not initialize to empty values if those are not valid +values in the UI. + +The UI shall be "live" and update the data model each time a state change occurs +without requiring the user to "submit" state changes. + +UI code shall be data-driven and independent of the specific data to be +displayed, so that any similar data could be substituted. For instance, if +choices or data are presented to the user, the choices or data shall not be hard +coded into the widgets, they shall be driven by the data model, so that the +contents may be replaced by a data model change. + +Use appropriate IconData from Icons for icons in the UI. No emojis. Be careful +to allow the UI to adapt to differently sized devices without causing layout +errors or unusable UI. Do not make up icon names, use only icon names known to +exist. + +VERY IMPORTANT: Do not include "Flutter Demo", "Flutter Demo Home Page" or other +demo-related text in the UI. You're not making a demo. + +Use initializer lists when initializing values in a data model constructor. +Initializing members inside of the constructor body will cause a programming +error. There is no need to call super() without arguments. Initial values shall +always be valid values for the rest of the UI. + +Import only actual packages like flutter and provider. Don't make up package +names. Any import statements shall only appear at the top of the file before any +other code. + +Only import packages that are explicitly allowed. Packages which start with +"dart:" or "package:flutter/" are allowed, as are any packages specified in the +ALLOWED PACKAGES section below. + +Whenever a state management solution is necessary, the provider package is +preferred, but only if provider is listed as an allowed package. Do not use a +state management solution for trivial state management, such as passing data to +a widget that is a child of the widget which provides the data. + +Be sure to supply specific type arguments to all functions and constructors with +generic type arguments. This is especially important for Provider APIs and when +using the map function on Lists and other Iterables. + +When a generic type argument has a type constraint, be sure that the value given +to the generic is of a compatible type. For example, when using a class defined +as ChangeNotifierProvider, make sure that the +provided data class is of type ChangeNotifier?. + +Anything returned from the function given as the create argument of a +ChangeNotifierProvider must extend ChangeNotifier, or +it won't compile. The return type of the create function must be a +ChangeNotifier?, so the type of the value returned must extend ChangeNotifier. +If it returns a simple Object, it will not compile. + +When using ChangeNotifierProvider, use the `builder` parameter rather than the +`child` parameter to ensure that the correct context is used. E.g. + ```dart + ChangeNotifierProvider( + create: (context) => CatNameData(), + builder: (context, child) => SomeWidget()... + ) + ``` + +When working with BuildContexts, be sure that the correct context variable is +being supplied as an argument. Take into account that the context given to a +build function does not contain the widgets inside of the build function. + +When using TabBar and TabBarView, remember to specify a TabBarController using +the `controller` parameter in the constructor. For example: + ```dart + TabBar( + controller: _tabController, + tabs: myTabs, + ), + ``` +The TabBarController must be owned by a StatefulWidget that implements +TickerProviderStateMixin. Remember to dispose of the TabBarController. + +When creating callbacks, be sure that the nullability and type of the arguments, +and the return value, match the required signature of the callback. In +particular, remember that some widget callbacks must accept nullable parameters, +such as the DropdownButton's `onChanged` callback which must accept a nullable +String parameter: `void onChanged(String? value). + +Referencing a value from a map e.g. `myMap['some_key']` will return a *nullable* +type which *nearly always* needs to be converted to a non-null type, e.g. via a +cast like `myMap['some_key']!` or with a fallback value as in +`myMap['some_key'] ?? fallback`. + +When mapping an iterable, always specify the generic type of the result, e.g. +`someIterable.map((item) => someLogicToCreateWidget(item))`. + +If the Timer or Future types are referenced, the dart:async package must be +imported. + +Create new widget classes instead of creating private build functions that +return Widgets. This is because private build functions that return Widgets can +reduce performance by causing excessive rebuilding of the UI, and can cause the +wrong context to be used. + +Instance variables cannot be accessed from the initializer list of a +constructor. Only static variables can be accessed from the initializer list, +and only const members can be accessed from the initializer list of a const +constructor. + +Do not refer to any local assets in the app, e.g. *never* use `Image.asset`. If +you want to use an image and don't have a URL, use `Image.network` with the +following placeholder URL: +https://www.gstatic.com/flutter-onestack-prototype/genui/example_1.jpg. + +Make sure to check for layout overflows in the generated code and fix them +before returning the code. + +The OUTPUT step shall emit only complete, compilable Flutter Dart code in a +single file which contains a complete app meant to be able to run immediately. +The output you will create is the contents of the main.dart file. + +Make sure to take into account any attachments as part of the user's prompt. + +ALLOWED PACKAGES: +Allowed packages which are used must be imported using the IMPORT given in order +for the app to build. + +The following packages, at the specified versions, are allowed: +${_allowedFlutterPackages().map((p) => '- $p').join('\n')} + +$modelSpecificInstructions + +Only output the Dart code for the program. +'''); + +Content _dartSystemInstructions(String modelSpecificInstructions) => + Content.text(''' +You're an expert Dart developer specializing in writing efficient, idiomatic, +and production-ready Dart programs. +You will produce professional, release-ready Dart applications. All of the +instructions below are required to be rigorously followed. + +Dart applications include standalone scripts, backend services, CLI tools, and +other non-Flutter programs. +They shall prioritize clarity, maintainability, and correctness. Your output +must be complete, fully functional, and immediately executable. + +You're using the following process to systematically construct the Dart program +(each numbered step is a distinct part of the process): + +1. **PLANNING**: Determine how to fully implement the requested functionality in an idiomatic Dart program. +2. **IMPLEMENTATION**: Generate the entire Dart program, ensuring correctness, efficiency, and adherence to best practices. +3. **OUTPUT**: Output the finished program **only**, with no explanations or commentary. + +After each step in the process, integrate the information from the previous step +and move forward without requiring user verification. +The **only output** shall be the final, complete Dart program. + +--- + +### **Requirements for Generating Dart Code** +- All logic and functionality **must be fully implemented**—no TODOs, +placeholders, or incomplete functions. + +- Programs must **follow Dart best practices**, including effective use of +`async/await`, null safety, and type inference. + +- **Main execution should be structured properly**: The `main` function should +handle input/output in a clean and structured manner. + +- **No Flutter or UI code is allowed**: The generated program **must not** +include any `flutter` imports, widget-based logic, or references to +`MaterialApp`, `Widgets`, or UI-related libraries. + +- Programs must be **pure Dart**, using `dart:` libraries or explicitly allowed +third-party packages. + +- **Use appropriate null safety practices**: Avoid unnecessary nullable types +and provide meaningful default values when needed. + +- **Ensure correctness in type usage**: Use appropriate generic constraints and +avoid unnecessary dynamic typing. + +- If the program requires **parsing, async tasks, or JSON handling**, use Dart’s +built-in libraries like `dart:convert` and `dart:async` instead of external +dependencies unless specified. + +- **Use efficient and idiomatic Dart patterns**: Prefer `map`/`reduce` for list +operations, and use extension methods where relevant. + +- **Error handling must be robust**: If user input or file I/O is involved, wrap +potentially failing operations in `try/catch` blocks. + +- **Programs must be designed for reusability**: Functions and classes should be +structured to allow modularity and extension without unnecessary global state. + +--- + +### **Final Output Requirement** +- The **only** output shall be a **complete, compilable Dart program** in a +**single file** that runs immediately. + +- Ensure that the generated program meets the functional requirements described +in the prompt. + +- If any attachments are provided, they must be considered as part of the +prompt. + +--- + +### **Instructions for Generating Code** +**DO NOT** include Flutter-related imports, widgets, UI components, or anything +related to mobile app development. + +You are generating a **pure Dart** program with a `main` function entry point. + +--- + +### **Allowed Packages** +The following Dart packages are explicitly allowed. If a package is not listed, +it should not be used unless explicitly requested in the prompt. + +Allowed packages must be imported exactly as specified: +${_allowedDartPackages().map((p) => '- $p').join('\n')} + +Imports must appear at the top of the file before any other code. + +--- + +$modelSpecificInstructions + +--- + +Only output the Dart code for the program. +'''); diff --git a/pkgs/dart_services/lib/src/project_templates.dart b/pkgs/dart_services/lib/src/project_templates.dart index 15b228a56..ce70de326 100644 --- a/pkgs/dart_services/lib/src/project_templates.dart +++ b/pkgs/dart_services/lib/src/project_templates.dart @@ -193,10 +193,15 @@ String? _packageNameFromPackageUri(String uriString) { return uri.pathSegments.first; } -bool isSupportedPackage(String package) => - _packagesIndicatingFlutter.contains(package) || +bool isSupportedFlutterPackage(String package) => + _packagesIndicatingFlutter.contains(package); + +bool isSupportedDartPackage(String package) => supportedBasicDartPackages.contains(package); +bool isSupportedPackage(String package) => + isSupportedFlutterPackage(package) || isSupportedDartPackage(package); + /// If the specified [package] is deprecated in DartPad and /// slated to be removed in a future update. bool isDeprecatedPackage(String package) => diff --git a/pkgs/dart_services/pubspec.yaml b/pkgs/dart_services/pubspec.yaml index d890d0357..581f68cac 100644 --- a/pkgs/dart_services/pubspec.yaml +++ b/pkgs/dart_services/pubspec.yaml @@ -12,7 +12,8 @@ dependencies: bazel_worker: ^1.1.1 dartpad_shared: ^0.0.0 encrypt: ^5.0.3 - http: ^1.2.1 + google_generative_ai: ^0.4.6 + http: ^1.3.0 json_annotation: ^4.9.0 logging: ^1.2.0 meta: ^1.15.0 diff --git a/pkgs/dart_services/test/genai_test.dart b/pkgs/dart_services/test/genai_test.dart new file mode 100644 index 000000000..fb6ec5c08 --- /dev/null +++ b/pkgs/dart_services/test/genai_test.dart @@ -0,0 +1,75 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:dart_services/src/generative_ai.dart'; +import 'package:test/test.dart'; + +void main() { + group('GenerativeAI.cleanCode', () { + final unwrappedCode = ''' +void main() { + print("hello, world"); +} +'''; + + test('handles code without markdown wrapper', () async { + final input = Stream.fromIterable( + unwrappedCode.split('\n').map((line) => '$line\n'), + ); + final cleaned = await GenerativeAI.cleanCode(input).join(); + expect(cleaned.trim(), unwrappedCode.trim()); + }); + + test('handles code with markdown wrapper', () async { + final input = Stream.fromIterable( + unwrappedCode.split('\n').map((line) => '$line\n'), + ); + final cleaned = await GenerativeAI.cleanCode(input).join(); + expect(cleaned.trim(), unwrappedCode.trim()); + }); + + test('handles code with markdown wrapper and trailing newline', () async { + final input = Stream.fromIterable( + unwrappedCode.split('\n').map((line) => '$line\n'), + ); + final cleaned = await GenerativeAI.cleanCode(input).join(); + expect(cleaned.trim(), unwrappedCode.trim()); + }); + + test('handles single-chunk response with markdown wrapper', () async { + final input = Stream.fromIterable( + unwrappedCode.split('\n').map((line) => '$line\n'), + ); + final cleaned = await GenerativeAI.cleanCode(input).join(); + expect(cleaned.trim(), unwrappedCode.trim()); + }); + + test('handles partial first line buffering', () async { + final input = Stream.fromIterable([ + '```', + 'dart\n', + 'void main() {\n', + ' print("hello, world");\n', + '}\n', + '```', + ]); + + final cleaned = await GenerativeAI.cleanCode(input).join(); + expect(cleaned.trim(), unwrappedCode.trim()); + }); + + test('handles single-line code without trailing newline', () async { + final input = Stream.fromIterable( + ['void main() { print("hello, world"); }'], + ); + + final cleaned = await GenerativeAI.cleanCode(input).join(); + final oneline = unwrappedCode + .replaceAll('\n', ' ') + .replaceAll(' ', ' ') + .replaceAll(' ', ' '); + expect(cleaned.trim(), oneline.trim()); + }); + }); +} diff --git a/pkgs/dart_services/tool/dependencies/pub_dependencies_stable.json b/pkgs/dart_services/tool/dependencies/pub_dependencies_stable.json index c00e4803c..23504f9b7 100644 --- a/pkgs/dart_services/tool/dependencies/pub_dependencies_stable.json +++ b/pkgs/dart_services/tool/dependencies/pub_dependencies_stable.json @@ -6,7 +6,7 @@ "args": "2.6.0", "async": "2.11.0", "basics": "0.10.0", - "bloc": "8.1.4", + "bloc": "9.0.0", "boolean_selector": "2.1.1", "characters": "1.3.0", "clock": "1.1.1", @@ -18,7 +18,7 @@ "cross_file": "0.3.4+2", "crypto": "3.0.6", "csslib": "1.0.2", - "dart_earcut": "1.1.0", + "dart_earcut": "1.2.0", "dartz": "0.10.1", "english_words": "4.0.0", "equatable": "2.0.7", @@ -32,12 +32,12 @@ "flame_splash_screen": "0.3.1", "flame_tiled": "1.21.2", "flutter_adaptive_scaffold": "0.3.1", - "flutter_bloc": "8.1.6", + "flutter_bloc": "9.0.0", "flutter_hooks": "0.20.5", "flutter_map": "7.0.2", "flutter_markdown": "0.7.5", "flutter_riverpod": "2.6.1", - "flutter_svg": "2.0.16", + "flutter_svg": "2.0.17", "forge2d": "0.13.1", "frontend_server_client": "4.0.0", "glob": "2.1.2", @@ -46,7 +46,7 @@ "google_generative_ai": "0.4.6", "hooks_riverpod": "2.6.1", "html": "0.15.5", - "http": "1.2.2", + "http": "1.3.0", "http_multi_server": "3.2.2", "http_parser": "4.1.2", "intl": "0.20.1", @@ -61,7 +61,7 @@ "logger": "2.5.0", "logging": "1.3.0", "macros": "0.1.3-main.0", - "markdown": "7.2.2", + "markdown": "7.3.0", "matcher": "0.12.16+1", "material_color_utilities": "0.11.1", "meta": "1.15.0", @@ -92,11 +92,11 @@ "pub_semver": "2.1.5", "quiver": "3.2.2", "riverpod": "2.6.1", - "rohd": "0.6.0", + "rohd": "0.6.1", "rohd_vf": "0.6.0", "rxdart": "0.28.0", "shared_preferences": "2.3.5", - "shared_preferences_android": "2.4.0", + "shared_preferences_android": "2.4.2", "shared_preferences_foundation": "2.5.4", "shared_preferences_linux": "2.4.1", "shared_preferences_platform_interface": "2.4.1", @@ -130,15 +130,15 @@ "url_launcher_linux": "3.2.1", "url_launcher_macos": "3.2.2", "url_launcher_platform_interface": "2.3.2", - "url_launcher_web": "2.3.3", - "url_launcher_windows": "3.1.3", + "url_launcher_web": "2.4.0", + "url_launcher_windows": "3.1.4", "vector_graphics": "1.1.15", - "vector_graphics_codec": "1.1.12", + "vector_graphics_codec": "1.1.13", "vector_graphics_compiler": "1.1.16", "vector_math": "2.1.4", "video_player": "2.9.2", "video_player_android": "2.7.17", - "video_player_avfoundation": "2.6.5", + "video_player_avfoundation": "2.6.7", "video_player_platform_interface": "6.2.3", "video_player_web": "2.3.3", "vm_service": "14.3.0", diff --git a/pkgs/dartpad_shared/lib/model.dart b/pkgs/dartpad_shared/lib/model.dart index d70957e67..5ea6ca218 100644 --- a/pkgs/dartpad_shared/lib/model.dart +++ b/pkgs/dartpad_shared/lib/model.dart @@ -2,6 +2,9 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:json_annotation/json_annotation.dart'; part 'model.g.dart'; @@ -432,3 +435,102 @@ class PackageInfo { Map toJson() => _$PackageInfoToJson(this); } + +@JsonSerializable() +class SuggestFixRequest { + final String errorMessage; + final int? line; + final int? column; + final String source; + + SuggestFixRequest({ + required this.errorMessage, + required this.line, + required this.column, + required this.source, + }); + + factory SuggestFixRequest.fromJson(Map json) => + _$SuggestFixRequestFromJson(json); + + Map toJson() => _$SuggestFixRequestToJson(this); + + @override + String toString() => 'SuggestFixRequest ' + '[$errorMessage] ' + '[${source.substring(0, 10)} (...)'; +} + +enum AppType { dart, flutter } + +@JsonSerializable() +class GenerateCodeRequest { + final AppType appType; + final String prompt; + final List attachments; + + GenerateCodeRequest({ + required this.appType, + required this.prompt, + required this.attachments, + }); + + factory GenerateCodeRequest.fromJson(Map json) => + _$GenerateCodeRequestFromJson(json); + + Map toJson() => _$GenerateCodeRequestToJson(this); + + @override + String toString() => 'GenerateCodeRequest [$prompt]'; +} + +@JsonSerializable() +class UpdateCodeRequest { + final AppType appType; + final String prompt; + final String source; + final List attachments; + + UpdateCodeRequest({ + required this.appType, + required this.prompt, + required this.source, + required this.attachments, + }); + + factory UpdateCodeRequest.fromJson(Map json) => + _$UpdateCodeRequestFromJson(json); + + Map toJson() => _$UpdateCodeRequestToJson(this); + + @override + String toString() => 'UpdateCodeRequest [$prompt]'; +} + +@JsonSerializable() +class Attachment { + Attachment({ + required this.name, + required this.base64EncodedBytes, + required this.mimeType, + }); + + factory Attachment.fromJson(Map json) => + _$AttachmentFromJson(json); + + Map toJson() => _$AttachmentToJson(this); + + Attachment.fromBytes({ + required this.name, + required Uint8List bytes, + required this.mimeType, + }) : base64EncodedBytes = base64Encode(bytes), + _cachedBytes = bytes; + + final String name; + final String base64EncodedBytes; + final String mimeType; + + Uint8List? _cachedBytes; + Uint8List get bytes => _cachedBytes ??= base64Decode(base64EncodedBytes); +} diff --git a/pkgs/dartpad_shared/lib/model.g.dart b/pkgs/dartpad_shared/lib/model.g.dart index 5e8dfacfb..ffdf10f8b 100644 --- a/pkgs/dartpad_shared/lib/model.g.dart +++ b/pkgs/dartpad_shared/lib/model.g.dart @@ -38,10 +38,10 @@ AnalysisIssue _$AnalysisIssueFromJson(Map json) => code: json['code'] as String?, correction: json['correction'] as String?, url: json['url'] as String?, - hasFix: json['hasFix'] as bool?, contextMessages: (json['contextMessages'] as List?) ?.map((e) => DiagnosticMessage.fromJson(e as Map)) .toList(), + hasFix: json['hasFix'] as bool?, ); Map _$AnalysisIssueToJson(AnalysisIssue instance) => @@ -52,8 +52,8 @@ Map _$AnalysisIssueToJson(AnalysisIssue instance) => 'code': instance.code, 'correction': instance.correction, 'url': instance.url, - 'hasFix': instance.hasFix, 'contextMessages': instance.contextMessages, + 'hasFix': instance.hasFix, }; Location _$LocationFromJson(Map json) => Location( @@ -330,3 +330,72 @@ Map _$PackageInfoToJson(PackageInfo instance) => 'version': instance.version, 'supported': instance.supported, }; + +SuggestFixRequest _$SuggestFixRequestFromJson(Map json) => + SuggestFixRequest( + errorMessage: json['errorMessage'] as String, + line: (json['line'] as num?)?.toInt(), + column: (json['column'] as num?)?.toInt(), + source: json['source'] as String, + ); + +Map _$SuggestFixRequestToJson(SuggestFixRequest instance) => + { + 'errorMessage': instance.errorMessage, + 'line': instance.line, + 'column': instance.column, + 'source': instance.source, + }; + +GenerateCodeRequest _$GenerateCodeRequestFromJson(Map json) => + GenerateCodeRequest( + appType: $enumDecode(_$AppTypeEnumMap, json['appType']), + prompt: json['prompt'] as String, + attachments: (json['attachments'] as List) + .map((e) => Attachment.fromJson(e as Map)) + .toList(), + ); + +Map _$GenerateCodeRequestToJson( + GenerateCodeRequest instance) => + { + 'appType': _$AppTypeEnumMap[instance.appType]!, + 'prompt': instance.prompt, + 'attachments': instance.attachments, + }; + +const _$AppTypeEnumMap = { + AppType.dart: 'dart', + AppType.flutter: 'flutter', +}; + +UpdateCodeRequest _$UpdateCodeRequestFromJson(Map json) => + UpdateCodeRequest( + appType: $enumDecode(_$AppTypeEnumMap, json['appType']), + prompt: json['prompt'] as String, + source: json['source'] as String, + attachments: (json['attachments'] as List) + .map((e) => Attachment.fromJson(e as Map)) + .toList(), + ); + +Map _$UpdateCodeRequestToJson(UpdateCodeRequest instance) => + { + 'appType': _$AppTypeEnumMap[instance.appType]!, + 'prompt': instance.prompt, + 'source': instance.source, + 'attachments': instance.attachments, + }; + +Attachment _$AttachmentFromJson(Map json) => Attachment( + name: json['name'] as String, + base64EncodedBytes: json['base64EncodedBytes'] as String, + mimeType: json['mimeType'] as String, + ); + +Map _$AttachmentToJson(Attachment instance) => + { + 'name': instance.name, + 'base64EncodedBytes': instance.base64EncodedBytes, + 'mimeType': instance.mimeType, + }; diff --git a/pkgs/dartpad_shared/lib/services.dart b/pkgs/dartpad_shared/lib/services.dart index 091c38279..41dfa2c5a 100644 --- a/pkgs/dartpad_shared/lib/services.dart +++ b/pkgs/dartpad_shared/lib/services.dart @@ -43,6 +43,15 @@ class ServicesClient { Future openInIdx(OpenInIdxRequest request) => _requestPost('openInIDX', request.toJson(), OpenInIdxResponse.fromJson); + Stream suggestFix(SuggestFixRequest request) => + _requestPostStream('suggestFix', request.toJson()); + + Stream generateCode(GenerateCodeRequest request) => + _requestPostStream('generateCode', request.toJson()); + + Stream updateCode(UpdateCodeRequest request) => + _requestPostStream('updateCode', request.toJson()); + void dispose() => client.close(); Future _requestGet( @@ -84,6 +93,28 @@ class ServicesClient { } } } + + Stream _requestPostStream( + String action, + Map request, + ) async* { + final httpRequest = Request( + 'POST', + Uri.parse('${rootUrl}api/v3/$action'), + ); + httpRequest.encoding = utf8; + httpRequest.headers['Content-Type'] = 'application/json'; + httpRequest.body = json.encode(request); + final response = await client.send(httpRequest); + + if (response.statusCode != 200) throw ApiRequestError(action, ''); + + try { + yield* response.stream.transform(utf8.decoder); + } on FormatException catch (e) { + throw ApiRequestError('$action: $e', ''); + } + } } class ApiRequestError implements Exception { diff --git a/pkgs/dartpad_shared/pubspec.yaml b/pkgs/dartpad_shared/pubspec.yaml index 539445d96..afad646df 100644 --- a/pkgs/dartpad_shared/pubspec.yaml +++ b/pkgs/dartpad_shared/pubspec.yaml @@ -6,7 +6,7 @@ environment: sdk: ^3.5.0 dependencies: - http: ^1.2.1 + http: ^1.3.0 json_annotation: ^4.9.0 meta: ^1.15.0 diff --git a/pkgs/dartpad_ui/devtools_options.yaml b/pkgs/dartpad_ui/devtools_options.yaml new file mode 100644 index 000000000..fa0b357c4 --- /dev/null +++ b/pkgs/dartpad_ui/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/pkgs/dartpad_ui/lib/console.dart b/pkgs/dartpad_ui/lib/console.dart index be036ea00..ff5478c59 100644 --- a/pkgs/dartpad_ui/lib/console.dart +++ b/pkgs/dartpad_ui/lib/console.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'enable_gen_ai.dart'; +import 'suggest_fix.dart'; import 'theme.dart'; import 'widgets.dart'; @@ -28,7 +30,6 @@ class _ConsoleWidgetState extends State { @override void initState() { super.initState(); - scrollController = ScrollController(); widget.output.addListener(_scrollToEnd); } @@ -38,7 +39,6 @@ class _ConsoleWidgetState extends State { widget.output.removeListener(_scrollToEnd); scrollController?.dispose(); scrollController = null; - super.dispose(); } @@ -61,13 +61,13 @@ class _ConsoleWidgetState extends State { padding: const EdgeInsets.all(denseSpacing), child: ValueListenableBuilder( valueListenable: widget.output, - builder: (context, value, _) => Stack( + builder: (context, consoleOutput, _) => Stack( children: [ SizedBox.expand( child: SingleChildScrollView( controller: scrollController, child: SelectableText( - value, + consoleOutput, maxLines: null, style: GoogleFonts.robotoMono( fontSize: theme.textTheme.bodyMedium?.fontSize, @@ -81,10 +81,25 @@ class _ConsoleWidgetState extends State { mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (genAiEnabled) + MiniIconButton( + icon: Image.asset( + 'gemini_sparkle_192.png', + width: 16, + height: 16, + ), + tooltip: 'Suggest fix', + onPressed: consoleOutput.isEmpty + ? null + : () => suggestFix( + context: context, + errorMessage: consoleOutput, + ), + ), MiniIconButton( - icon: Icons.playlist_remove, + icon: const Icon(Icons.playlist_remove), tooltip: 'Clear console', - onPressed: value.isEmpty ? null : _clearConsole, + onPressed: consoleOutput.isEmpty ? null : _clearConsole, ), ], ), @@ -100,9 +115,16 @@ class _ConsoleWidgetState extends State { } void _scrollToEnd() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - scrollController?.animateTo( - scrollController!.position.maxScrollExtent, + if (!mounted) return; + final controller = scrollController; + if (controller == null) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (!controller.hasClients) return; + + controller.animateTo( + controller.position.maxScrollExtent, duration: animationDelay, curve: animationCurve, ); diff --git a/pkgs/dartpad_ui/lib/editor/editor.dart b/pkgs/dartpad_ui/lib/editor/editor.dart index fee9e9a9b..d3226f083 100644 --- a/pkgs/dartpad_ui/lib/editor/editor.dart +++ b/pkgs/dartpad_ui/lib/editor/editor.dart @@ -10,10 +10,13 @@ import 'dart:ui_web' as ui_web; import 'package:dartpad_shared/services.dart' as services; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:pretty_diff_text/pretty_diff_text.dart'; import 'package:web/web.dart' as web; import '../local_storage.dart'; import '../model.dart'; +import '../utils.dart'; import 'codemirror.dart'; // TODO: implement find / find next @@ -585,3 +588,84 @@ extension SourceChangeExtension on services.SourceChange { }.toJS; } } + +class ReadOnlyEditorWidget extends StatefulWidget { + const ReadOnlyEditorWidget(this.source, {super.key}); + final String source; + + @override + State createState() => _ReadOnlyEditorWidgetState(); +} + +class _ReadOnlyEditorWidgetState extends State { + final _appModel = AppModel()..appReady.value = false; + late final _appServices = AppServices(_appModel, Channel.defaultChannel); + + @override + void initState() { + super.initState(); + _appModel.sourceCodeController.text = widget.source; + } + + @override + void didUpdateWidget(covariant ReadOnlyEditorWidget oldWidget) { + if (widget.source != oldWidget.source) { + _appModel.sourceCodeController.textNoScroll = widget.source; + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _appModel.dispose(); + _appServices.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 500, + child: EditorWidget( + appModel: _appModel, + appServices: _appServices, + ), + ); + } +} + +class ReadOnlyDiffWidget extends StatelessWidget { + const ReadOnlyDiffWidget({ + required this.existingSource, + required this.newSource, + super.key, + }); + + final String existingSource; + final String newSource; + + // NOTE: the focus is needed to enable GeneratingCodeDialog to process + // keyboard shortcuts, e.g. cmd+enter + @override + 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, + ), + diffCleanupType: DiffCleanupType.SEMANTIC, + ), + ), + ), + ); + } +} diff --git a/pkgs/dartpad_ui/lib/enable_gen_ai.dart b/pkgs/dartpad_ui/lib/enable_gen_ai.dart new file mode 100644 index 000000000..90745d60b --- /dev/null +++ b/pkgs/dartpad_ui/lib/enable_gen_ai.dart @@ -0,0 +1,2 @@ +// turn on or off gen-ai features in the client +const bool genAiEnabled = false; diff --git a/pkgs/dartpad_ui/lib/local_storage.dart b/pkgs/dartpad_ui/lib/local_storage.dart index c4b79a343..b53836f5f 100644 --- a/pkgs/dartpad_ui/lib/local_storage.dart +++ b/pkgs/dartpad_ui/lib/local_storage.dart @@ -2,6 +2,8 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:dartpad_shared/model.dart'; + import 'local_storage/stub.dart' if (dart.library.js_util) 'local_storage/web.dart'; @@ -13,4 +15,16 @@ abstract class LocalStorage { void saveUserKeybinding(String keybinding); String? getUserKeybinding(); + + void saveLastCreateCodePrompt(String prompt); + String? getLastCreateCodePrompt(); + + void saveLastCreateCodeAppType(AppType appType); + AppType getLastCreateCodeAppType(); + + void saveLastUpdateCodePrompt(String prompt); + String? getLastUpdateCodePrompt(); + + void saveLastUpdateCodeAppType(AppType appType); + AppType getLastUpdateCodeAppType(); } diff --git a/pkgs/dartpad_ui/lib/local_storage/stub.dart b/pkgs/dartpad_ui/lib/local_storage/stub.dart index eec333aa2..54dfe27fe 100644 --- a/pkgs/dartpad_ui/lib/local_storage/stub.dart +++ b/pkgs/dartpad_ui/lib/local_storage/stub.dart @@ -2,12 +2,18 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:dartpad_shared/model.dart'; + import '../local_storage.dart'; import '../utils.dart'; class LocalStorageImpl extends LocalStorage { String? _code; String? _keyBinding; + String? _lastCreateCodePrompt; + String? _lastUpdateCodePrompt; + AppType _lastCreateCodeAppType = AppType.flutter; + AppType _lastUpdateCodeAppType = AppType.flutter; @override void saveUserCode(String code) => _code = code; @@ -15,9 +21,37 @@ class LocalStorageImpl extends LocalStorage { @override void saveUserKeybinding(String keybinding) => _keyBinding = keybinding; + @override + void saveLastCreateCodePrompt(String prompt) => + _lastCreateCodePrompt = prompt; + + @override + void saveLastUpdateCodePrompt(String prompt) => + _lastUpdateCodePrompt = prompt; + @override String? getUserCode() => _code?.nullIfEmpty; @override String? getUserKeybinding() => _keyBinding?.nullIfEmpty; + + @override + String? getLastCreateCodePrompt() => _lastCreateCodePrompt?.nullIfEmpty; + + @override + String? getLastUpdateCodePrompt() => _lastUpdateCodePrompt?.nullIfEmpty; + + @override + AppType getLastCreateCodeAppType() => _lastCreateCodeAppType; + + @override + AppType getLastUpdateCodeAppType() => _lastUpdateCodeAppType; + + @override + void saveLastCreateCodeAppType(AppType appType) => + _lastCreateCodeAppType = appType; + + @override + void saveLastUpdateCodeAppType(AppType appType) => + _lastUpdateCodeAppType = appType; } diff --git a/pkgs/dartpad_ui/lib/local_storage/web.dart b/pkgs/dartpad_ui/lib/local_storage/web.dart index 48af9603f..f6fa05979 100644 --- a/pkgs/dartpad_ui/lib/local_storage/web.dart +++ b/pkgs/dartpad_ui/lib/local_storage/web.dart @@ -2,13 +2,18 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:dartpad_shared/model.dart'; import 'package:web/web.dart' as web; import '../local_storage.dart'; import '../utils.dart'; -const _userInputKey = 'user_'; +const _userInputKey = 'user_input_'; const _userKeybindingKey = 'user_keybinding_'; +const _lastCreateCodePromptKey = 'last_create_code_prompt_'; +const _lastUpdateCodePromptKey = 'last_update_code_prompt_'; +const _lastCreateCodeAppTypeKey = 'last_create_code_app_type_'; +const _lastUpdateCodeAppTypeKey = 'last_update_code_app_type_'; class LocalStorageImpl extends LocalStorage { @override @@ -26,4 +31,46 @@ class LocalStorageImpl extends LocalStorage { @override String? getUserKeybinding() => web.window.localStorage.getItem(_userKeybindingKey)?.nullIfEmpty; + + @override + void saveLastCreateCodePrompt(String prompt) => + web.window.localStorage.setItem(_lastCreateCodePromptKey, prompt); + + @override + String? getLastCreateCodePrompt() => + web.window.localStorage.getItem(_lastCreateCodePromptKey)?.nullIfEmpty; + + @override + void saveLastUpdateCodePrompt(String prompt) => + web.window.localStorage.setItem(_lastUpdateCodePromptKey, prompt); + + @override + String? getLastUpdateCodePrompt() => + web.window.localStorage.getItem(_lastUpdateCodePromptKey)?.nullIfEmpty; + + @override + AppType getLastCreateCodeAppType() { + final appType = web.window.localStorage.getItem(_lastCreateCodeAppTypeKey); + return AppType.values.firstWhere( + (e) => e.name == appType, + orElse: () => AppType.flutter, + ); + } + + @override + void saveLastCreateCodeAppType(AppType appType) => + web.window.localStorage.setItem(_lastCreateCodeAppTypeKey, appType.name); + + @override + AppType getLastUpdateCodeAppType() { + final appType = web.window.localStorage.getItem(_lastUpdateCodeAppTypeKey); + return AppType.values.firstWhere( + (e) => e.name == appType, + orElse: () => AppType.flutter, + ); + } + + @override + void saveLastUpdateCodeAppType(AppType appType) => + web.window.localStorage.setItem(_lastUpdateCodeAppTypeKey, appType.name); } diff --git a/pkgs/dartpad_ui/lib/main.dart b/pkgs/dartpad_ui/lib/main.dart index 9929c119f..67c02bcd6 100644 --- a/pkgs/dartpad_ui/lib/main.dart +++ b/pkgs/dartpad_ui/lib/main.dart @@ -20,6 +20,7 @@ import 'console.dart'; import 'docs.dart'; import 'editor/editor.dart'; import 'embed.dart'; +import 'enable_gen_ai.dart'; import 'execution/execution.dart'; import 'extensions.dart'; import 'keys.dart' as keys; @@ -28,6 +29,7 @@ import 'model.dart'; import 'problems.dart'; import 'samples.g.dart'; import 'theme.dart'; +import 'utils.dart'; import 'versions.dart'; import 'widgets.dart'; @@ -593,6 +595,14 @@ class DartPadAppBar extends StatelessWidget implements PreferredSizeWidget { const ListSamplesWidget(smallIcon: true), ], + if (genAiEnabled) ...[ + const SizedBox(width: denseSpacing), + GeminiMenu( + generateNewCode: () => _generateNewCode(context), + updateExistingCode: () => _updateExistingCode(context), + ), + ], + const SizedBox(width: defaultSpacing), // Hide the snippet title when the screen width is too small. if (constraints.maxWidth > smallScreenWidth) @@ -637,6 +647,137 @@ class DartPadAppBar extends StatelessWidget implements PreferredSizeWidget { final response = await appServices.services.openInIdx(request); url_launcher.launchUrl(Uri.parse(response.idxUrl)); } + + Future _generateNewCode(BuildContext context) async { + final appModel = Provider.of(context, listen: false); + final appServices = Provider.of(context, listen: false); + final lastPrompt = LocalStorage.instance.getLastCreateCodePrompt(); + 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(), + promptButtons: { + 'to-do app': + 'Generate a Flutter to-do app with add, remove, and complete task functionality', + 'login screen': + 'Generate a Flutter login screen with email and password fields, validation, and a submit button', + 'tic-tac-toe': + 'Generate a Flutter tic-tac-toe game with two players, win detection, and a reset button', + if (lastPrompt != null) 'your last prompt': lastPrompt, + }, + ), + ); + + if (!context.mounted || + promptResponse == null || + promptResponse.prompt.isEmpty) { + return; + } + + LocalStorage.instance.saveLastCreateCodeAppType(promptResponse.appType); + LocalStorage.instance.saveLastCreateCodePrompt(promptResponse.prompt); + + try { + final stream = appServices.generateCode( + GenerateCodeRequest( + appType: promptResponse.appType, + prompt: promptResponse.prompt, + attachments: promptResponse.attachments, + ), + ); + + final generateResponse = await showDialog( + context: context, + builder: (context) => GeneratingCodeDialog( + stream: stream, + title: 'Generating New Code', + ), + ); + + if (!context.mounted || + generateResponse == null || + generateResponse.source.isEmpty) { + return; + } + + appModel.sourceCodeController.textNoScroll = generateResponse.source; + appServices.editorService!.focus(); + + if (generateResponse.runNow) appServices.performCompileAndRun(); + } catch (error) { + appModel.editorStatus.showToast('Error generating code'); + appModel.appendLineToConsole('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: LocalStorage.instance.getLastUpdateCodeAppType(), + promptButtons: { + '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', + 'pink': + 'Make the app pink by changing the color scheme to use a rich, pink color palette', + if (lastPrompt != null) 'your last prompt': lastPrompt, + }, + ), + ); + + if (!context.mounted || + promptResponse == null || + promptResponse.prompt.isEmpty) { + return; + } + + LocalStorage.instance.saveLastUpdateCodeAppType(promptResponse.appType); + 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.source.isEmpty) { + return; + } + + appModel.sourceCodeController.textNoScroll = generateResponse.source; + appServices.editorService!.focus(); + + if (generateResponse.runNow) appServices.performCompileAndRun(); + } catch (error) { + appModel.editorStatus.showToast('Error updating code'); + appModel.appendLineToConsole('Updating code issue: $error'); + } + } } class EditorWithButtons extends StatelessWidget { @@ -684,7 +825,7 @@ class EditorWithButtons extends StatelessWidget { builder: (_, bool value, __) { return PointerInterceptor( child: MiniIconButton( - icon: Icons.help_outline, + icon: const Icon(Icons.help_outline), tooltip: 'Show docs', // small: true, onPressed: @@ -700,7 +841,7 @@ class EditorWithButtons extends StatelessWidget { builder: (_, bool value, __) { return PointerInterceptor( child: MiniIconButton( - icon: Icons.format_align_left, + icon: const Icon(Icons.format_align_left), tooltip: 'Format', small: true, onPressed: value ? null : onFormat, @@ -1161,6 +1302,56 @@ class ContinueInMenu extends StatelessWidget { } } +class GeminiMenu extends StatelessWidget { + const GeminiMenu({ + required this.generateNewCode, + required this.updateExistingCode, + super.key, + }); + + final VoidCallback generateNewCode; + final VoidCallback updateExistingCode; + + @override + Widget build(BuildContext context) { + final image = Image.asset( + 'gemini_sparkle_192.png', + width: 24, + height: 24, + ); + + return MenuAnchor( + builder: (context, MenuController controller, Widget? child) { + return TextButton.icon( + onPressed: () => controller.toggleMenuState(), + icon: image, + label: const Text('Gemini'), + ); + }, + menuChildren: [ + ...[ + MenuItemButton( + leadingIcon: image, + onPressed: generateNewCode, + child: const Padding( + padding: EdgeInsets.only(right: 32), + child: Text('Generate Code'), + ), + ), + MenuItemButton( + leadingIcon: image, + onPressed: updateExistingCode, + child: const Padding( + padding: EdgeInsets.only(right: 32), + child: Text('Update Code'), + ), + ), + ].map((widget) => PointerInterceptor(child: widget)) + ], + ); + } +} + class KeyBindingsTable extends StatelessWidget { final List<(String, List)> bindings; final AppModel appModel; diff --git a/pkgs/dartpad_ui/lib/model.dart b/pkgs/dartpad_ui/lib/model.dart index efb915fc0..34f98e1d7 100644 --- a/pkgs/dartpad_ui/lib/model.dart +++ b/pkgs/dartpad_ui/lib/model.dart @@ -8,7 +8,7 @@ import 'package:collection/collection.dart'; import 'package:dartpad_shared/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; +import 'package:http/http.dart'; import 'flutter_samples.dart'; import 'gists.dart'; @@ -137,7 +137,7 @@ class AppServices { final AppModel appModel; final ValueNotifier _channel = ValueNotifier(Channel.defaultChannel); - final http.Client _httpClient = http.Client(); + final Client _httpClient = Client(); late ServicesClient services; ExecutionService? _executionService; @@ -356,6 +356,18 @@ class AppServices { } } + Stream suggestFix(SuggestFixRequest request) { + return services.suggestFix(request); + } + + Stream generateCode(GenerateCodeRequest request) { + return services.generateCode(request); + } + + Stream updateCode(UpdateCodeRequest request) { + return services.updateCode(request); + } + Future _compileDDC(CompileRequest request) async { try { appModel.compilingBusy.value = true; @@ -494,3 +506,25 @@ class SplitDragStateManager { } enum SplitDragState { inactive, active } + +class PromptDialogResponse { + const PromptDialogResponse({ + required this.appType, + required this.prompt, + this.attachments = const [], + }); + + final AppType appType; + final String prompt; + final List attachments; +} + +class GeneratingCodeDialogResponse { + const GeneratingCodeDialogResponse({ + required this.source, + required this.runNow, + }); + + final String source; + final bool runNow; +} diff --git a/pkgs/dartpad_ui/lib/problems.dart b/pkgs/dartpad_ui/lib/problems.dart index c81514b1e..d8baccaa5 100644 --- a/pkgs/dartpad_ui/lib/problems.dart +++ b/pkgs/dartpad_ui/lib/problems.dart @@ -11,7 +11,9 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; +import 'enable_gen_ai.dart'; import 'model.dart'; +import 'suggest_fix.dart'; import 'theme.dart'; const _rowPadding = 2.0; @@ -100,7 +102,27 @@ class ProblemWidget extends StatelessWidget { overflow: TextOverflow.clip, textAlign: TextAlign.end, style: subtleText, - ) + ), + IconButton( + onPressed: () => _quickFixes(context), + tooltip: 'Quick fixes', + icon: const Icon(Icons.lightbulb_outline), + ), + if (genAiEnabled) + IconButton( + onPressed: () => suggestFix( + context: context, + errorMessage: issue.message, + line: issue.location.line, + column: issue.location.column, + ), + tooltip: 'Suggest fix', + icon: Image.asset( + 'gemini_sparkle_192.png', + width: 16, + height: 16, + ), + ), ], ), if (issue.correction case final correction?) ...[ @@ -167,6 +189,21 @@ class ProblemWidget extends StatelessWidget { ), ); } + + void _quickFixes(BuildContext context) { + final appServices = Provider.of(context, listen: false); + + appServices.editorService?.jumpTo(AnalysisIssue( + kind: issue.kind, + message: issue.message, + location: Location( + line: issue.location.line, + column: issue.location.column, + ), + )); + + appServices.editorService?.showQuickFixes(); + } } extension AnalysisIssueExtension on AnalysisIssue { diff --git a/pkgs/dartpad_ui/lib/suggest_fix.dart b/pkgs/dartpad_ui/lib/suggest_fix.dart new file mode 100644 index 000000000..209036bf5 --- /dev/null +++ b/pkgs/dartpad_ui/lib/suggest_fix.dart @@ -0,0 +1,56 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:dartpad_shared/model.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'model.dart'; +import 'utils.dart'; +import 'widgets.dart'; + +Future suggestFix({ + required BuildContext context, + required String errorMessage, + int? line, + int? column, +}) async { + final appModel = Provider.of(context, listen: false); + final appServices = Provider.of(context, listen: false); + final existingSource = appModel.sourceCodeController.text; + + try { + final stream = appServices.suggestFix( + SuggestFixRequest( + 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, + ), + ); + + if (!context.mounted || result == null || result.source.isEmpty) return; + + if (result.source == existingSource) { + appModel.editorStatus.showToast('No suggested fix'); + } else { + appModel.editorStatus.showToast('Fix suggested'); + appModel.sourceCodeController.textNoScroll = result.source; + appServices.editorService!.focus(); + if (result.runNow) appServices.performCompileAndRun(); + } + } catch (error) { + appModel.editorStatus.showToast('Error suggesting fix'); + appModel.appendLineToConsole('Suggesting fix issue: $error'); + } +} diff --git a/pkgs/dartpad_ui/lib/utils.dart b/pkgs/dartpad_ui/lib/utils.dart index c008f888e..e6cbe25fa 100644 --- a/pkgs/dartpad_ui/lib/utils.dart +++ b/pkgs/dartpad_ui/lib/utils.dart @@ -188,3 +188,13 @@ enum MessageState { extension StringUtils on String { String? get nullIfEmpty => isEmpty ? null : this; } + +extension TextEditingControllerExtensions on TextEditingController { + // set the source w/o scrolling to the top + set textNoScroll(String text) { + value = TextEditingValue( + text: text, + selection: const TextSelection.collapsed(offset: 0), + ); + } +} diff --git a/pkgs/dartpad_ui/lib/widgets.dart b/pkgs/dartpad_ui/lib/widgets.dart index 2b86324eb..7cc7b22d1 100644 --- a/pkgs/dartpad_ui/lib/widgets.dart +++ b/pkgs/dartpad_ui/lib/widgets.dart @@ -2,10 +2,20 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'dart:async'; + +import 'package:dartpad_shared/model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; +import 'editor/editor.dart'; +import 'model.dart'; import 'theme.dart'; import 'utils.dart'; @@ -52,7 +62,7 @@ class _HyperlinkState extends State { } class MiniIconButton extends StatelessWidget { - final IconData icon; + final Widget icon; final String tooltip; final bool small; final VoidCallback? onPressed; @@ -83,7 +93,7 @@ class MiniIconButton extends StatelessWidget { shape: const WidgetStatePropertyAll(CircleBorder()), backgroundColor: WidgetStatePropertyAll(backgroundColor), ), - icon: Icon(icon), + icon: icon, iconSize: small ? 16 : smallIconSize, splashRadius: small ? 16 : smallIconSize, visualDensity: VisualDensity.compact, @@ -208,6 +218,13 @@ class MediumDialog extends StatelessWidget { return PointerInterceptor( child: AlertDialog( backgroundColor: theme.scaffoldBackgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: BorderSide( + color: theme.colorScheme.outline, + width: 1, + ), + ), title: Text(title, maxLines: 1), contentTextStyle: theme.textTheme.bodyMedium, contentPadding: const EdgeInsets.fromLTRB(24, defaultSpacing, 24, 8), @@ -270,3 +287,499 @@ final class Logo extends StatelessWidget { return Image.asset(assetPath, width: width); } } + +bool get _nonMac => defaultTargetPlatform != TargetPlatform.macOS; +bool get _mac => defaultTargetPlatform == TargetPlatform.macOS; + +class PromptDialog extends StatefulWidget { + const PromptDialog({ + required this.title, + required this.hint, + required this.promptButtons, + required this.initialAppType, + super.key, + }); + + final String title; + final String hint; + final Map promptButtons; + final AppType initialAppType; + + @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(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return PointerInterceptor( + child: AlertDialog( + backgroundColor: theme.scaffoldBackgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: BorderSide(color: theme.colorScheme.outline), + ), + title: Text(widget.title), + contentTextStyle: theme.textTheme.bodyMedium, + contentPadding: const EdgeInsets.fromLTRB(24, defaultSpacing, 24, 8), + content: SizedBox( + width: 700, + child: CallbackShortcuts( + bindings: { + SingleActivator( + LogicalKeyboardKey.enter, + meta: _mac, + control: _nonMac, + ): () { + if (_controller.text.isNotEmpty) _onGenerate(); + }, + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: OverflowBar( + spacing: 8, + alignment: MainAxisAlignment.start, + children: [ + for (final entry in widget.promptButtons.entries) + TextButton( + onPressed: () { + _controller.text = entry.value; + _focusNode.requestFocus(); + }, + child: 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), + SizedBox( + height: 128, + child: EditableImageList( + attachments: _attachments, + onRemove: _removeAttachment, + onAdd: _addAttachment, + maxAttachments: 3, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, controller, _) => TextButton( + onPressed: controller.text.isEmpty ? null : _onGenerate, + child: Text( + 'Generate', + style: TextStyle( + color: controller.text.isEmpty ? theme.disabledColor : null, + ), + ), + ), + ), + ], + ), + ); + } + + void _onGenerate() { + assert(_controller.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', + ), + ), + ); + } +} + +class GeneratingCodeDialog extends StatefulWidget { + const GeneratingCodeDialog({ + required this.stream, + required this.title, + this.existingSource, + super.key, + }); + + final Stream stream; + final String title; + final String? existingSource; + @override + State createState() => _GeneratingCodeDialogState(); +} + +class _GeneratingCodeDialogState extends State { + final _generatedCode = StringBuffer(); + 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; + }), + ); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + @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: widget.existingSource == null + ? ReadOnlyEditorWidget(_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, + ), + ], + ), + ), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: _done ? _onAccept : null, + child: Text( + 'Accept', + style: TextStyle( + color: !_done ? theme.disabledColor : null, + ), + ), + ), + TextButton( + onPressed: _done ? _onAcceptAndRun : null, + child: Text( + 'Accept & Run', + style: TextStyle( + color: !_done ? theme.disabledColor : null, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + void _onAccept() { + assert(_done); + Navigator.pop( + context, + GeneratingCodeDialogResponse( + source: _generatedCode.toString(), runNow: false), + ); + } + + void _onAcceptAndRun() { + assert(_done); + Navigator.pop( + context, + GeneratingCodeDialogResponse( + source: _generatedCode.toString(), runNow: true), + ); + } +} + +class EditableImageList extends StatelessWidget { + final List attachments; + final void Function(int index) onRemove; + final void Function() onAdd; + final int maxAttachments; + + const EditableImageList({ + super.key, + required this.attachments, + required this.onRemove, + required this.onAdd, + required this.maxAttachments, + }); + + @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) { + return _AddImageWidget( + onAdd: attachments.length < maxAttachments ? onAdd : null, + ); + } else { + final attachmentIndex = index - 1; + return _ImageAttachmentWidget( + attachment: attachments[attachmentIndex], + onRemove: () => onRemove(attachmentIndex), + ); + } + }, + ); + } +} + +class _ImageAttachmentWidget extends StatelessWidget { + final Attachment attachment; + final void Function() onRemove; + + const _ImageAttachmentWidget({ + required this.attachment, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + return GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Dialog( + backgroundColor: Colors.transparent, + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: MemoryImage(attachment.bytes), + fit: BoxFit.contain, + ), + ), + ), + ), + ); + }, + ); + }, + child: Container( + margin: const EdgeInsets.all(8), + width: 128, + height: 128, + decoration: BoxDecoration( + image: DecorationImage( + image: MemoryImage(attachment.bytes), + fit: BoxFit.contain, + ), + ), + ), + ), + 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, + ), + ), + ), + ), + ), + ], + ); + } +} + +class _AddImageWidget extends StatelessWidget { + final void Function()? onAdd; + const _AddImageWidget({required this.onAdd}); + + @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(), + ), + child: const Center( + child: Text( + 'Add\nImage', + textAlign: TextAlign.center, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/pkgs/dartpad_ui/pubspec.yaml b/pkgs/dartpad_ui/pubspec.yaml index 563e01c55..e11f37757 100644 --- a/pkgs/dartpad_ui/pubspec.yaml +++ b/pkgs/dartpad_ui/pubspec.yaml @@ -15,9 +15,12 @@ dependencies: sdk: flutter go_router: ^14.1.4 google_fonts: ^6.2.1 - http: ^1.2.1 + http: ^1.3.0 + image_picker: ^1.1.2 json_annotation: ^4.9.0 + mime: ^2.0.0 pointer_interceptor: ^0.10.1 + pretty_diff_text: ^2.0.0 provider: ^6.1.2 split_view: ^3.2.1 url_launcher: ^6.3.0 diff --git a/pkgs/samples/devtools_options.yaml b/pkgs/samples/devtools_options.yaml new file mode 100644 index 000000000..fa0b357c4 --- /dev/null +++ b/pkgs/samples/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: