Skip to content

SandPod/pixorama_course

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

12 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Pixorama Course

This is a step by step course to learn how to create a collaborative drawing app using Serverpod and the Pixel package.

Prerequisites

  • Basic knowledge of Dart and Flutter.
  • Flutter SDK installed.

Steps

These are the steps to create a collaborative drawing app using Serverpod and the Pixel package.

Step 1:

Install the serverpod cli globally using the following command:

dart pub global activate serverpod_cli

Validate the installation by running the following command:

serverpod version

Step 2: Create a Serverpod mini project

Create a new Serverpod mini project using the following command:

serverpod create pixorama --mini

Inspect the contents of the pixorama folder. You should see three directories created:

  • pixorama_flutter: This is the Flutter application where you will implement the drawing functionality.
  • pixorama_server: This is the server application that will handle communication between different flutter applications.
  • pixorama_client: This is the client application that will handle communication with the server.

Start the server by running the following command:

cd pixorama_server
dart bin/main.dart

Then start the flutter application by running the following command in a different terminal:

cd pixorama_flutter
flutter run -d chrome

The example application should now be running in your browser.

Step 3: Build the pixel drawing canvas

In the pixorama_flutter directory, add the pixel package dependency by running the following command:

# In the pixorama_flutter directory
flutter pub add pixel

Replace MyHomePage and MyHomePageState with the example provided in the pixels package documentation on pub.dev.

Start the app by running the following command:

# In the pixorama_flutter directory
flutter run -d chrome

You should now be able to run the app and see a blank canvas. You can draw on the canvas by selecting a color and clicking on a pixel.

Step 4: Serve app from Serverpod Server

4.1 Prepare flutter assets

In order to serve the app from the Serverpod server, you first need to build the app. Run the following command in the pixorama_flutter directory:

# In the pixorama_flutter directory
flutter build web

This will create a build/web directory containing the built ready to be served.

Create a new directory called web in the pixorama_server directory. This is where the built app will be served from.

# In the pixorama_server directory
mkdir -p web/app

Copy the contents of the pixorama_flutter/build/web directory to the pixorama_server/web/app directory.

# In the serverpod project directory
cp -r pixorama_flutter/build/web/ pixorama_server/web/app

As a last step we need to configure a template file. Create a new directory called templates in the pixorama_server/web directory. This is where the template file will be served from.

# In the pixorama_server directory
mkdir -p web/templates

Then move the index.html file from the web/app directory to the web/templates directory.

# In the pixorama_server directory
mv web/app/index.html web/templates

This file is needed to serve the app.

4.2 Configure serverpod to serve the app

To serve the app we need the web server running in Serverpod. This is done by adding configuration for the web server in pixorama_server/lib/serer.dart file.

Add a config for the web server to Serverpod:

  final pod = Serverpod(
    args,
    Protocol(),
    Endpoints(),
    config: ServerpodConfig(
      // Api server configuration
      apiServer: ServerConfig(
        port: 8080,
        publicScheme: 'http',
        publicHost: 'localhost',
        publicPort: 8080,
      ),
      // Add a web server to serve static files.
      webServer: ServerConfig(
        port: 8081,
        publicScheme: 'http',
        publicHost: 'localhost',
        publicPort: 8081,
      ),
    ),
  );

Now that we have the web server configured, we will add a route to serve the app.

First we create the route in lib/src/web/index_route.dart file:

import 'dart:io';

import 'package:serverpod/serverpod.dart';

/// A route that serves the index page of the web application.
/// The route is registered as a route in the web server.
/// The name of the widget should correspond to a template file in the server's
/// web/templates directory. The template is loaded when the server starts.
class IndexRoute extends WidgetRoute {
  @override
  Future<Widget> build(Session session, HttpRequest request) async {
    return Widget(name: 'index');
  }
}

The route serves the index.html file from the web/templates directory. The name of the widget should correspond to a template file in the server's web/templates directory. The template is loaded when the server starts.

Now we need to register the route in Serverpod. Open the lib/src/server.dart file and add the following code before starting the server:

  // Setup a default page at the web root.
  pod.webServer.addRoute(IndexRoute(), '/');
  pod.webServer.addRoute(IndexRoute(), '/index.html');

But if we start the server now, we will get an error becuase the assets are not served from the app directory. To fix this we need to also serve the assets through a routeStaticDirectory route.

Add the following code to the lib/src/server.dart file:

  // Serve all files in the /app directory.
  pod.webServer.addRoute(
    RouteStaticDirectory(serverDirectory: 'app', basePath: '/'),
    '/*',
  );

Now start the server by running the following command in the pixorama_server directory:

# In the pixorama_server directory
dart bin/main.dart

Then open your browser and go to http://localhost:8081. You should see the app running. You can draw on the canvas by selecting a color and clicking on a pixel.

Step 5: Share state between apps

5.1 Create models

In order to share the state between apps, we need our server to become authoritative over the state. This means that the server will be responsible for storing the state and sending it to the clients.

To allow the server to share state with the apps we will introduce two models:

  • ImageData: This model will be used to share the full image data with the clients. It will be used to send the full image data to the clients when they connect to the server.
  • ImageUpdate: This model will be used to communicate a single pixel update to and from clients.

We will add these as models to the pixorama_server project. Open the lib/src/protocol/models directory and create two new files, image_data.spy.yaml and image_update.spy.yaml.

Add the following code to the image_update.spy.yaml file:

class: ImageData
fields:
  pixels: ByteData
  width: int
  height: int

Add the following code to the image_update.spy.yaml file:

class: ImageUpdate
fields:
  pixelIndex: int
  colorIndex: int

Now we need to generate the models. Run the following command in the pixorama_server directory:

# In the pixorama_server directory
serverpod generate

The files will be generated in the lib/src/protocol/generated directory. You should see two new files, image_data.dart and image_update.dart. These files contain the generated code for the models.

5.2 Create new endpoints

Now we need to actually store the state in the server and then make it possible for the apps to communicate with the server.

We will store the state directly in the endpoint we will be using. Create a new file called pixorama_endpoint in the lib/src/endpoints directory and add the following code:

import 'dart:typed_data';

import 'package:serverpod/serverpod.dart';

class PixoramaEndpoint extends Endpoint {
  static const _imageWidth = 64;
  static const _imageHeight = 64;
  static const _numPixels = _imageWidth * _imageHeight;

  static const _numColorsInPalette = 16;
  static const _defaultPixelColor = 2;

  final _pixelData = Uint8List(_numPixels)
    ..fillRange(
      0,
      _numPixels,
      _defaultPixelColor,
    );

  static const _channelPixelAdded = 'pixel-added';
}

We will store the pixel data in an Uint8List array. This will be used to store the pixel data for the image. The image will be 64x64 pixels and will have 16 colors in the palette. The default color for the pixels will be 2.

Now we will create 2 endpoints to communicate our with out application.

Inside of the PixoramaEndpoint class, add the following method:

  /// Sets a single pixel and notifies all connected clients about the change.
  Future<void> setPixel(
    Session session, {
    required int colorIndex,
    required int pixelIndex,
  }) async {
    // Check that the input parameters are valid. If not, throw a
    // `FormatException`, which will be logged and thrown as
    // `ServerpodClientException` in the app.
    if (colorIndex < 0 || colorIndex >= _numColorsInPalette) {
      throw FormatException('colorIndex is out of range: $colorIndex');
    }
    if (pixelIndex < 0 || pixelIndex >= _numPixels) {
      throw FormatException('pixelIndex is out of range: $pixelIndex');
    }

    // Update our global image.
    _pixelData[pixelIndex] = colorIndex;

    // Notify all connected clients that we set a pixel, by posting a message
    // to the _channelPixelAdded channel.
    session.messages.postMessage(
      _channelPixelAdded,
      ImageUpdate(
        pixelIndex: pixelIndex,
        colorIndex: colorIndex,
      ),
    );
  }

This endpoint is responsible for setting a single pixel in the image and then notifying all connected clients about the change.

Then add the following method:

  /// Returns a stream of image updates. The first message will always be a
  /// `ImageData` object, which contains the full image. Sequential updates
  /// will be `ImageUpdate` objects, which contains a single updated pixel.
  Stream imageUpdates(Session session) async* {
    // Request a stream of updates from the pixel-added channel in
    // MessageCentral.
    var updateStream =
        session.messages.createStream<ImageUpdate>(_channelPixelAdded);

    // Yield a first full image to the client.
    yield ImageData(
      pixels: _pixelData.buffer.asByteData(),
      width: _imageWidth,
      height: _imageHeight,
    );

    // Relay all individual pixel updates from the pixel-added channel to
    // the client.
    await for (var imageUpdate in updateStream) {
      yield imageUpdate;
    }
  }

This endpoint streams image updates to connected clients. The first message sent will be the full image data. The following messages will be updates to single pixels.

Now to update our client and server to be aware of the endpoints we need to generate the code again.

# In the pixorama_server directory
serverpod generate

This will generate the code for the endpoints in the client. You should be able to see them as autocomplete suggestion on the client that is instantiated in the flutter app.

client.pixorama.setPixel(...);
client.pixorama.imageUpdates(...);

5.3 Use endpoints in application

Now we need to use our endpoints in the application. We will start with sharing what we click on with the server.

Open the pixorama_flutter/lib/main.dart file and add the following to the PixelEditor widgets constructor:

onSetPixel: (details) {
  // When a user clicks a pixel we will get a callback from the
  // PixelImageController, with information about the changed
  // pixel. When that happens we call the setPixels method on
  // the server.
  client.pixorama.setPixel(
    pixelIndex: details.tapDetails.index,
    colorIndex: details.colorIndex,
  );
},

This will call the setPixel method on the server when a user clicks a pixel. The details object contains information about the clicked pixel and the color index.

Now we need to make the server authoritative over the state. This means that we need to get the image data from the server when we start the app and listen for updates from the server.

We start by adding a method for fetching and listening to updated from the server. Add the following method to _MyHomePageState class:

  Future<void> _listenToUpdates() async {
    // Indefinitely try to connect and listen to updates from the server.
    while (true) {
      try {
        // Get the stream of updates from the server.
        final imageUpdates = client.pixorama.imageUpdates();

        // Listen for updates from the stream. The await for construct will
        // wait for a message to arrive from the server, then run through the
        // body of the loop.
        await for (final update in imageUpdates) {
          // Check which type of update we have received.
          if (update is ImageData) {
            // This is a complete image update, containing all pixels in the
            // image. Create a new PixelImageController with the pixel data.
            setState(() {
              _controller = PixelImageController(
                pixels: update.pixels,
                palette: PixelPalette.rPlace(),
                width: update.width,
                height: update.height,
              );
            });
          } else if (update is ImageUpdate) {
            // Got an incremental update of the image. Just set the single
            // pixel.
            _controller?.setPixelIndex(
              pixelIndex: update.pixelIndex,
              colorIndex: update.colorIndex,
            );
          }
        }
      } on MethodStreamException catch (_) {
        // We lost the connection to the server, or failed to connect.
        setState(() {
          _controller = null;
        });
      }

      // Wait 5 seconds until we try to connect again.
      await Future.delayed(Duration(seconds: 5));
    }
  }

This method will try to connect to the server and listen for updates. If the connection fails, it will wait 5 seconds and try again.

It will also initiate the _controller variable with the pixel data from the server.

For this to work we need to make _controller a nullable variable instead of instantiated when the app is started. Change the following line:

PixelImageController? _controller;

Now that the controller is nullable, we need to check if it is null before using it. Replace the Center widget in the build method with the following code:

Center(
  child: _controller == null
      ? const CircularProgressIndicator()
      : PixelEditor(
          controller: _controller!,
          onSetPixel: (details) {
            // When a user clicks a pixel we will get a callback from the
            // PixelImageController, with information about the changed
            // pixel. When that happens we call the setPixels method on
            // the server.
            client.pixorama.setPixel(
              pixelIndex: details.tapDetails.index,
              colorIndex: details.colorIndex,
            );
          },
        ),
)

This will show a loading indicator while the app is waiting for the server to send the image data. When the image data is received, it will show the PixelEditor widget.

Now we need to call the _listenToUpdates method when the app starts to start listening for updates from the server. Add the following code to the initState method:

  @override
  void initState() {
    super.initState();

    // Connect to the server and start listening to updates.
    _listenToUpdates();
  }

Now we need to build and copy the app to the server again.

# In the pixorama_flutter directory
flutter build web
# In the serverpod project directory
cp -r pixorama_flutter/build/web/ pixorama_server/web/app

Then start the server by running the following command in the pixorama_server directory:

# In the pixorama_server directory
dart bin/main.dart

Then open your browser and go to http://localhost:8081. You should see the app running. If you open the app in multiple tabs, you should see that the changes are reflected in all tabs.

And that should be it! Nice work! πŸŽ‰πŸš€

About

A step by step course of how to build pixorama

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published