Avatar of Andrew BekhietAndrew 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.

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)

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

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

Let’s work on our Flutter app!

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

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

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!')));
  }
}

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
...

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

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

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
  • 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

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

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:

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

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

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

  • 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();
                    }
                  },
                ),
              );
            },
          );
          ...

Now we’ll setup the gameplay UI.

  • 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,
        ));
      }
    }
  },
);
...

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

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])),
    );
  }
}

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();
  }
}

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

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

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,
          ),
        ],
      ),
    );
  }
}

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

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!

Here are some helpful resources if you have any trouble.