Avatar of Andrew Bekhiet
Andrew Bekhiet

Deploy a Dart App on Railway, Part 2

On our previous article, we have successfully deployed a hello world Dart app to Railway using Serverpod

In this article, we’re going to actually build our small project: Trivyal, so let’s jump right in …

ā„¹ļø

FYI - To avoid visually overwhelming, I have not added all code blocks directly in the page. Instead, I will provide links to the relevant code to copy in the public repository.

Setting up Google sign in šŸ’¼

Before anything,

ā„¹ļø

To keep things simple we will assume we only target web for this article, but you can read the full instructions on this article from Serverpod docs

Please note that I changed the redirectUri from /googlesignIn in Serverpod’s article to /googleSignIn in this article (to save you hours from debugging a spelling error bug)

OAuth consent screen

After finishing OAuth consent screen setup do the following:

  • Go to Credentials and click ā€œCreate credentialā€ ⇒ ā€œOAuth Client IDā€
  • Under ā€œApplication typeā€ choose ā€œWeb applicationā€
  • Give the client a name and leave Authorized JavaScript origins and authorized redirect URIs blank for now
  • Click ā€œCreateā€ then ā€œDownload JSONā€ and save it to trivyal_server/config/google_client_secret.json
  • Make sure to add config/google_client_secret.json to your .gitignore so you don’t accidentally add it to git

Server code setup

We will be using serverpod_auth_server module to setup authentication in our server

  • Add serverpod_auth_server to server dependencies and serverpod_auth_client to client dependencies
  • Make sure that all related serverpod dependencies have the exact same version across the project to avoid conflicts
  • Edit trivyal_server/lib/server.dart to match the following:
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_server/serverpod_auth_server.dart' as auth;  // Added!
import 'package:trivyal_server/src/web/routes/root.dart';

import 'src/generated/endpoints.dart';
import 'src/generated/protocol.dart';

// This is the starting point of your Serverpod server. In most cases, you will
// only need to make additions to this file if you add future calls,  are
// configuring Relic (Serverpod's web-server), or need custom setup work.

void run(List<String> args) async {
  // Initialize Serverpod and connect it with your generated code.
  final pod = Serverpod(
    args,
    Protocol(),
    Endpoints(),
    authenticationHandler: auth.authenticationHandler, // Added!
  );

  pod.webServer.addRoute(auth.RouteGoogleSignIn(), '/googleSignin'); // Added!

  // If you are using any future calls, they need to be registered here.
  // pod.registerFutureCall(ExampleFutureCall(), 'exampleFutureCall');

  // If you are using any future calls, they need to be registered here.
  // pod.registerFutureCall(ExampleFutureCall(), 'exampleFutureCall');

  // Serve all files in the trivyal_server/web directory.
  // This directory will be populated by Docker with our flutter web build.
  pod.webServer.addRoute(
    RouteStaticDirectory(
      serverDirectory: '.',
      serveAsRootPath: '/index.html',
      basePath: '/',
    ),
    '/*',
  );

  // Start the server.
  await pod.start();
}
  • Run serverpod create-migration to create a new database migration to be applied later

Flutter setup šŸ“±

Let’s work on our Flutter app!

Oauth configuration

On the credentials page in GCP, edit the server OAuth client we created earlier

  • Add the following as as Authorized JavaScript origins
    • http://localhost:8082
    • http://localhost:49660
    • https://<railway-provided-domain>
  • Add the following as authorized redirect URIs
    • http://localhost:8082
    • http://localhost:49660
    • http://localhost:8082/googleSignIn
    • https://<railway-provided-domain>/googleSignIn
Adding origins and redirect URIs in GCP for OAuth
Adding origins and redirect URIs in GCP for OAuth
  • Save the updated JSON to trivyal_server/config/google_client_secret.json

Flutter app UI

We will use flutter_riverpod package for dependency injection in this tutorial, but you can use any other package

  • Add flutter_riverpod to trivyal_flutter dependencies
  • Add serverpod_auth_google_flutter to trivyal_flutter dependencies. We will use SignInWithGoogleButton from this package to handle the sign in logic
  • Add trivyal_flutter/lib/utils/providers.dart file with the following code:
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:serverpod_auth_google_flutter/serverpod_auth_google_flutter.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:trivyal_client/trivyal_client.dart';

enum AppEnvironment { dev, prod }

final Provider<AppEnvironment> appEnvironmentProvider = Provider<AppEnvironment>((ref) => kDebugMode ? AppEnvironment.dev : AppEnvironment.prod);

final Provider<String> serverUrlProvider = Provider<String>(
  (ref) => ref.watch(appEnvironmentProvider) == AppEnvironment.dev
      ? 'http://$localhost:8080/'
      : 'https://example.com/api/',
);
final Provider<String> serverWebServerUrlProvider = Provider<String>(
  (ref) => ref.watch(appEnvironmentProvider) == AppEnvironment.dev
      ? 'http://$localhost:8082/'
      : 'https://example.com/',
);

final Provider<Client> clientProvider = Provider<Client>(
  (ref) => Client(ref.watch(serverUrlProvider), authenticationKeyManager: FlutterAuthenticationKeyManager())..connectivityMonitor = FlutterConnectivityMonitor(),
);

final ChangeNotifierProvider<SessionManager> sessionManagerProvider =
    ChangeNotifierProvider<SessionManager>(
  (ref) {
    final sessionManager = SessionManager(caller: ref.watch(clientProvider).modules.auth);
    ref.onDispose(sessionManager.dispose);

    return sessionManager;
  },
);

Make sure to replace https://example.com with your Railway provided domain for the server

Notice that I moved the Serverpod client singleton to providers and added authenticationKeyManager to store authentication keys and tokens

Login and Home screens

In trivyal_flutter/lib/main.dart

  • Add a ProviderScope widget to the root of the app, and ensure the session manager is initialized. Then we can use the session manager to determine if the user is signed in or not and redirect to LoginScreen or HomeScreen
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:serverpod_auth_google_flutter/serverpod_auth_google_flutter.dart';

import 'auth/login_screen.dart';
import 'game_designer/home_screen.dart';
import 'utils/providers.dart';

void main() {
  runApp(const ProviderScope(child: TrivyalApp()));
}

class TrivyalApp extends ConsumerStatefulWidget {
  const TrivyalApp({super.key});

  @override
  ConsumerState<TrivyalApp> createState() => _TrivyalAppState();
}

class _TrivyalAppState extends ConsumerState<TrivyalApp> {
  late SessionManager sessionManager;
  Future<void>? initSessionFuture;

  @override
  Widget build(BuildContext context) {
    sessionManager = ref.watch(sessionManagerProvider);
    initSessionFuture ??= sessionManager.initialize();

    return FutureBuilder(
      future: initSessionFuture,
      builder: (context, sessionInitialized) {
        return MaterialApp(
          title: 'Trivyal',
          theme: ThemeData.from(
            colorScheme: ColorScheme.fromSeed(
              seedColor: Colors.deepPurple,
              brightness: PlatformDispatcher.instance.platformBrightness,
            ),
          ),
          home: sessionInitialized.connectionState == ConnectionState.done
              ? sessionManager.isSignedIn
                  ? const HomeScreen()
                  : const LoginScreen()
              : const Scaffold(body: Center(child: CircularProgressIndicator())),
        );
      },
    );
  }
}
  • Now copy the LoginScreen code from here to trivyal_flutter/lib/auth/login_screen.dart. This will allow the user to either enter a game pin or sign in with google:
  • Create trivyal_flutter/lib/tils/secrets.dart and add your Google Server Client Id that we created earlier:
const String googleServerClientId = '<Your google server client id>';

It should something like this <random-numbers>-<random-text>.apps.googleusercontent.com and should match the client id in trivyal_server/config/google_client_secret.json

ā•

Make sure to add lib/utils/secrets.dart to your trivyal_flutter/.gitignore

To compile the app add a home screen at trivyal_flutter/lib/game_designer/home_screen.dart:

import 'package:flutter/material.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(body: Center(child: Text('This is our home screen!')));
  }
}

Storing Google client secret

We will use Railway environment variables to store google client secret

  • Encode google_client_secret.json to base64 (It’s a best practice)
base64 -w 0 trivyal_server/config/google_client_secret.json
  • Copy the output and paste it in a new variable in Railway CONFIG_GOOGLE_CLIENT_SECRET
  • Open Dockerfile and add a step before starting the server to decode the secret to its destination:
...
EXPOSE 8082

ARG CONFIG_GOOGLE_CLIENT_SECRET
RUN echo $CONFIG_GOOGLE_CLIENT_SECRET | base64 -d > ./config/google_client_secret.json

ENTRYPOINT ./server --mode=$runmode --server-id=$serverid --logging=$logging --role=$role
  • Encode trivyal_flutter/lib/utils/secrets.dart to base64
base64 -w 0 trivyal_flutter/lib/utils/secrets.dart
  • Copy the output and paste it in a new variable in Railway UTILS_SECRETS_DART
  • Add the decoding command in Dockerfile before the web build:
...
ARG UTILS_SECRETS_DART

RUN echo $UTILS_SECRETS_DART | base64 -d > ./lib/utils/secrets.dart

RUN flutter pub get
RUN flutter build web
...

Deploying to Railway

Now let’s deploy to Railway!

  • Run dart bin/main.dart --mode production --apply-migrations in trivyal_server dir to apply db migrations
Don’t forget to run your database migration
Don’t forget to run your database migration
  • Deploy to Railway using railway up -d

Adding Game designer šŸŽØ

Let’s turn that empty home screen into games list.

Adding models

This is how our database should be structured:

Trivyal Database structure
Trivyal Database structure

So let’s create our first model: Game (we already created User in the previous step)

  • Create a new file trivyal_server/lib/src/models/game.spy.yaml
class: Game
table: games
fields:
  name: String
  owner: module:auth:UserInfo?, relation(onDelete=cascade)
  questions: List<Question>?, relation(name=games_questions)
ā„¹ļø

You can learn more about working with relationships in Serverpod at their documentation

  • Add trivyal_server/lib/src/models/question.spy.yaml
class: Question
table: questions
fields:
  gameId: int, relation(name=games_questions,parent=games, onDelete=cascade)
  text: String
  choices: List<Choice>
  correctChoiceId: int
  timeInSeconds: int
  • Add trivyal_server/lib/src/models/choice.spy.yaml
class: Choice
fields:
  id: int
  text: String
  color: int?
  • Add trivyal_server/lib/src/models/game_list_response.spy.yaml
class: GameListResponse
fields:
  data: List<Game>
  • Run serverpod generate to generate dart models and database mappings
  • Run serverpod create-migration to create db migration

Adding CRUD Game endpoint

  • Create a new file trivyal_server/lib/src/endpoints/games_endpoint.dart
  • Copy the code from here and paste it into the file you just created to add a GamesEndpoint class with the boring CRUD methods
  • Run serverpod generate to update the generated code

Edit game screen

Let’s change the home screen to show current user games using the GamesEndpoint we created in the previous step

  • Add material_symbols_icons to dependencies
  • Change trivyal_flutter/lib/game_designer/home_screen.dart to match this file
  • Create a new file trivyal_flutter/lib/game_designer/edit_game.dart
  • Copy the code here and paste it into the file you just created to add an EditGame screen

This screen creates a new game if the game has null id, otherwise updates the game name

Question editor widget

Now let’s add a question editor widget so we can add and edit questions

  • Add collection and flex_color_pickerto dependencies
  • Create a new file trivyal_flutter/ib/game_designer/question_widget.dart
  • Copy the code here and paste it into the file you just created

If you run the server and the app locally, you should see something like this:

edit-game-with-questions.gif

Hosting a live game 🌐

Let’s get the app setup to host live games.

Live Games models

To save live game state, we’ll need to make a new table

  • Create a new model trivyal_server/lib/src/models/live_game.spy.yaml
class: LiveGame
table: live_games
fields:
  pin: int
  gameId: int, relation(parent=games)
  players: List<String>
  currentStatus: LiveGameStatus
  currentQuestion: Question?
  currentQuestionIndex: int?
  currentQuestionShowTime: DateTime?
  currentResults: Map<String, int>
  playerAnswers: Map<String, int>
  playerAnswersTime: Map<String, DateTime>
indexes:
  pin_idx:
    fields: pin
    unique: true
  gameId_idx:
    fields: gameId
    unique: true
  • Add trivyal_server/lib/src/models/live_game_status.spy.yaml
enum: LiveGameStatus
serialized: byName
values:
  - lobby
  - starting
  - answeringQuestion
  - revealingAnswer
  - podium
  - results
  • Add trivyal_server/lib/src/models/live_game_admin_event.spy.yaml for sending admin events
enum: LiveGameAdminEvent
serialized: byName
values:
  - startGame
  - showPodium
  - nextQuestion
  • And trivyal_server/lib/src/models/live_game_answer.spy.yaml for players to send their answers
class: LiveGameAnswer
fields:
  player: String
  answerId: int
  • Run serverpod generate && serverpod create-migration

Live Games Endpoint

Now we will create LiveGames endpoint which will handle game events in realtime and broadcast current state to all clients.

LiveGames endpoint explained
LiveGames endpoint explained

Start button!

  • Go to trivyal_flutter/lib/game_designer/home_screen.dart and add a start button to start hosting a live game!
          ...
          return ListView.builder(
              ...
              return ListTile(
                title: Text(game.name),
                onTap: () => Navigator.of(context).push(
                  MaterialPageRoute(builder: (context) => EditGame(game: game)),
                ),
                trailing: IconButton(
                  icon: Icon(Symbols.play_circle),
                  tooltip: 'Start live game',
                  onPressed: () async {
                    final navigator = Navigator.of(context);

                    final streamController = StreamController<LiveGameAdminEvent>();

                    try {
                      final liveGameStream = client.liveGames.startOrJoinLiveGame(
                        gameId: game.id!,
                        adminMessages: streamController.stream,
                      );

                      // TODO: add game shell
                      // Navigator.push(
                      //   context,
                      //   MaterialPageRoute(
                      //     builder: (context) => GameShell(
                      //       liveGameStream: liveGameStream,
                      //       liveGamePlayerSink: streamController.sink,
                      //     ),
                      //   ),
                      // );
                    } catch (_) {
                      streamController.close();
                    }
                  },
                ),
              );
            },
          );
          ...

Gameplay UI šŸŽ®

Now we’ll setup the gameplay UI.

Joining a Live Game

  • Go to trivyal_flutter/lib/auth/login_screen.dart and add the enter button code:
...
FilledButton(
  child: const Text('Enter!'),
  onPressed: () {
    if (!Form.of(context).validate()) return;

    final scaffoldMessenger = ScaffoldMessenger.of(context);
    final streamController = StreamController<int>();
    try {
      final liveGameStream = client.liveGames.joinLiveGame(
        pin: int.parse(_pinController.text),
        playerName: _nameController.text,
        answerIdStream: streamController.stream,
      );

      Navigator.push(
        context,
        MaterialPageRoute(
          builder: (context) => GameShell(
            playerName: _nameController.text,
            liveGameStream: liveGameStream,
            liveGamePlayerSink: streamController.sink,
          ),
        ),
      );
    } catch (e) {
      streamController.close();

      if (e.toString().contains('Live game not found')) {
        scaffoldMessenger.showSnackBar(SnackBar(
          content: Text('Live game not found'),
          backgroundColor: Colors.red,
        ));
      }
    }
  },
);
...

Game Shell screen

This screen will listen to the game stream and show a widget based on current state

  • Create a new file trivyal_flutter/lib/gameplay/game_shelll_screen.dart
  • Copy the code from here and paste it into the file you just created

Lobby widget

This is where players will be waiting until the admin presses start!

  • Create a new file trivyal_flutter/lib/gameplay/lobby_widget.dart and add the following code
import 'package:flutter/material.dart';
import 'package:trivyal_client/trivyal_client.dart';

class LobbyWidget extends StatelessWidget {
  final LiveGame liveGame;

  const LobbyWidget({required this.liveGame, super.key});

  @override
  Widget build(BuildContext context) {
    if (liveGame.players.isEmpty) {
      return const Center(child: Text('Waiting for players to join the game...'));
    }

    return ListView.builder(
      itemCount: liveGame.players.length,
      itemBuilder: (context, index) => ListTile(title: Text(liveGame.players[index])),
    );
  }
}

Live Game starting widget

Just a small 3s countdown animation

  • Create a new file trivyal_flutter/lib/gameplay/live_game_starting_widget.dart and add the following code:
import 'package:flutter/material.dart';

class LiveGameStartingWidget extends StatefulWidget {
  const LiveGameStartingWidget({super.key});

  @override
  State<LiveGameStartingWidget> createState() => _LiveGameStartingWidgetState();
}

class _LiveGameStartingWidgetState extends State<LiveGameStartingWidget> with SingleTickerProviderStateMixin {
  late final AnimationController _animationController = AnimationController(vsync: this, duration: Duration(seconds: 3))..forward();

  late final Animation<int> countdownAnimation = _animationController.drive(
    TweenSequence([
      TweenSequenceItem(tween: ConstantTween(3), weight: 1),
      TweenSequenceItem(tween: ConstantTween(2), weight: 1),
      TweenSequenceItem(tween: ConstantTween(1), weight: 1),
    ]),
  );

  late final Animation<double> scaleAnimation = _animationController
      .drive(
        TweenSequence<double>([
          TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 1),
          TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 1),
          TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 1),
        ]),
      ).drive(CurveTween(curve: Curves.fastOutSlowIn));

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: _animationController,
        builder: (context, child) {
          return Transform.scale(
            scale: scaleAnimation.value,
            child: Text(
              countdownAnimation.value.toString(),
              textAlign: TextAlign.center,
              style: TextStyle(fontSize: 80),
            ),
          );
        },
      ),
    );
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }
}

Answering Questions widget

The actual question and answers, and the correct answer when answers are revealed

  • Create a new file trivyal_flutter/lib/gameplay/answering_questions_widget.dart
  • Copy the code from here and paste it into the file you just created

Podium Widget

Animates players’ scores and their ranks so far

  • Create a new file trivyal_flutter/lib/gameplay/podium_widget.dart
  • Copy the code from here and paste it into the file you just created

Rank widget

Shows the final rank of each player on their screen

  • Create trivyal_flutter/lib/gameplay/rank_widget.dart and add the following code:
import 'package:flutter/material.dart';

class RankWidget extends StatelessWidget {
  final int rank;

  const RankWidget({super.key, required this.rank});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            'You are #',
            textAlign: TextAlign.center,
            style: Theme.of(context).textTheme.bodyLarge,
          ),
          Text(
            rank.toString(),
            textAlign: TextAlign.center,
            style: Theme.of(context).textTheme.displayLarge,
          ),
        ],
      ),
    );
  }
}

Putting it all together ✨

After finishing the endpoint and the UI code, we’re ready to railway up -d and play!

trivyal-demo.gif

And that was how to deploy a Flutter/Dart app to Railway!

I hope you enjoyed reading the article as much as I enjoyed writing it!

Useful resources šŸ“–

Here are some helpful resources if you have any trouble.