diff --git a/pkgs/dart_services/tool/dependencies/pub_dependencies_beta.json b/pkgs/dart_services/tool/dependencies/pub_dependencies_beta.json index fe94f8a5c..f72f802f9 100644 --- a/pkgs/dart_services/tool/dependencies/pub_dependencies_beta.json +++ b/pkgs/dart_services/tool/dependencies/pub_dependencies_beta.json @@ -46,7 +46,7 @@ "glob": "2.1.2", "go_router": "13.2.0", "google_fonts": "6.1.0", - "google_generative_ai": "0.1.0", + "google_generative_ai": "0.2.0", "hooks_riverpod": "2.4.10", "html": "0.15.4", "http": "1.2.0", diff --git a/pkgs/dart_services/tool/dependencies/pub_dependencies_main.json b/pkgs/dart_services/tool/dependencies/pub_dependencies_main.json index 8e3b5fd1d..aaefa33bd 100644 --- a/pkgs/dart_services/tool/dependencies/pub_dependencies_main.json +++ b/pkgs/dart_services/tool/dependencies/pub_dependencies_main.json @@ -46,7 +46,7 @@ "glob": "2.1.2", "go_router": "13.2.0", "google_fonts": "6.1.0", - "google_generative_ai": "0.1.0", + "google_generative_ai": "0.2.0", "hooks_riverpod": "2.4.10", "html": "0.15.4", "http": "1.2.0", diff --git a/pkgs/dart_services/tool/dependencies/pub_dependencies_stable.json b/pkgs/dart_services/tool/dependencies/pub_dependencies_stable.json index d2a27ee96..b5ca9b506 100644 --- a/pkgs/dart_services/tool/dependencies/pub_dependencies_stable.json +++ b/pkgs/dart_services/tool/dependencies/pub_dependencies_stable.json @@ -46,7 +46,7 @@ "glob": "2.1.2", "go_router": "13.2.0", "google_fonts": "6.1.0", - "google_generative_ai": "0.1.0", + "google_generative_ai": "0.2.0", "hooks_riverpod": "2.4.10", "html": "0.15.4", "http": "1.2.0", diff --git a/pkgs/samples/README.md b/pkgs/samples/README.md index 12b54562b..bd28f3df7 100644 --- a/pkgs/samples/README.md +++ b/pkgs/samples/README.md @@ -9,6 +9,8 @@ Sample code snippets for DartPad. | --- | --- | --- | --- | | Dart | Fibonacci | [fibonacci.dart](lib/fibonacci.dart) | `fibonacci` | | Dart | Hello world | [hello_world.dart](lib/hello_world.dart) | `hello-world` | +| Ecosystem | Flame game | [brick_breaker.dart](lib/brick_breaker.dart) | `flame-game` | +| Ecosystem | Google AI SDK | [google_ai.dart](lib/google_ai.dart) | `google-ai-sdk` | | Flutter | Counter | [main.dart](lib/main.dart) | `counter` | | Flutter | Sunflower | [sunflower.dart](lib/sunflower.dart) | `sunflower` | diff --git a/pkgs/samples/lib/brick_breaker.dart b/pkgs/samples/lib/brick_breaker.dart new file mode 100644 index 000000000..5437b0c54 --- /dev/null +++ b/pkgs/samples/lib/brick_breaker.dart @@ -0,0 +1,326 @@ +// Copyright 2024 the Dart project authors. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file. + +/// A simplified brick-breaker game, +/// built using the Flame game engine for Flutter. +/// +/// To learn how to build a more complete version of this game yourself, +/// check out the codelab at https://docs.flutter.dev/brick-breaker. +library; + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() { + runApp(const GameApp()); +} + +class GameApp extends StatefulWidget { + const GameApp({super.key}); + + @override + State createState() => _GameAppState(); +} + +class _GameAppState extends State { + late final BrickBreaker game; + + @override + void initState() { + super.initState(); + game = BrickBreaker(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xffa9d6e5), + Color(0xfff2e8cf), + ], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: FittedBox( + child: SizedBox( + width: gameWidth, + height: gameHeight, + child: GameWidget( + game: game, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class BrickBreaker extends FlameGame + with HasCollisionDetection, KeyboardEvents, TapDetector { + BrickBreaker() + : super( + camera: CameraComponent.withFixedResolution( + width: gameWidth, height: gameHeight)); + + final rand = math.Random(); + double get width => size.x; + double get height => size.y; + + @override + FutureOr onLoad() async { + super.onLoad(); + camera.viewfinder.anchor = Anchor.topLeft; + world.add(PlayArea()); + startGame(); + } + + void startGame() { + world.removeAll(world.children.query()); + world.removeAll(world.children.query()); + world.removeAll(world.children.query()); + + world.add(Ball( + difficultyModifier: difficultyModifier, + radius: ballRadius, + position: size / 2, + velocity: + Vector2((rand.nextDouble() - 0.5) * width, height * 0.2).normalized() + ..scale(height / 4), + )); + + world.add(Bat( + size: Vector2(batWidth, batHeight), + cornerRadius: const Radius.circular(ballRadius / 2), + position: Vector2(width / 2, height * 0.95), + )); + + world.addAll([ + for (var i = 0; i < brickColors.length; i++) + for (var j = 1; j <= 5; j++) + Brick( + Vector2( + (i + 0.5) * brickWidth + (i + 1) * brickGutter, + (j + 2.0) * brickHeight + j * brickGutter, + ), + brickColors[i], + ), + ]); + } + + @override + void onTap() { + super.onTap(); + startGame(); + } + + @override + KeyEventResult onKeyEvent( + RawKeyEvent event, Set keysPressed) { + super.onKeyEvent(event, keysPressed); + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowLeft: + case LogicalKeyboardKey.keyA: + world.children.query().first.moveBy(-batStep); + case LogicalKeyboardKey.keyD: + case LogicalKeyboardKey.arrowRight: + world.children.query().first.moveBy(batStep); + case LogicalKeyboardKey.space: + case LogicalKeyboardKey.enter: + startGame(); + } + return KeyEventResult.handled; + } + + @override + Color backgroundColor() => const Color(0xfff2e8cf); +} + +class Ball extends CircleComponent + with CollisionCallbacks, HasGameReference { + Ball({ + required this.velocity, + required super.position, + required double radius, + required this.difficultyModifier, + }) : super( + radius: radius, + anchor: Anchor.center, + paint: Paint() + ..color = const Color(0xff1e6091) + ..style = PaintingStyle.fill, + children: [CircleHitbox()]); + + final Vector2 velocity; + final double difficultyModifier; + + @override + void update(double dt) { + super.update(dt); + position += velocity * dt; + } + + @override + void onCollisionStart( + Set intersectionPoints, PositionComponent other) { + super.onCollisionStart(intersectionPoints, other); + if (other is PlayArea) { + if (intersectionPoints.first.y <= 0) { + velocity.y = -velocity.y; + } else if (intersectionPoints.first.x <= 0) { + velocity.x = -velocity.x; + } else if (intersectionPoints.first.x >= game.width) { + velocity.x = -velocity.x; + } else if (intersectionPoints.first.y >= game.height) { + add(RemoveEffect( + delay: 0.35, + onComplete: () { + game.startGame(); + }, + )); + } + } else if (other is Bat) { + velocity.y = -velocity.y; + velocity.x = velocity.x + + (position.x - other.position.x) / other.size.x * game.width * 0.3; + } else if (other is Brick) { + if (position.y < other.position.y - other.size.y / 2) { + velocity.y = -velocity.y; + } else if (position.y > other.position.y + other.size.y / 2) { + velocity.y = -velocity.y; + } else if (position.x < other.position.x) { + velocity.x = -velocity.x; + } else if (position.x > other.position.x) { + velocity.x = -velocity.x; + } + velocity.setFrom(velocity * difficultyModifier); + } + } +} + +class Bat extends PositionComponent + with DragCallbacks, HasGameReference { + Bat({ + required this.cornerRadius, + required super.position, + required super.size, + }) : super(anchor: Anchor.center, children: [RectangleHitbox()]); + + final Radius cornerRadius; + + final _paint = Paint() + ..color = const Color(0xff1e6091) + ..style = PaintingStyle.fill; + + @override + void render(Canvas canvas) { + super.render(canvas); + canvas.drawRRect( + RRect.fromRectAndRadius( + Offset.zero & size.toSize(), + cornerRadius, + ), + _paint, + ); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + if (isRemoved) return; + super.onDragUpdate(event); + position.x = (position.x + event.localDelta.x) + .clamp(width / 2, game.width - width / 2); + } + + void moveBy(double dx) { + add(MoveToEffect( + Vector2( + (position.x + dx).clamp(width / 2, game.width - width / 2), + position.y, + ), + EffectController(duration: 0.1), + )); + } +} + +class Brick extends RectangleComponent + with CollisionCallbacks, HasGameReference { + Brick(Vector2 position, Color color) + : super( + position: position, + size: Vector2(brickWidth, brickHeight), + anchor: Anchor.center, + paint: Paint() + ..color = color + ..style = PaintingStyle.fill, + children: [RectangleHitbox()], + ); + + @override + void onCollisionStart( + Set intersectionPoints, PositionComponent other) { + super.onCollisionStart(intersectionPoints, other); + removeFromParent(); + + if (game.world.children.query().length == 1) { + game.startGame(); + } + } +} + +class PlayArea extends RectangleComponent with HasGameReference { + PlayArea() : super(children: [RectangleHitbox()]); + + @override + Future onLoad() async { + super.onLoad(); + size = Vector2(game.width, game.height); + } +} + +const brickColors = [ + Color(0xfff94144), + Color(0xfff3722c), + Color(0xfff8961e), + Color(0xfff9844a), + Color(0xfff9c74f), + Color(0xff90be6d), + Color(0xff43aa8b), + Color(0xff4d908e), + Color(0xff277da1), + Color(0xff577590), +]; + +const gameWidth = 820.0; +const gameHeight = 1600.0; +const ballRadius = gameWidth * 0.02; +const batWidth = gameWidth * 0.2; +const batHeight = ballRadius * 2; +const batStep = gameWidth * 0.05; +const brickGutter = gameWidth * 0.015; +final brickWidth = + (gameWidth - (brickGutter * (brickColors.length + 1))) / brickColors.length; +const brickHeight = gameHeight * 0.03; +const difficultyModifier = 1.05; diff --git a/pkgs/samples/lib/google_ai.dart b/pkgs/samples/lib/google_ai.dart new file mode 100644 index 000000000..ff783d240 --- /dev/null +++ b/pkgs/samples/lib/google_ai.dart @@ -0,0 +1,339 @@ +// Copyright 2024 the Dart project authors. 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:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:url_launcher/link.dart'; + +void main() { + runApp(const GenerativeAISample()); +} + +class GenerativeAISample extends StatelessWidget { + const GenerativeAISample({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Flutter + Generative AI', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + brightness: Brightness.dark, + seedColor: const Color.fromARGB(255, 171, 222, 244), + ), + useMaterial3: true, + ), + home: const ChatScreen(title: 'Flutter + Generative AI'), + ); + } +} + +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key, required this.title}); + + final String title; + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + String? apiKey; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: switch (apiKey) { + final providedKey? => ChatWidget(apiKey: providedKey), + _ => ApiKeyWidget(onSubmitted: (key) { + setState(() => apiKey = key); + }), + }, + ); + } +} + +class ApiKeyWidget extends StatelessWidget { + ApiKeyWidget({required this.onSubmitted, super.key}); + + final ValueChanged onSubmitted; + final TextEditingController _textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'To use the Gemini API, you\'ll need an API key. ' + 'If you don\'t already have one, ' + 'create a key in Google AI Studio.', + ), + const SizedBox(height: 8), + Link( + uri: Uri.https('makersuite.google.com', '/app/apikey'), + target: LinkTarget.blank, + builder: (context, followLink) => TextButton( + onPressed: followLink, + child: const Text('Get an API Key'), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + decoration: + textFieldDecoration(context, 'Enter your API key'), + controller: _textController, + onSubmitted: (value) { + onSubmitted(value); + }, + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () { + onSubmitted(_textController.value.text); + }, + child: const Text('Submit'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class ChatWidget extends StatefulWidget { + const ChatWidget({required this.apiKey, super.key}); + + final String apiKey; + + @override + State createState() => _ChatWidgetState(); +} + +class _ChatWidgetState extends State { + late final GenerativeModel _model; + late final ChatSession _chat; + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(debugLabel: 'TextField'); + bool _loading = false; + + @override + void initState() { + super.initState(); + _model = GenerativeModel( + model: 'gemini-pro', + apiKey: widget.apiKey, + ); + _chat = _model.startChat(); + } + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration( + milliseconds: 750, + ), + curve: Curves.easeOutCirc, + ), + ); + } + + @override + Widget build(BuildContext context) { + final history = _chat.history.toList(); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + final content = history[idx]; + final text = content.parts + .whereType() + .map((e) => e.text) + .join(''); + return MessageWidget( + text: text, + isFromUser: content.role == 'user', + ); + }, + itemCount: history.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _textFieldFocus, + decoration: + textFieldDecoration(context, 'Enter a prompt...'), + controller: _textController, + onSubmitted: (String value) { + _sendChatMessage(value); + }, + ), + ), + const SizedBox.square(dimension: 15), + if (!_loading) + IconButton( + onPressed: () async { + _sendChatMessage(_textController.text); + }, + icon: Icon( + Icons.send, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + const CircularProgressIndicator(), + ], + ), + ), + ], + ), + ); + } + + Future _sendChatMessage(String message) async { + setState(() { + _loading = true; + }); + + try { + final response = await _chat.sendMessage( + Content.text(message), + ); + final text = response.text; + + if (text == null) { + _showError('Empty response.'); + return; + } else { + setState(() { + _loading = false; + _scrollDown(); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _loading = false; + }); + } finally { + _textController.clear(); + setState(() { + _loading = false; + }); + _textFieldFocus.requestFocus(); + } + } + + void _showError(String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Something went wrong'), + content: SingleChildScrollView( + child: Text(message), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ) + ], + ); + }, + ); + } +} + +class MessageWidget extends StatelessWidget { + const MessageWidget({ + super.key, + required this.text, + required this.isFromUser, + }); + + final String text; + final bool isFromUser; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: + isFromUser ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + Flexible( + child: Container( + constraints: const BoxConstraints(maxWidth: 480), + decoration: BoxDecoration( + color: isFromUser + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(18), + ), + padding: const EdgeInsets.symmetric( + vertical: 15, + horizontal: 20, + ), + margin: const EdgeInsets.only(bottom: 8), + child: MarkdownBody(data: text), + ), + ), + ], + ); + } +} + +InputDecoration textFieldDecoration(BuildContext context, String hintText) => + InputDecoration( + contentPadding: const EdgeInsets.all(15), + hintText: hintText, + border: OutlineInputBorder( + borderRadius: const BorderRadius.all( + Radius.circular(14), + ), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all( + Radius.circular(14), + ), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + ), + ), + ); diff --git a/pkgs/samples/lib/samples.json b/pkgs/samples/lib/samples.json index 4547118d2..811cd818e 100644 --- a/pkgs/samples/lib/samples.json +++ b/pkgs/samples/lib/samples.json @@ -6,23 +6,39 @@ "samples": [ { "category": "Dart", + "icon": "dart", "name": "Hello world", "path": "lib/hello_world.dart" }, { "category": "Dart", + "icon": "dart", "name": "Fibonacci", "path": "lib/fibonacci.dart" }, { "category": "Flutter", + "icon": "flutter", "name": "Counter", "path": "lib/main.dart" }, { "category": "Flutter", + "icon": "flutter", "name": "Sunflower", "path": "lib/sunflower.dart" + }, + { + "category": "Ecosystem", + "icon": "gemini", + "name": "Google AI SDK", + "path": "lib/google_ai.dart" + }, + { + "category": "Ecosystem", + "icon": "flame", + "name": "Flame game", + "path": "lib/brick_breaker.dart" } ] } \ No newline at end of file diff --git a/pkgs/samples/pubspec.yaml b/pkgs/samples/pubspec.yaml index 414e9b9bf..a0c4ba60a 100644 --- a/pkgs/samples/pubspec.yaml +++ b/pkgs/samples/pubspec.yaml @@ -6,8 +6,12 @@ environment: sdk: ^3.2.0 dependencies: + flame: ^1.15.0 flutter: sdk: flutter + flutter_markdown: ^0.6.19 + google_generative_ai: ^0.2.0 + url_launcher: ^6.2.4 dev_dependencies: args: ^2.4.0 diff --git a/pkgs/samples/tool/samples.dart b/pkgs/samples/tool/samples.dart index a291b1b87..69ab7b89c 100644 --- a/pkgs/samples/tool/samples.dart +++ b/pkgs/samples/tool/samples.dart @@ -34,6 +34,7 @@ void main(List args) { const Set categories = { 'Dart', 'Flutter', + 'Ecosystem', }; class Samples { @@ -114,7 +115,7 @@ class Samples { stderr.writeln('Generated sample files not up-to-date.'); stderr.writeln('Re-generate by running:'); stderr.writeln(''); - stderr.writeln(' dart tool/samples.dart'); + stderr.writeln(' dart run tool/samples.dart'); stderr.writeln(''); exit(1); } @@ -154,12 +155,14 @@ import 'package:collection/collection.dart'; class Sample { final String category; + final String icon; final String name; final String id; final String source; - Sample({ + const Sample({ required this.category, + required this.icon, required this.name, required this.id, required this.source, @@ -171,12 +174,12 @@ class Sample { String toString() => '[\$category] \$name (\$id)'; } -class Samples { - static final List all = [ +abstract final class Samples { + static const List all = [ ${samples.map((s) => s.sourceId).join(',\n ')}, ]; - static final Map> categories = { + static const Map> categories = { ${categories.map((category) => _mapForCategory(category)).join(',\n ')}, }; @@ -187,7 +190,7 @@ class Samples { '''); - buf.writeln('Map _defaults = {'); + buf.writeln('const Map _defaults = {'); for (final entry in defaults.entries) { final source = File(entry.value).readAsStringSync().trimRight(); @@ -211,12 +214,14 @@ class Samples { class Sample implements Comparable { final String category; + final String icon; final String name; final String id; final String path; Sample({ required this.category, + required this.icon, required this.name, required this.id, required this.path, @@ -225,6 +230,7 @@ class Sample implements Comparable { factory Sample.fromJson(Map json) { return Sample( category: json['category'], + icon: json['icon'], name: json['name'], id: (json['id'] as String?) ?? _idFromName(json['name']), path: json['path'], @@ -246,8 +252,9 @@ class Sample implements Comparable { String get sourceDef { return ''' -final $sourceId = Sample( +const $sourceId = Sample( category: '$category', + icon: '$icon', name: '$name', id: '$id', source: r\'\'\' diff --git a/pkgs/sketch_pad/assets/dart_logo_128.png b/pkgs/sketch_pad/assets/dart_logo_128.png deleted file mode 100644 index 7ee61fd4d..000000000 Binary files a/pkgs/sketch_pad/assets/dart_logo_128.png and /dev/null differ diff --git a/pkgs/sketch_pad/assets/dart_logo_192.png b/pkgs/sketch_pad/assets/dart_logo_192.png new file mode 100644 index 000000000..fb1d23f8a Binary files /dev/null and b/pkgs/sketch_pad/assets/dart_logo_192.png differ diff --git a/pkgs/sketch_pad/assets/flame_logo_192.png b/pkgs/sketch_pad/assets/flame_logo_192.png new file mode 100644 index 000000000..396099820 Binary files /dev/null and b/pkgs/sketch_pad/assets/flame_logo_192.png differ diff --git a/pkgs/sketch_pad/assets/gemini_sparkle_192.png b/pkgs/sketch_pad/assets/gemini_sparkle_192.png new file mode 100644 index 000000000..07e81893b Binary files /dev/null and b/pkgs/sketch_pad/assets/gemini_sparkle_192.png differ diff --git a/pkgs/sketch_pad/lib/main.dart b/pkgs/sketch_pad/lib/main.dart index 84996f7f3..ac61da52a 100644 --- a/pkgs/sketch_pad/lib/main.dart +++ b/pkgs/sketch_pad/lib/main.dart @@ -280,7 +280,7 @@ class _DartPadMainPageState extends State { height: toolbarItemHeight, child: Row( children: [ - dartLogo(width: 32), + const Logo(width: 32, type: 'dart'), const SizedBox(width: denseSpacing), Text(appName, style: TextStyle( @@ -684,13 +684,17 @@ class SectionWidget extends StatelessWidget { class NewSnippetWidget extends StatelessWidget { final AppServices appServices; - static final _menuItems = [ + static const _menuItems = [ ( label: 'Dart snippet', - icon: dartLogo(), + icon: Logo(type: 'dart'), kind: 'dart', ), - (label: 'Flutter snippet', icon: flutterLogo(), kind: 'flutter'), + ( + label: 'Flutter snippet', + icon: Logo(type: 'flutter'), + kind: 'flutter', + ), ]; const NewSnippetWidget({ @@ -714,7 +718,7 @@ class NewSnippetWidget extends StatelessWidget { child: MenuItemButton( leadingIcon: item.icon, child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 32, 0), + padding: const EdgeInsets.only(right: 32), child: Text(item.label), ), onPressed: () => appServices.resetTo(type: item.kind), @@ -743,10 +747,9 @@ class ListSamplesWidget extends StatelessWidget { } List _buildMenuItems(BuildContext context) { - final categories = Samples.categories.keys; - final menuItems = [ - for (final category in categories) ...[ + for (final MapEntry(key: category, value: samples) + in Samples.categories.entries) ...[ MenuItemButton( onPressed: null, child: Text( @@ -754,13 +757,13 @@ class ListSamplesWidget extends StatelessWidget { style: Theme.of(context).textTheme.bodyLarge, ), ), - for (final sample in Samples.categories[category]!) + for (final sample in samples) MenuItemButton( - leadingIcon: sample.isDart ? dartLogo() : flutterLogo(), + leadingIcon: Logo(type: sample.icon), onPressed: () => GoRouter.of(context).replaceQueryParam('sample', sample.id), child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 32, 0), + padding: const EdgeInsets.only(right: 32), child: Text(sample.name), ), ), diff --git a/pkgs/sketch_pad/lib/samples.g.dart b/pkgs/sketch_pad/lib/samples.g.dart index 27cb03e4a..4f88ac2c7 100644 --- a/pkgs/sketch_pad/lib/samples.g.dart +++ b/pkgs/sketch_pad/lib/samples.g.dart @@ -8,12 +8,14 @@ import 'package:collection/collection.dart'; class Sample { final String category; + final String icon; final String name; final String id; final String source; - Sample({ + const Sample({ required this.category, + required this.icon, required this.name, required this.id, required this.source, @@ -25,15 +27,17 @@ class Sample { String toString() => '[$category] $name ($id)'; } -class Samples { - static final List all = [ +abstract final class Samples { + static const List all = [ _fibonacci, _helloWorld, + _flameGame, + _googleSdk, _counter, _sunflower, ]; - static final Map> categories = { + static const Map> categories = { 'Dart': [ _fibonacci, _helloWorld, @@ -42,6 +46,10 @@ class Samples { _counter, _sunflower, ], + 'Ecosystem': [ + _flameGame, + _googleSdk, + ], }; static Sample? getById(String? id) => all.firstWhereOrNull((s) => s.id == id); @@ -49,7 +57,7 @@ class Samples { static String getDefault({required String type}) => _defaults[type]!; } -Map _defaults = { +const Map _defaults = { 'dart': r''' void main() { for (int i = 0; i < 10; i++) { @@ -82,8 +90,9 @@ class MyApp extends StatelessWidget { ''', }; -final _fibonacci = Sample( +const _fibonacci = Sample( category: 'Dart', + icon: 'dart', name: 'Fibonacci', id: 'fibonacci', source: r''' @@ -104,8 +113,9 @@ int fibonacci(int n) { ''', ); -final _helloWorld = Sample( +const _helloWorld = Sample( category: 'Dart', + icon: 'dart', name: 'Hello world', id: 'hello-world', source: r''' @@ -121,8 +131,692 @@ void main() { ''', ); -final _counter = Sample( +const _flameGame = Sample( + category: 'Ecosystem', + icon: 'flame', + name: 'Flame game', + id: 'flame-game', + source: r''' +// Copyright 2024 the Dart project authors. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file. + +/// A simplified brick-breaker game, +/// built using the Flame game engine for Flutter. +/// +/// To learn how to build a more complete version of this game yourself, +/// check out the codelab at https://docs.flutter.dev/brick-breaker. +library; + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() { + runApp(const GameApp()); +} + +class GameApp extends StatefulWidget { + const GameApp({super.key}); + + @override + State createState() => _GameAppState(); +} + +class _GameAppState extends State { + late final BrickBreaker game; + + @override + void initState() { + super.initState(); + game = BrickBreaker(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xffa9d6e5), + Color(0xfff2e8cf), + ], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: FittedBox( + child: SizedBox( + width: gameWidth, + height: gameHeight, + child: GameWidget( + game: game, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class BrickBreaker extends FlameGame + with HasCollisionDetection, KeyboardEvents, TapDetector { + BrickBreaker() + : super( + camera: CameraComponent.withFixedResolution( + width: gameWidth, height: gameHeight)); + + final rand = math.Random(); + double get width => size.x; + double get height => size.y; + + @override + FutureOr onLoad() async { + super.onLoad(); + camera.viewfinder.anchor = Anchor.topLeft; + world.add(PlayArea()); + startGame(); + } + + void startGame() { + world.removeAll(world.children.query()); + world.removeAll(world.children.query()); + world.removeAll(world.children.query()); + + world.add(Ball( + difficultyModifier: difficultyModifier, + radius: ballRadius, + position: size / 2, + velocity: + Vector2((rand.nextDouble() - 0.5) * width, height * 0.2).normalized() + ..scale(height / 4), + )); + + world.add(Bat( + size: Vector2(batWidth, batHeight), + cornerRadius: const Radius.circular(ballRadius / 2), + position: Vector2(width / 2, height * 0.95), + )); + + world.addAll([ + for (var i = 0; i < brickColors.length; i++) + for (var j = 1; j <= 5; j++) + Brick( + Vector2( + (i + 0.5) * brickWidth + (i + 1) * brickGutter, + (j + 2.0) * brickHeight + j * brickGutter, + ), + brickColors[i], + ), + ]); + } + + @override + void onTap() { + super.onTap(); + startGame(); + } + + @override + KeyEventResult onKeyEvent( + RawKeyEvent event, Set keysPressed) { + super.onKeyEvent(event, keysPressed); + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowLeft: + case LogicalKeyboardKey.keyA: + world.children.query().first.moveBy(-batStep); + case LogicalKeyboardKey.keyD: + case LogicalKeyboardKey.arrowRight: + world.children.query().first.moveBy(batStep); + case LogicalKeyboardKey.space: + case LogicalKeyboardKey.enter: + startGame(); + } + return KeyEventResult.handled; + } + + @override + Color backgroundColor() => const Color(0xfff2e8cf); +} + +class Ball extends CircleComponent + with CollisionCallbacks, HasGameReference { + Ball({ + required this.velocity, + required super.position, + required double radius, + required this.difficultyModifier, + }) : super( + radius: radius, + anchor: Anchor.center, + paint: Paint() + ..color = const Color(0xff1e6091) + ..style = PaintingStyle.fill, + children: [CircleHitbox()]); + + final Vector2 velocity; + final double difficultyModifier; + + @override + void update(double dt) { + super.update(dt); + position += velocity * dt; + } + + @override + void onCollisionStart( + Set intersectionPoints, PositionComponent other) { + super.onCollisionStart(intersectionPoints, other); + if (other is PlayArea) { + if (intersectionPoints.first.y <= 0) { + velocity.y = -velocity.y; + } else if (intersectionPoints.first.x <= 0) { + velocity.x = -velocity.x; + } else if (intersectionPoints.first.x >= game.width) { + velocity.x = -velocity.x; + } else if (intersectionPoints.first.y >= game.height) { + add(RemoveEffect( + delay: 0.35, + onComplete: () { + game.startGame(); + }, + )); + } + } else if (other is Bat) { + velocity.y = -velocity.y; + velocity.x = velocity.x + + (position.x - other.position.x) / other.size.x * game.width * 0.3; + } else if (other is Brick) { + if (position.y < other.position.y - other.size.y / 2) { + velocity.y = -velocity.y; + } else if (position.y > other.position.y + other.size.y / 2) { + velocity.y = -velocity.y; + } else if (position.x < other.position.x) { + velocity.x = -velocity.x; + } else if (position.x > other.position.x) { + velocity.x = -velocity.x; + } + velocity.setFrom(velocity * difficultyModifier); + } + } +} + +class Bat extends PositionComponent + with DragCallbacks, HasGameReference { + Bat({ + required this.cornerRadius, + required super.position, + required super.size, + }) : super(anchor: Anchor.center, children: [RectangleHitbox()]); + + final Radius cornerRadius; + + final _paint = Paint() + ..color = const Color(0xff1e6091) + ..style = PaintingStyle.fill; + + @override + void render(Canvas canvas) { + super.render(canvas); + canvas.drawRRect( + RRect.fromRectAndRadius( + Offset.zero & size.toSize(), + cornerRadius, + ), + _paint, + ); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + if (isRemoved) return; + super.onDragUpdate(event); + position.x = (position.x + event.localDelta.x) + .clamp(width / 2, game.width - width / 2); + } + + void moveBy(double dx) { + add(MoveToEffect( + Vector2( + (position.x + dx).clamp(width / 2, game.width - width / 2), + position.y, + ), + EffectController(duration: 0.1), + )); + } +} + +class Brick extends RectangleComponent + with CollisionCallbacks, HasGameReference { + Brick(Vector2 position, Color color) + : super( + position: position, + size: Vector2(brickWidth, brickHeight), + anchor: Anchor.center, + paint: Paint() + ..color = color + ..style = PaintingStyle.fill, + children: [RectangleHitbox()], + ); + + @override + void onCollisionStart( + Set intersectionPoints, PositionComponent other) { + super.onCollisionStart(intersectionPoints, other); + removeFromParent(); + + if (game.world.children.query().length == 1) { + game.startGame(); + } + } +} + +class PlayArea extends RectangleComponent with HasGameReference { + PlayArea() : super(children: [RectangleHitbox()]); + + @override + Future onLoad() async { + super.onLoad(); + size = Vector2(game.width, game.height); + } +} + +const brickColors = [ + Color(0xfff94144), + Color(0xfff3722c), + Color(0xfff8961e), + Color(0xfff9844a), + Color(0xfff9c74f), + Color(0xff90be6d), + Color(0xff43aa8b), + Color(0xff4d908e), + Color(0xff277da1), + Color(0xff577590), +]; + +const gameWidth = 820.0; +const gameHeight = 1600.0; +const ballRadius = gameWidth * 0.02; +const batWidth = gameWidth * 0.2; +const batHeight = ballRadius * 2; +const batStep = gameWidth * 0.05; +const brickGutter = gameWidth * 0.015; +final brickWidth = + (gameWidth - (brickGutter * (brickColors.length + 1))) / brickColors.length; +const brickHeight = gameHeight * 0.03; +const difficultyModifier = 1.05; +''', +); + +const _googleSdk = Sample( + category: 'Ecosystem', + icon: 'gemini', + name: 'Google AI SDK', + id: 'google-ai-sdk', + source: r''' +// Copyright 2024 the Dart project authors. 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:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:url_launcher/link.dart'; + +void main() { + runApp(const GenerativeAISample()); +} + +class GenerativeAISample extends StatelessWidget { + const GenerativeAISample({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Flutter + Generative AI', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + brightness: Brightness.dark, + seedColor: const Color.fromARGB(255, 171, 222, 244), + ), + useMaterial3: true, + ), + home: const ChatScreen(title: 'Flutter + Generative AI'), + ); + } +} + +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key, required this.title}); + + final String title; + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + String? apiKey; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: switch (apiKey) { + final providedKey? => ChatWidget(apiKey: providedKey), + _ => ApiKeyWidget(onSubmitted: (key) { + setState(() => apiKey = key); + }), + }, + ); + } +} + +class ApiKeyWidget extends StatelessWidget { + ApiKeyWidget({required this.onSubmitted, super.key}); + + final ValueChanged onSubmitted; + final TextEditingController _textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'To use the Gemini API, you\'ll need an API key. ' + 'If you don\'t already have one, ' + 'create a key in Google AI Studio.', + ), + const SizedBox(height: 8), + Link( + uri: Uri.https('makersuite.google.com', '/app/apikey'), + target: LinkTarget.blank, + builder: (context, followLink) => TextButton( + onPressed: followLink, + child: const Text('Get an API Key'), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + decoration: + textFieldDecoration(context, 'Enter your API key'), + controller: _textController, + onSubmitted: (value) { + onSubmitted(value); + }, + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () { + onSubmitted(_textController.value.text); + }, + child: const Text('Submit'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class ChatWidget extends StatefulWidget { + const ChatWidget({required this.apiKey, super.key}); + + final String apiKey; + + @override + State createState() => _ChatWidgetState(); +} + +class _ChatWidgetState extends State { + late final GenerativeModel _model; + late final ChatSession _chat; + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(debugLabel: 'TextField'); + bool _loading = false; + + @override + void initState() { + super.initState(); + _model = GenerativeModel( + model: 'gemini-pro', + apiKey: widget.apiKey, + ); + _chat = _model.startChat(); + } + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration( + milliseconds: 750, + ), + curve: Curves.easeOutCirc, + ), + ); + } + + @override + Widget build(BuildContext context) { + final history = _chat.history.toList(); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + final content = history[idx]; + final text = content.parts + .whereType() + .map((e) => e.text) + .join(''); + return MessageWidget( + text: text, + isFromUser: content.role == 'user', + ); + }, + itemCount: history.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _textFieldFocus, + decoration: + textFieldDecoration(context, 'Enter a prompt...'), + controller: _textController, + onSubmitted: (String value) { + _sendChatMessage(value); + }, + ), + ), + const SizedBox.square(dimension: 15), + if (!_loading) + IconButton( + onPressed: () async { + _sendChatMessage(_textController.text); + }, + icon: Icon( + Icons.send, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + const CircularProgressIndicator(), + ], + ), + ), + ], + ), + ); + } + + Future _sendChatMessage(String message) async { + setState(() { + _loading = true; + }); + + try { + final response = await _chat.sendMessage( + Content.text(message), + ); + final text = response.text; + + if (text == null) { + _showError('Empty response.'); + return; + } else { + setState(() { + _loading = false; + _scrollDown(); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _loading = false; + }); + } finally { + _textController.clear(); + setState(() { + _loading = false; + }); + _textFieldFocus.requestFocus(); + } + } + + void _showError(String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Something went wrong'), + content: SingleChildScrollView( + child: Text(message), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ) + ], + ); + }, + ); + } +} + +class MessageWidget extends StatelessWidget { + const MessageWidget({ + super.key, + required this.text, + required this.isFromUser, + }); + + final String text; + final bool isFromUser; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: + isFromUser ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + Flexible( + child: Container( + constraints: const BoxConstraints(maxWidth: 480), + decoration: BoxDecoration( + color: isFromUser + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(18), + ), + padding: const EdgeInsets.symmetric( + vertical: 15, + horizontal: 20, + ), + margin: const EdgeInsets.only(bottom: 8), + child: MarkdownBody(data: text), + ), + ), + ], + ); + } +} + +InputDecoration textFieldDecoration(BuildContext context, String hintText) => + InputDecoration( + contentPadding: const EdgeInsets.all(15), + hintText: hintText, + border: OutlineInputBorder( + borderRadius: const BorderRadius.all( + Radius.circular(14), + ), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all( + Radius.circular(14), + ), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + ), + ), + ); +''', +); + +const _counter = Sample( category: 'Flutter', + icon: 'flutter', name: 'Counter', id: 'counter', source: r''' @@ -202,8 +896,9 @@ class _MyHomePageState extends State { ''', ); -final _sunflower = Sample( +const _sunflower = Sample( category: 'Flutter', + icon: 'flutter', name: 'Sunflower', id: 'sunflower', source: r''' diff --git a/pkgs/sketch_pad/lib/utils.dart b/pkgs/sketch_pad/lib/utils.dart index 2dc0fb9f8..fa62a3b86 100644 --- a/pkgs/sketch_pad/lib/utils.dart +++ b/pkgs/sketch_pad/lib/utils.dart @@ -28,16 +28,6 @@ void unimplemented(BuildContext context, String message) { String generateSnippetName() => fluttering_phrases.generate(); -Image dartLogo({double? width}) { - return Image.asset('assets/dart_logo_128.png', - width: width ?? defaultIconSize); -} - -Image flutterLogo({double? width}) { - return Image.asset('assets/flutter_logo_192.png', - width: width ?? defaultIconSize); -} - RelativeRect calculatePopupMenuPosition( BuildContext context, { bool growUpwards = false, diff --git a/pkgs/sketch_pad/lib/widgets.dart b/pkgs/sketch_pad/lib/widgets.dart index 87821a3bb..8ca68756a 100644 --- a/pkgs/sketch_pad/lib/widgets.dart +++ b/pkgs/sketch_pad/lib/widgets.dart @@ -248,3 +248,22 @@ class GoldenRatioCenter extends StatelessWidget { ); } } + +final class Logo extends StatelessWidget { + final String? _type; + final double width; + + const Logo({super.key, this.width = defaultIconSize, String? type}) + : _type = type; + + @override + Widget build(BuildContext context) { + final assetPath = switch (_type) { + 'flutter' => 'assets/flutter_logo_192.png', + 'flame' => 'assets/flame_logo_192.png', + 'gemini' => 'assets/gemini_sparkle_192.png', + _ => 'assets/dart_logo_192.png', + }; + return Image.asset(assetPath, width: width); + } +} diff --git a/pkgs/sketch_pad/pubspec.yaml b/pkgs/sketch_pad/pubspec.yaml index 77f932a05..6f27b9998 100644 --- a/pkgs/sketch_pad/pubspec.yaml +++ b/pkgs/sketch_pad/pubspec.yaml @@ -32,8 +32,10 @@ dev_dependencies: flutter: uses-material-design: true assets: - - assets/dart_logo_128.png + - assets/dart_logo_192.png + - assets/flame_logo_192.png - assets/flutter_logo_192.png + - assets/gemini_sparkle_192.png fonts: - family: RobotoMono fonts: