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 …
Before anything,
- Create a new Google Cloud Project
- Setup the OAuth consent screen
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 andserverpod_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
- 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
totrivyal_flutter
dependencies - Add
serverpod_auth_google_flutter
totrivyal_flutter
dependencies. We will useSignInWithGoogleButton
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 toLoginScreen
orHomeScreen
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 totrivyal_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
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
intrivyal_server
dir to apply db migrations
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
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)
- 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
andflex_color_picker
to 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
- Create a new endpoint
trivyal_server/lib/src/endpoints/live_games_endpoint.dart
and copy the code https://github.com/Andrew-Bekhiet/trivyal/blob/master/trivyal_server/lib/src/endpoints/live_games_endpoint.dart
- 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.