diff --git a/docs/01-get-started/01-creating-endpoints.md b/docs/01-get-started/01-creating-endpoints.md index 08b62d01..934784aa 100644 --- a/docs/01-get-started/01-creating-endpoints.md +++ b/docs/01-get-started/01-creating-endpoints.md @@ -37,21 +37,10 @@ development: Next, we add the Dartantic AI package as a dependency to our server. This package provides a convenient interface for working with different AI providers, including Google's Gemini API. ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ dart pub add dartantic_ai ``` -::::warning -`dartantic_ai` recently introduced a breaking change. To avoid build errors, pin the versions in your server's `pubspec.yaml` using `dependency_overrides`: - -```yaml -dependency_overrides: - dartantic_ai: 1.2.0 - dartantic_interface: 1.1.0 -``` - -:::: - ## Create a new endpoint Create a new file in `magic_recipe_server/lib/src/recipes/` called `recipe_endpoint.dart`. This is where you will define your endpoint and its methods. With Serverpod, you can choose any directory structure you want to use. E.g., you could also use `src/endpoints/` if you want to go layer first or `src/features/recipes/` if you have many features. @@ -67,26 +56,26 @@ import 'package:serverpod/serverpod.dart'; class RecipeEndpoint extends Endpoint { /// Pass in a string containing the ingredients and get a recipe back. Future generateRecipe(Session session, String ingredients) async { - // Serverpod automatically loads your passwords.yaml file and makes the passwords available - // in the session.passwords map. + // Serverpod automatically loads your passwords.yaml file and makes the + // passwords available in the session.passwords map. final geminiApiKey = session.passwords['geminiApiKey']; if (geminiApiKey == null) { throw Exception('Gemini API key not found'); } // Configure the Dartantic AI agent for Gemini before sending the prompt. - Agent.environment['GEMINI_API_KEY'] = geminiApiKey; final agent = Agent.forProvider( - Providers.google, + GoogleProvider(apiKey: geminiApiKey), chatModelName: 'gemini-2.5-flash-lite', ); - // A prompt to generate a recipe, the user will provide a free text input with the ingredients. + // A prompt to generate a recipe, the user will provide a free text input + // with the ingredients. final prompt = 'Generate a recipe using the following ingredients: $ingredients. ' - 'Always put the title of the recipe in the first line, and then the instructions. ' - 'The recipe should be easy to follow and include all necessary steps. ' - 'Please provide a detailed recipe.'; + 'Always put the title of the recipe in the first line, followed by the ' + 'instructions. The recipe should be easy to follow and include all ' + 'necessary steps.'; final response = await agent.send(prompt); @@ -104,25 +93,25 @@ class RecipeEndpoint extends Endpoint { :::info -For methods to be generated, they need to return a typed `Future`, where the type should be `void` `bool`, `int`, `double`, `String`, `UuidValue`, `Duration`, `DateTime`, `ByteData`, `Uri`, `BigInt`, or [serializable models](../06-concepts/02-models.md). The first parameter should be a `Session` object. You can pass any serializable types as parameters, and even use `List`, `Map`, or `Set` as long as they are typed. +For methods to be recognized by Serverpod, they need to return a typed `Future` or `Stream`, where the type must be `void` `bool`, `int`, `double`, `String`, `UuidValue`, `Duration`, `DateTime`, `ByteData`, `Uri`, `BigInt`, or a [serializable model](../06-concepts/02-models.md). The first parameter must be a `Session` object. You can pass any serializable types as parameters, and even use `List`, `Map`, `Set` or Dart records as long as they are typed. ::: Now, you need to generate the code for your new endpoint. You do this by running `serverpod generate` in the server directory of your project: ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ serverpod generate ``` `serverpod generate` will create bindings for the endpoint and register them in the server's `generated/protocol.dart` file. It will also generate the required client code so that you can call your new `generateRecipe` method from your app. :::note -When writing server-side code, in most cases, you want it to be "stateless". This means you want to avoid using global or static variables. Instead, think of each endpoint method as a function that does stuff in a sub-second timeframe and returns data or a status message to your client. If you want to run more complex computations, you can schedule a [future call](../06-concepts/14-scheduling.md), but you usually shouldn't keep the connection open for longer durations. The `Session` object contains all the information you need to access the database and other features of Serverpod. It is similar to the `BuildContext` in Flutter. +When writing server-side code, in most cases, you want it to be _stateless_. This means you avoid using global or static variables. Instead, think of each endpoint method as a function that does stuff in a sub-second timeframe and returns data or a status messages to your client. If you want to run more complex computations, you can return a `Stream` to yield progress updates as your task progresses. ::: ## Call the endpoint from the client -Now that you have created the endpoint, you can call it from the Flutter app. Do this in the `magic_recipe_flutter/lib/main.dart` file. Modify the `_callHello` method to call your new endpoint method and rename it to `_callGenerateRecipe`. It should look like this; feel free to just copy and paste: +Now that you have created the endpoint, you can call it from the Flutter app. Do this in the `magic_recipe_flutter/lib/main.dart` file. Rename the `_callHello` method to `_callGenerateRecipe` and modify it to do the following (feel free to just copy and paste): ```dart @@ -130,8 +119,8 @@ class MyHomePageState extends State { /// Holds the last result or null if no result exists yet. String? _resultMessage; - /// Holds the last error message that we've received from the server or null if no - /// error exists yet. + /// Holds the last error message that we've received from the server or null + /// if no error exists yet. String? _errorMessage; final _textEditingController = TextEditingController(); @@ -140,19 +129,26 @@ class MyHomePageState extends State { void _callGenerateRecipe() async { try { + // Reset the state. setState(() { _errorMessage = null; _resultMessage = null; _loading = true; }); - final result = - await client.recipe.generateRecipe(_textEditingController.text); + + // Call our `generateRecipe` method on the server. + final result = await client.recipe.generateRecipe( + _textEditingController.text, + ); + + // Update the state with the recipe we got from the server. setState(() { _errorMessage = null; _resultMessage = result; _loading = false; }); } catch (e) { + // If something goes wrong, set an error message. setState(() { _errorMessage = '$e'; _resultMessage = null; @@ -215,7 +211,7 @@ Before you start your server, ensure no other Serverpod server is running. Also, Let's try our new recipe app! First, start the server: ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ docker compose up -d $ dart bin/main.dart --apply-migrations ``` @@ -223,7 +219,7 @@ $ dart bin/main.dart --apply-migrations Now, you can start the Flutter app: ```bash -$ cd magic_recipe/magic_recipe_flutter +$ cd magic_recipe_flutter $ flutter run -d chrome ``` diff --git a/docs/01-get-started/02-models-and-data.md b/docs/01-get-started/02-models-and-data.md index 5a216b4a..bd2ed418 100644 --- a/docs/01-get-started/02-models-and-data.md +++ b/docs/01-get-started/02-models-and-data.md @@ -8,7 +8,7 @@ Serverpod ships with a powerful data modeling system that uses easy-to-read defi ## Create a new model -Models files can be placed anywhere in the server's `lib` directory. We will create a new model file called `recipe.spy.yaml` in the `magic_recipe_server/lib/src/recipes/` directory. We like to use the extension `.spy.yaml` to indicate that this is a _serverpod YAML_ file. +Models files can be placed anywhere in the server's `lib` directory. We will create a new model file called `recipe.spy.yaml` in the `magic_recipe_server/lib/src/recipes/` directory. Use the `.spy.yaml` extension to indicate that this is a _serverpod YAML_ file. ```yaml @@ -33,7 +33,7 @@ You can use most primitive Dart types here or any other models you have specifie To generate the code for the model, run the `serverpod generate` command in your server directory: ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ serverpod generate ``` @@ -92,9 +92,8 @@ class RecipeEndpoint extends Endpoint { } // Configure the Dartantic AI agent for Gemini before sending the prompt. - Agent.environment['GEMINI_API_KEY'] = geminiApiKey; final agent = Agent.forProvider( - Providers.google, + GoogleProvider(apiKey: geminiApiKey), chatModelName: 'gemini-2.5-flash-lite', ); @@ -133,60 +132,27 @@ class RecipeEndpoint extends Endpoint { First, we need to update our generated client by running `serverpod generate`. ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ serverpod generate ``` -Now that we have created the `Recipe` model we can use it in the client. We will do this in the `magic_recipe/magic_recipe_flutter/lib/main.dart` file. Let's update our `RecipeWidget` so that it displays the author and year of the recipe in addition to the recipe itself. +Now that we have created the `Recipe` model we can use it in the app. We will do this in the `_callGenerateRecipe` method of the `magic_recipe_flutter/lib/main.dart` file. Let's update our `RecipeWidget` so that it displays the author and year of the recipe in addition to the recipe itself. - + ```dart -class MyHomePageState extends State { - // Rename _resultMessage to _recipe and change the type to Recipe. - - /// Holds the last result or null if no result exists yet. - Recipe? _recipe; +void _callGenerateRecipe() async { // ... - void _callGenerateRecipe() async { - try { - setState(() { - _errorMessage = null; - _recipe = null; - _loading = true; - }); - final result = - await client.recipe.generateRecipe(_textEditingController.text); - setState(() { - _errorMessage = null; - _recipe = result; - _loading = false; - }); - } catch (e) { - setState(() { - _errorMessage = '$e'; - _recipe = null; - _loading = false; - }); - } - } -// ... - @override - Widget build(BuildContext context) { - return Scaffold( + + // Update the state with the recipe we got from the server. + setState(() { + _errorMessage = null; + + // Here we read the properties from our new Recipe model. + _resultMessage = '${result.author} on ${result.date}:\n${result.text}'; + _loading = false; + }); + // ... - // Change the ResultDisplay to use the Recipe object. - ResultDisplay( - resultMessage: _recipe != null - ? '${_recipe?.author} on ${_recipe?.date}:\n${_recipe?.text}' - : null, - errorMessage: _errorMessage, - ), - ), - ), - ], - ), - ), - ); } } ``` @@ -199,14 +165,70 @@ class MyHomePageState extends State { ```dart -class MyHomePageState extends State { - // Rename _resultMessage to _recipe and change the type to Recipe. +import 'package:magic_recipe_client/magic_recipe_client.dart'; +import 'package:flutter/material.dart'; +import 'package:serverpod_flutter/serverpod_flutter.dart'; +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; + +/// Sets up a global client object that can be used to talk to the server from +/// anywhere in our app. The client is generated from your server code +/// and is set up to connect to a Serverpod running on a local server on +/// the default port. You will need to modify this to connect to staging or +/// production servers. +/// In a larger app, you may want to use the dependency injection of your choice +/// instead of using a global client object. This is just a simple example. +late final Client client; + +late String serverUrl; + +void main() { + // When you are running the app on a physical device, you need to set the + // server URL to the IP address of your computer. You can find the IP + // address by running `ipconfig` on Windows or `ifconfig` on Mac/Linux. + // You can set the variable when running or building your app like this: + // E.g. `flutter run --dart-define=SERVER_URL=https://api.example.com/` + const serverUrlFromEnv = String.fromEnvironment('SERVER_URL'); + final serverUrl = serverUrlFromEnv.isEmpty + ? 'http://$localhost:8080/' + : serverUrlFromEnv; + + client = Client(serverUrl) + ..connectivityMonitor = FlutterConnectivityMonitor() + ..authSessionManager = FlutterAuthSessionManager(); + + client.auth.initialize(); + + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Serverpod Demo', + theme: ThemeData(primarySwatch: Colors.blue), + home: const MyHomePage(title: 'Serverpod Example'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + MyHomePageState createState() => MyHomePageState(); +} +class MyHomePageState extends State { /// Holds the last result or null if no result exists yet. - Recipe? _recipe; + String? _resultMessage; - /// Holds the last error message that we've received from the server or null if no - /// error exists yet. + /// Holds the last error message that we've received from the server or null + /// if no error exists yet. String? _errorMessage; final _textEditingController = TextEditingController(); @@ -215,22 +237,29 @@ class MyHomePageState extends State { void _callGenerateRecipe() async { try { + // Reset the state. setState(() { _errorMessage = null; - _recipe = null; + _resultMessage = null; _loading = true; }); - final result = - await client.recipe.generateRecipe(_textEditingController.text); + + // Call our `generateRecipe` method on the server. + final result = await client.recipe.generateRecipe( + _textEditingController.text, + ); + + // Update the state with the recipe we got from the server. setState(() { _errorMessage = null; - _recipe = result; + _resultMessage = '${result.author} on ${result.date}:\n${result.text}'; _loading = false; }); } catch (e) { + // If something goes wrong, set an error message. setState(() { _errorMessage = '$e'; - _recipe = null; + _resultMessage = null; _loading = false; }); } @@ -261,17 +290,13 @@ class MyHomePageState extends State { onPressed: _loading ? null : _callGenerateRecipe, child: _loading ? const Text('Loading...') - : const Text('Send to Server'), + : const Text('Generate Recipe'), ), ), Expanded( child: SingleChildScrollView( - child: - // Change the ResultDisplay to use the Recipe object. - ResultDisplay( - resultMessage: _recipe != null - ? '${_recipe?.author} on ${_recipe?.date}:\n${_recipe?.text}' - : null, + child: ResultDisplay( + resultMessage: _resultMessage, errorMessage: _errorMessage, ), ), @@ -282,6 +307,121 @@ class MyHomePageState extends State { ); } } + +class SignInScreen extends StatelessWidget { + const SignInScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: SignInWidget( + client: client, + onAuthenticated: () {}, + ), + ); + } +} + +class ConnectedScreen extends StatefulWidget { + const ConnectedScreen({super.key}); + + @override + State createState() => _ConnectedScreenState(); +} + +class _ConnectedScreenState extends State { + /// Holds the last result or null if no result exists yet. + String? _resultMessage; + + /// Holds the last error message that we've received from the server or null + /// if no error exists yet. + String? _errorMessage; + + final _textEditingController = TextEditingController(); + + /// Calls the `hello` method of the `greeting` endpoint. Will set either the + /// `_resultMessage` or `_errorMessage` field, depending on if the call + /// is successful. + void _callHello() async { + try { + final result = await client.greeting.hello(_textEditingController.text); + setState(() { + _errorMessage = null; + _resultMessage = result.message; + }); + } catch (e) { + setState(() { + _errorMessage = '$e'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text('You are connected'), + ElevatedButton( + onPressed: () async { + await client.auth.signOutDevice(); + }, + child: const Text('Sign out'), + ), + const SizedBox(height: 32), + TextField( + controller: _textEditingController, + decoration: const InputDecoration(hintText: 'Enter your name'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _callHello, + child: const Text('Send to Server'), + ), + const SizedBox(height: 16), + ResultDisplay( + resultMessage: _resultMessage, + errorMessage: _errorMessage, + ), + ], + ), + ); + } +} + +/// ResultDisplays shows the result of the call. Either the returned result +/// from the `example.greeting` endpoint method or an error message. +class ResultDisplay extends StatelessWidget { + final String? resultMessage; + final String? errorMessage; + + const ResultDisplay({super.key, this.resultMessage, this.errorMessage}); + + @override + Widget build(BuildContext context) { + String text; + Color backgroundColor; + if (errorMessage != null) { + backgroundColor = Colors.red[300]!; + text = errorMessage!; + } else if (resultMessage != null) { + backgroundColor = Colors.green[300]!; + text = resultMessage!; + } else { + backgroundColor = Colors.grey[300]!; + text = 'No server response yet.'; + } + + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 50), + child: Container( + color: backgroundColor, + child: Center(child: Text(text)), + ), + ); + } +} ``` @@ -292,7 +432,7 @@ class MyHomePageState extends State { First, start the server: ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ docker compose up -d $ dart bin/main.dart ``` @@ -300,7 +440,7 @@ $ dart bin/main.dart Then, start the Flutter app: ```bash -$ cd magic_recipe/magic_recipe_flutter +$ cd magic_recipe_flutter $ flutter run -d chrome ``` diff --git a/docs/01-get-started/03-working-with-the-database.md b/docs/01-get-started/03-working-with-the-database.md index 70fd8045..51034f07 100644 --- a/docs/01-get-started/03-working-with-the-database.md +++ b/docs/01-get-started/03-working-with-the-database.md @@ -41,7 +41,7 @@ To create a migration, follow these two steps in order: 2. Run `serverpod create-migration` to create the necessary database migration. ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ serverpod generate $ serverpod create-migration ``` @@ -88,6 +88,8 @@ class RecipeEndpoint extends Endpoint { /// Pass in a string containing the ingredients and get a recipe back. Future generateRecipe(Session session, String ingredients) async { // ... + } + /// This method returns all the generated recipes from the database. Future> getRecipes(Session session) async { // Get all the recipes from the database, sorted by date. @@ -126,11 +128,10 @@ class RecipeEndpoint extends Endpoint { if (geminiApiKey == null) { throw Exception('Gemini API key not found'); } - + // Configure the Dartantic AI agent for Gemini before sending the prompt. - Agent.environment['GEMINI_API_KEY'] = geminiApiKey; final agent = Agent.forProvider( - Providers.google, + GoogleProvider(apiKey: geminiApiKey), chatModelName: 'gemini-2.5-flash-lite', ); @@ -178,16 +179,17 @@ class RecipeEndpoint extends Endpoint { :::info -The when adding a `table` to the model class definition, the model will now give you access to the database, specifically to the `recipes` table through `Recipe.db` (e.g. `Recipe.db.find(session)`. -The `insertRow` method is used to insert a new row in the database. The `find` method is used to query the database and get all the rows of a specific type. See [CRUD](../06-concepts/06-database/05-crud.md) and [relation queries](../06-concepts/06-database/07-relation-queries.md) for more information. +The when adding a `table` to the model class definition, the model will now give you access to the database. In this case we find the database methods under `Recipe.db`. + +The `insertRow` method is used to insert a new row into the database. The `find` method is used to query the database and get all the rows of a specific type. See [CRUD](../06-concepts/06-database/05-crud.md) and [relation queries](../06-concepts/06-database/07-relation-queries.md) for more information. ::: ## Generate the code -Like before, when you change something that has an effect on the client code, you need to run `serverpod generate`. We don't need to run `serverpod create-migrations` again because we already created a migration in the previous step and haven't done any changes that affect the database. +Like before, when you change something that has an effect on your client code, you need to run `serverpod generate`. We don't need to run `serverpod create-migrations` again because we already created a migration in the previous step and haven't done any changes that affect the database. ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ serverpod generate ``` @@ -210,8 +212,8 @@ import 'package:serverpod_flutter/serverpod_flutter.dart'; /// and is set up to connect to a Serverpod running on a local server on /// the default port. You will need to modify this to connect to staging or /// production servers. -/// In a larger app, you may want to use the dependency injection of your choice instead of -/// using a global client object. This is just a simple example. +/// In a larger app, you may want to use the dependency injection of your choice +/// instead of using a global client object. This is just a simple example. late final Client client; late String serverUrl; @@ -262,8 +264,8 @@ class MyHomePageState extends State { List _recipeHistory = []; - /// Holds the last error message that we've received from the server or null if no - /// error exists yet. + /// Holds the last error message that we've received from the server or null + /// if no error exists yet. String? _errorMessage; final _textEditingController = TextEditingController(); @@ -366,7 +368,8 @@ class MyHomePageState extends State { // Change the ResultDisplay to use the Recipe object ResultDisplay( resultMessage: _recipe != null - ? '${_recipe?.author} on ${_recipe?.date}:\n${_recipe?.text}' + ? '${_recipe?.author} on ${_recipe?.date}:\n' + '${_recipe?.text}' : null, errorMessage: _errorMessage, ), @@ -430,7 +433,7 @@ To run the application with database support, follow these steps in order: First, start the database and apply migrations: ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ docker compose up -d # Start the database container $ dart bin/main.dart --apply-migrations # Apply any pending migrations ``` @@ -442,7 +445,7 @@ The `--apply-migrations` flag is safe to use during development - if no migratio Next, launch the Flutter app: ```bash -$ cd magic_recipe/magic_recipe_flutter +$ cd magic_recipe_flutter $ flutter run -d chrome ``` @@ -454,7 +457,7 @@ You've now learned the fundamentals of Serverpod: - Storing data persistently in a database. - Using the generated client code in your Flutter app. -We're excited to see what you'll build with Flutter and Serverpod! If you need help, don't hesitate to ask questions in our [community on Github](https://github.com/serverpod/serverpod/discussions). Both the Serverpod team and community members are active and ready to help. +We're excited to see what you'll build with Flutter and Serverpod! If you need help, don't hesitate to ask questions in our [community on Github](https://github.com/serverpod/serverpod/discussions). Both the Serverpod team and community members are active and ready to help. To connect with other Serverpod users we also have a [Discord community](https://serverpod.dev/discord). :::tip Database operations are a broad topic, and Serverpod's ORM offers many powerful features. To learn more about advanced database operations, check out the [Database](../06-concepts/06-database/01-connection.md) section. diff --git a/versioned_docs/version-3.0.0/01-get-started/01-creating-endpoints.md b/versioned_docs/version-3.0.0/01-get-started/01-creating-endpoints.md index 08b62d01..934784aa 100644 --- a/versioned_docs/version-3.0.0/01-get-started/01-creating-endpoints.md +++ b/versioned_docs/version-3.0.0/01-get-started/01-creating-endpoints.md @@ -37,21 +37,10 @@ development: Next, we add the Dartantic AI package as a dependency to our server. This package provides a convenient interface for working with different AI providers, including Google's Gemini API. ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ dart pub add dartantic_ai ``` -::::warning -`dartantic_ai` recently introduced a breaking change. To avoid build errors, pin the versions in your server's `pubspec.yaml` using `dependency_overrides`: - -```yaml -dependency_overrides: - dartantic_ai: 1.2.0 - dartantic_interface: 1.1.0 -``` - -:::: - ## Create a new endpoint Create a new file in `magic_recipe_server/lib/src/recipes/` called `recipe_endpoint.dart`. This is where you will define your endpoint and its methods. With Serverpod, you can choose any directory structure you want to use. E.g., you could also use `src/endpoints/` if you want to go layer first or `src/features/recipes/` if you have many features. @@ -67,26 +56,26 @@ import 'package:serverpod/serverpod.dart'; class RecipeEndpoint extends Endpoint { /// Pass in a string containing the ingredients and get a recipe back. Future generateRecipe(Session session, String ingredients) async { - // Serverpod automatically loads your passwords.yaml file and makes the passwords available - // in the session.passwords map. + // Serverpod automatically loads your passwords.yaml file and makes the + // passwords available in the session.passwords map. final geminiApiKey = session.passwords['geminiApiKey']; if (geminiApiKey == null) { throw Exception('Gemini API key not found'); } // Configure the Dartantic AI agent for Gemini before sending the prompt. - Agent.environment['GEMINI_API_KEY'] = geminiApiKey; final agent = Agent.forProvider( - Providers.google, + GoogleProvider(apiKey: geminiApiKey), chatModelName: 'gemini-2.5-flash-lite', ); - // A prompt to generate a recipe, the user will provide a free text input with the ingredients. + // A prompt to generate a recipe, the user will provide a free text input + // with the ingredients. final prompt = 'Generate a recipe using the following ingredients: $ingredients. ' - 'Always put the title of the recipe in the first line, and then the instructions. ' - 'The recipe should be easy to follow and include all necessary steps. ' - 'Please provide a detailed recipe.'; + 'Always put the title of the recipe in the first line, followed by the ' + 'instructions. The recipe should be easy to follow and include all ' + 'necessary steps.'; final response = await agent.send(prompt); @@ -104,25 +93,25 @@ class RecipeEndpoint extends Endpoint { :::info -For methods to be generated, they need to return a typed `Future`, where the type should be `void` `bool`, `int`, `double`, `String`, `UuidValue`, `Duration`, `DateTime`, `ByteData`, `Uri`, `BigInt`, or [serializable models](../06-concepts/02-models.md). The first parameter should be a `Session` object. You can pass any serializable types as parameters, and even use `List`, `Map`, or `Set` as long as they are typed. +For methods to be recognized by Serverpod, they need to return a typed `Future` or `Stream`, where the type must be `void` `bool`, `int`, `double`, `String`, `UuidValue`, `Duration`, `DateTime`, `ByteData`, `Uri`, `BigInt`, or a [serializable model](../06-concepts/02-models.md). The first parameter must be a `Session` object. You can pass any serializable types as parameters, and even use `List`, `Map`, `Set` or Dart records as long as they are typed. ::: Now, you need to generate the code for your new endpoint. You do this by running `serverpod generate` in the server directory of your project: ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ serverpod generate ``` `serverpod generate` will create bindings for the endpoint and register them in the server's `generated/protocol.dart` file. It will also generate the required client code so that you can call your new `generateRecipe` method from your app. :::note -When writing server-side code, in most cases, you want it to be "stateless". This means you want to avoid using global or static variables. Instead, think of each endpoint method as a function that does stuff in a sub-second timeframe and returns data or a status message to your client. If you want to run more complex computations, you can schedule a [future call](../06-concepts/14-scheduling.md), but you usually shouldn't keep the connection open for longer durations. The `Session` object contains all the information you need to access the database and other features of Serverpod. It is similar to the `BuildContext` in Flutter. +When writing server-side code, in most cases, you want it to be _stateless_. This means you avoid using global or static variables. Instead, think of each endpoint method as a function that does stuff in a sub-second timeframe and returns data or a status messages to your client. If you want to run more complex computations, you can return a `Stream` to yield progress updates as your task progresses. ::: ## Call the endpoint from the client -Now that you have created the endpoint, you can call it from the Flutter app. Do this in the `magic_recipe_flutter/lib/main.dart` file. Modify the `_callHello` method to call your new endpoint method and rename it to `_callGenerateRecipe`. It should look like this; feel free to just copy and paste: +Now that you have created the endpoint, you can call it from the Flutter app. Do this in the `magic_recipe_flutter/lib/main.dart` file. Rename the `_callHello` method to `_callGenerateRecipe` and modify it to do the following (feel free to just copy and paste): ```dart @@ -130,8 +119,8 @@ class MyHomePageState extends State { /// Holds the last result or null if no result exists yet. String? _resultMessage; - /// Holds the last error message that we've received from the server or null if no - /// error exists yet. + /// Holds the last error message that we've received from the server or null + /// if no error exists yet. String? _errorMessage; final _textEditingController = TextEditingController(); @@ -140,19 +129,26 @@ class MyHomePageState extends State { void _callGenerateRecipe() async { try { + // Reset the state. setState(() { _errorMessage = null; _resultMessage = null; _loading = true; }); - final result = - await client.recipe.generateRecipe(_textEditingController.text); + + // Call our `generateRecipe` method on the server. + final result = await client.recipe.generateRecipe( + _textEditingController.text, + ); + + // Update the state with the recipe we got from the server. setState(() { _errorMessage = null; _resultMessage = result; _loading = false; }); } catch (e) { + // If something goes wrong, set an error message. setState(() { _errorMessage = '$e'; _resultMessage = null; @@ -215,7 +211,7 @@ Before you start your server, ensure no other Serverpod server is running. Also, Let's try our new recipe app! First, start the server: ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ docker compose up -d $ dart bin/main.dart --apply-migrations ``` @@ -223,7 +219,7 @@ $ dart bin/main.dart --apply-migrations Now, you can start the Flutter app: ```bash -$ cd magic_recipe/magic_recipe_flutter +$ cd magic_recipe_flutter $ flutter run -d chrome ``` diff --git a/versioned_docs/version-3.0.0/01-get-started/02-models-and-data.md b/versioned_docs/version-3.0.0/01-get-started/02-models-and-data.md index 5a216b4a..bd2ed418 100644 --- a/versioned_docs/version-3.0.0/01-get-started/02-models-and-data.md +++ b/versioned_docs/version-3.0.0/01-get-started/02-models-and-data.md @@ -8,7 +8,7 @@ Serverpod ships with a powerful data modeling system that uses easy-to-read defi ## Create a new model -Models files can be placed anywhere in the server's `lib` directory. We will create a new model file called `recipe.spy.yaml` in the `magic_recipe_server/lib/src/recipes/` directory. We like to use the extension `.spy.yaml` to indicate that this is a _serverpod YAML_ file. +Models files can be placed anywhere in the server's `lib` directory. We will create a new model file called `recipe.spy.yaml` in the `magic_recipe_server/lib/src/recipes/` directory. Use the `.spy.yaml` extension to indicate that this is a _serverpod YAML_ file. ```yaml @@ -33,7 +33,7 @@ You can use most primitive Dart types here or any other models you have specifie To generate the code for the model, run the `serverpod generate` command in your server directory: ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ serverpod generate ``` @@ -92,9 +92,8 @@ class RecipeEndpoint extends Endpoint { } // Configure the Dartantic AI agent for Gemini before sending the prompt. - Agent.environment['GEMINI_API_KEY'] = geminiApiKey; final agent = Agent.forProvider( - Providers.google, + GoogleProvider(apiKey: geminiApiKey), chatModelName: 'gemini-2.5-flash-lite', ); @@ -133,60 +132,27 @@ class RecipeEndpoint extends Endpoint { First, we need to update our generated client by running `serverpod generate`. ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ serverpod generate ``` -Now that we have created the `Recipe` model we can use it in the client. We will do this in the `magic_recipe/magic_recipe_flutter/lib/main.dart` file. Let's update our `RecipeWidget` so that it displays the author and year of the recipe in addition to the recipe itself. +Now that we have created the `Recipe` model we can use it in the app. We will do this in the `_callGenerateRecipe` method of the `magic_recipe_flutter/lib/main.dart` file. Let's update our `RecipeWidget` so that it displays the author and year of the recipe in addition to the recipe itself. - + ```dart -class MyHomePageState extends State { - // Rename _resultMessage to _recipe and change the type to Recipe. - - /// Holds the last result or null if no result exists yet. - Recipe? _recipe; +void _callGenerateRecipe() async { // ... - void _callGenerateRecipe() async { - try { - setState(() { - _errorMessage = null; - _recipe = null; - _loading = true; - }); - final result = - await client.recipe.generateRecipe(_textEditingController.text); - setState(() { - _errorMessage = null; - _recipe = result; - _loading = false; - }); - } catch (e) { - setState(() { - _errorMessage = '$e'; - _recipe = null; - _loading = false; - }); - } - } -// ... - @override - Widget build(BuildContext context) { - return Scaffold( + + // Update the state with the recipe we got from the server. + setState(() { + _errorMessage = null; + + // Here we read the properties from our new Recipe model. + _resultMessage = '${result.author} on ${result.date}:\n${result.text}'; + _loading = false; + }); + // ... - // Change the ResultDisplay to use the Recipe object. - ResultDisplay( - resultMessage: _recipe != null - ? '${_recipe?.author} on ${_recipe?.date}:\n${_recipe?.text}' - : null, - errorMessage: _errorMessage, - ), - ), - ), - ], - ), - ), - ); } } ``` @@ -199,14 +165,70 @@ class MyHomePageState extends State { ```dart -class MyHomePageState extends State { - // Rename _resultMessage to _recipe and change the type to Recipe. +import 'package:magic_recipe_client/magic_recipe_client.dart'; +import 'package:flutter/material.dart'; +import 'package:serverpod_flutter/serverpod_flutter.dart'; +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; + +/// Sets up a global client object that can be used to talk to the server from +/// anywhere in our app. The client is generated from your server code +/// and is set up to connect to a Serverpod running on a local server on +/// the default port. You will need to modify this to connect to staging or +/// production servers. +/// In a larger app, you may want to use the dependency injection of your choice +/// instead of using a global client object. This is just a simple example. +late final Client client; + +late String serverUrl; + +void main() { + // When you are running the app on a physical device, you need to set the + // server URL to the IP address of your computer. You can find the IP + // address by running `ipconfig` on Windows or `ifconfig` on Mac/Linux. + // You can set the variable when running or building your app like this: + // E.g. `flutter run --dart-define=SERVER_URL=https://api.example.com/` + const serverUrlFromEnv = String.fromEnvironment('SERVER_URL'); + final serverUrl = serverUrlFromEnv.isEmpty + ? 'http://$localhost:8080/' + : serverUrlFromEnv; + + client = Client(serverUrl) + ..connectivityMonitor = FlutterConnectivityMonitor() + ..authSessionManager = FlutterAuthSessionManager(); + + client.auth.initialize(); + + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Serverpod Demo', + theme: ThemeData(primarySwatch: Colors.blue), + home: const MyHomePage(title: 'Serverpod Example'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + MyHomePageState createState() => MyHomePageState(); +} +class MyHomePageState extends State { /// Holds the last result or null if no result exists yet. - Recipe? _recipe; + String? _resultMessage; - /// Holds the last error message that we've received from the server or null if no - /// error exists yet. + /// Holds the last error message that we've received from the server or null + /// if no error exists yet. String? _errorMessage; final _textEditingController = TextEditingController(); @@ -215,22 +237,29 @@ class MyHomePageState extends State { void _callGenerateRecipe() async { try { + // Reset the state. setState(() { _errorMessage = null; - _recipe = null; + _resultMessage = null; _loading = true; }); - final result = - await client.recipe.generateRecipe(_textEditingController.text); + + // Call our `generateRecipe` method on the server. + final result = await client.recipe.generateRecipe( + _textEditingController.text, + ); + + // Update the state with the recipe we got from the server. setState(() { _errorMessage = null; - _recipe = result; + _resultMessage = '${result.author} on ${result.date}:\n${result.text}'; _loading = false; }); } catch (e) { + // If something goes wrong, set an error message. setState(() { _errorMessage = '$e'; - _recipe = null; + _resultMessage = null; _loading = false; }); } @@ -261,17 +290,13 @@ class MyHomePageState extends State { onPressed: _loading ? null : _callGenerateRecipe, child: _loading ? const Text('Loading...') - : const Text('Send to Server'), + : const Text('Generate Recipe'), ), ), Expanded( child: SingleChildScrollView( - child: - // Change the ResultDisplay to use the Recipe object. - ResultDisplay( - resultMessage: _recipe != null - ? '${_recipe?.author} on ${_recipe?.date}:\n${_recipe?.text}' - : null, + child: ResultDisplay( + resultMessage: _resultMessage, errorMessage: _errorMessage, ), ), @@ -282,6 +307,121 @@ class MyHomePageState extends State { ); } } + +class SignInScreen extends StatelessWidget { + const SignInScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: SignInWidget( + client: client, + onAuthenticated: () {}, + ), + ); + } +} + +class ConnectedScreen extends StatefulWidget { + const ConnectedScreen({super.key}); + + @override + State createState() => _ConnectedScreenState(); +} + +class _ConnectedScreenState extends State { + /// Holds the last result or null if no result exists yet. + String? _resultMessage; + + /// Holds the last error message that we've received from the server or null + /// if no error exists yet. + String? _errorMessage; + + final _textEditingController = TextEditingController(); + + /// Calls the `hello` method of the `greeting` endpoint. Will set either the + /// `_resultMessage` or `_errorMessage` field, depending on if the call + /// is successful. + void _callHello() async { + try { + final result = await client.greeting.hello(_textEditingController.text); + setState(() { + _errorMessage = null; + _resultMessage = result.message; + }); + } catch (e) { + setState(() { + _errorMessage = '$e'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text('You are connected'), + ElevatedButton( + onPressed: () async { + await client.auth.signOutDevice(); + }, + child: const Text('Sign out'), + ), + const SizedBox(height: 32), + TextField( + controller: _textEditingController, + decoration: const InputDecoration(hintText: 'Enter your name'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _callHello, + child: const Text('Send to Server'), + ), + const SizedBox(height: 16), + ResultDisplay( + resultMessage: _resultMessage, + errorMessage: _errorMessage, + ), + ], + ), + ); + } +} + +/// ResultDisplays shows the result of the call. Either the returned result +/// from the `example.greeting` endpoint method or an error message. +class ResultDisplay extends StatelessWidget { + final String? resultMessage; + final String? errorMessage; + + const ResultDisplay({super.key, this.resultMessage, this.errorMessage}); + + @override + Widget build(BuildContext context) { + String text; + Color backgroundColor; + if (errorMessage != null) { + backgroundColor = Colors.red[300]!; + text = errorMessage!; + } else if (resultMessage != null) { + backgroundColor = Colors.green[300]!; + text = resultMessage!; + } else { + backgroundColor = Colors.grey[300]!; + text = 'No server response yet.'; + } + + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 50), + child: Container( + color: backgroundColor, + child: Center(child: Text(text)), + ), + ); + } +} ``` @@ -292,7 +432,7 @@ class MyHomePageState extends State { First, start the server: ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ docker compose up -d $ dart bin/main.dart ``` @@ -300,7 +440,7 @@ $ dart bin/main.dart Then, start the Flutter app: ```bash -$ cd magic_recipe/magic_recipe_flutter +$ cd magic_recipe_flutter $ flutter run -d chrome ``` diff --git a/versioned_docs/version-3.0.0/01-get-started/03-working-with-the-database.md b/versioned_docs/version-3.0.0/01-get-started/03-working-with-the-database.md index 70fd8045..51034f07 100644 --- a/versioned_docs/version-3.0.0/01-get-started/03-working-with-the-database.md +++ b/versioned_docs/version-3.0.0/01-get-started/03-working-with-the-database.md @@ -41,7 +41,7 @@ To create a migration, follow these two steps in order: 2. Run `serverpod create-migration` to create the necessary database migration. ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ serverpod generate $ serverpod create-migration ``` @@ -88,6 +88,8 @@ class RecipeEndpoint extends Endpoint { /// Pass in a string containing the ingredients and get a recipe back. Future generateRecipe(Session session, String ingredients) async { // ... + } + /// This method returns all the generated recipes from the database. Future> getRecipes(Session session) async { // Get all the recipes from the database, sorted by date. @@ -126,11 +128,10 @@ class RecipeEndpoint extends Endpoint { if (geminiApiKey == null) { throw Exception('Gemini API key not found'); } - + // Configure the Dartantic AI agent for Gemini before sending the prompt. - Agent.environment['GEMINI_API_KEY'] = geminiApiKey; final agent = Agent.forProvider( - Providers.google, + GoogleProvider(apiKey: geminiApiKey), chatModelName: 'gemini-2.5-flash-lite', ); @@ -178,16 +179,17 @@ class RecipeEndpoint extends Endpoint { :::info -The when adding a `table` to the model class definition, the model will now give you access to the database, specifically to the `recipes` table through `Recipe.db` (e.g. `Recipe.db.find(session)`. -The `insertRow` method is used to insert a new row in the database. The `find` method is used to query the database and get all the rows of a specific type. See [CRUD](../06-concepts/06-database/05-crud.md) and [relation queries](../06-concepts/06-database/07-relation-queries.md) for more information. +The when adding a `table` to the model class definition, the model will now give you access to the database. In this case we find the database methods under `Recipe.db`. + +The `insertRow` method is used to insert a new row into the database. The `find` method is used to query the database and get all the rows of a specific type. See [CRUD](../06-concepts/06-database/05-crud.md) and [relation queries](../06-concepts/06-database/07-relation-queries.md) for more information. ::: ## Generate the code -Like before, when you change something that has an effect on the client code, you need to run `serverpod generate`. We don't need to run `serverpod create-migrations` again because we already created a migration in the previous step and haven't done any changes that affect the database. +Like before, when you change something that has an effect on your client code, you need to run `serverpod generate`. We don't need to run `serverpod create-migrations` again because we already created a migration in the previous step and haven't done any changes that affect the database. ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ serverpod generate ``` @@ -210,8 +212,8 @@ import 'package:serverpod_flutter/serverpod_flutter.dart'; /// and is set up to connect to a Serverpod running on a local server on /// the default port. You will need to modify this to connect to staging or /// production servers. -/// In a larger app, you may want to use the dependency injection of your choice instead of -/// using a global client object. This is just a simple example. +/// In a larger app, you may want to use the dependency injection of your choice +/// instead of using a global client object. This is just a simple example. late final Client client; late String serverUrl; @@ -262,8 +264,8 @@ class MyHomePageState extends State { List _recipeHistory = []; - /// Holds the last error message that we've received from the server or null if no - /// error exists yet. + /// Holds the last error message that we've received from the server or null + /// if no error exists yet. String? _errorMessage; final _textEditingController = TextEditingController(); @@ -366,7 +368,8 @@ class MyHomePageState extends State { // Change the ResultDisplay to use the Recipe object ResultDisplay( resultMessage: _recipe != null - ? '${_recipe?.author} on ${_recipe?.date}:\n${_recipe?.text}' + ? '${_recipe?.author} on ${_recipe?.date}:\n' + '${_recipe?.text}' : null, errorMessage: _errorMessage, ), @@ -430,7 +433,7 @@ To run the application with database support, follow these steps in order: First, start the database and apply migrations: ```bash -$ cd magic_recipe/magic_recipe_server +$ cd magic_recipe_server $ docker compose up -d # Start the database container $ dart bin/main.dart --apply-migrations # Apply any pending migrations ``` @@ -442,7 +445,7 @@ The `--apply-migrations` flag is safe to use during development - if no migratio Next, launch the Flutter app: ```bash -$ cd magic_recipe/magic_recipe_flutter +$ cd magic_recipe_flutter $ flutter run -d chrome ``` @@ -454,7 +457,7 @@ You've now learned the fundamentals of Serverpod: - Storing data persistently in a database. - Using the generated client code in your Flutter app. -We're excited to see what you'll build with Flutter and Serverpod! If you need help, don't hesitate to ask questions in our [community on Github](https://github.com/serverpod/serverpod/discussions). Both the Serverpod team and community members are active and ready to help. +We're excited to see what you'll build with Flutter and Serverpod! If you need help, don't hesitate to ask questions in our [community on Github](https://github.com/serverpod/serverpod/discussions). Both the Serverpod team and community members are active and ready to help. To connect with other Serverpod users we also have a [Discord community](https://serverpod.dev/discord). :::tip Database operations are a broad topic, and Serverpod's ORM offers many powerful features. To learn more about advanced database operations, check out the [Database](../06-concepts/06-database/01-connection.md) section.