Skip to content
Orvanov0.2.0

Flutter quickstart

You build a Flutter app where people sign up with an email and a password, sign in, see their name and email, and sign out. It uses orvano_flutter 0.2.0, runs on the web, iOS, and Android from one codebase, and keeps the session in the platform’s secure storage (the Keychain on iOS, the Keystore on Android). It takes about fifteen minutes.

The finished app is examples/flutter-quickstart in the Orvano repo. Every file below comes from it, and CI runs it on every change, on Chrome, and every night on the iOS simulator and the Android emulator.

If Orvano isn’t running on your machine yet, run these three commands. Run Orvano locally explains each one.

Terminal window
mkdir orvano-local && cd orvano-local
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/install" ghcr.io/orvanohq/orvano:0.2.0 install --local --yes
docker compose up -d --wait

You should see every service report Healthy, and the console open at http://localhost:7700.

  1. Open the setup link the installer printed and create the first admin. You land in your personal org.

    The setup page with a name, email, and password filled in, above the Create the first admin button.The setup page with a name, email, and password filled in, above the Create the first admin button.
  2. Select Create project, name it, and select Create project again. Setting it up takes a few seconds.

    The Create a project dialog with the name My app.The Create a project dialog with the name My app.
  3. On the project’s overview, copy the Project ID. Your app sends it with every request.

    The project overview, showing the project ID with a copy button.The project overview, showing the project ID with a copy button.
  4. Open Platforms and add one platform for each place your app runs. Select Add platform, pick the type, name it, enter its ID, and select Add platform. This app’s iOS bundle ID and Android package name are both dev.orvano.quickstart.

    Keep the type Web and enter localhost as the hostname. Browsers send the page’s origin with every call, and Orvano answers only your Web platforms’ origins. localhost covers every port, so it covers flutter run on 5050.

    The Add a platform dialog with the type Web, the name Local web, and the hostname localhost.The Add a platform dialog with the type Web, the name Local web, and the hostname localhost.

Create the app with the same bundle ID and package name you registered, then add orvano_flutter, pinned to 0.2.0, the version of the Orvano you’re running:

Terminal window
flutter create --org dev.orvano --project-name quickstart --platforms web,ios,android flutter_quickstart
cd flutter_quickstart
flutter pub add orvano_flutter:0.2.0

Your pubspec.yaml now has:

pubspec.yaml
dependencies:
flutter:
sdk: flutter
orvano_flutter: 0.2.0

If you start from your own app instead, register its own bundle ID and package name in step 2.

Your local Orvano answers on http://localhost:7700, plain HTTP. Each platform needs a small change to reach it while you develop.

Nothing to change. Run the app on port 5050 (step 7), which the Web platform localhost covers.

The app reads its settings at build time, from --dart-define flags:

lib/config.dart
/// Where Orvano runs: `--dart-define=ORVANO_ENDPOINT=...`, the local stack by
/// default. On the Android emulator, `adb reverse` makes `localhost` reach it.
const endpoint = String.fromEnvironment(
'ORVANO_ENDPOINT',
defaultValue: 'http://localhost:7700',
);
/// Your project's ID, from its overview in the Orvano console:
/// `--dart-define=ORVANO_PROJECT=...`.
const project = String.fromEnvironment('ORVANO_PROJECT');

main makes one client for the whole app. createClient keeps the session in secure storage and refreshes it when the app comes back to the front, so a signed in user stays signed in:

lib/main.dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
if (project.isEmpty) {
throw StateError(
'Run with --dart-define=ORVANO_PROJECT=<your project ID>.',
);
}
// One client for the whole app. It keeps the session in the platform's
// secure storage, and refreshes it when the app comes back to the front.
final orvano = Orvano(createClient(endpoint: endpoint, project: project));
runApp(QuickstartApp(orvano: orvano));
}
The whole lib/main.dart
lib/main.dart
import 'package:flutter/material.dart';
import 'package:orvano_flutter/orvano_flutter.dart';
import 'config.dart';
import 'home.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
if (project.isEmpty) {
throw StateError(
'Run with --dart-define=ORVANO_PROJECT=<your project ID>.',
);
}
// One client for the whole app. It keeps the session in the platform's
// secure storage, and refreshes it when the app comes back to the front.
final orvano = Orvano(createClient(endpoint: endpoint, project: project));
runApp(QuickstartApp(orvano: orvano));
}
/// The quickstart app.
class QuickstartApp extends StatelessWidget {
/// Creates the app around one Orvano client.
const QuickstartApp({super.key, required this.orvano});
/// The Orvano client every screen uses.
final Orvano orvano;
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Orvano Flutter quickstart',
theme: ThemeData(colorSchemeSeed: Colors.indigo),
darkTheme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
),
home: Home(orvano: orvano),
);
}

The home screen loads the signed in user when it opens. account.get throws a 401 when nobody is signed in, so it shows the forms then:

lib/home.dart
/// Loads the signed in user, or null when nobody is signed in.
Future<void> _refresh() async {
User? user;
if (await widget.orvano.client.getSession() != null) {
try {
user = await widget.orvano.account.get();
} on OrvanoException catch (error) {
if (error.status != 401) rethrow;
}
}
if (!mounted) return;
setState(() {
_loading = false;
_user = user;
});
}

account.create and account.createPasswordSession sign the user in, and the client stores the new session. account.deleteCurrentSession ends it on Orvano and forgets it on the device:

lib/home.dart
/// Creates the user and signs them in; the client stores the session.
Future<void> _signUp(String name, String email, String password) async {
await widget.orvano.account.create(
CreateAccountRequest(name: name, email: email, password: password),
);
await _refresh();
}
/// Signs an existing user in with their email and password.
Future<void> _signIn(String email, String password) async {
await widget.orvano.account.createPasswordSession(
CreatePasswordSessionRequest(email: email, password: password),
);
await _refresh();
}
/// Ends this session on Orvano and forgets it on the device.
Future<void> _signOut() async {
await widget.orvano.account.deleteCurrentSession();
await _refresh();
}

When someone is signed in, the screen shows their name and email:

lib/home.dart
Text(
"You're signed in",
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text('Name: ${user.name ?? 'No name'}'),
Text('Email: ${user.email ?? ''}'),
const SizedBox(height: 16),
FilledButton(
onPressed: _signOut,
child: const Text('Sign out'),
),

The forms catch OrvanoException and show its message, like a taken email or a wrong password.

The whole lib/home.dart, with the forms
lib/home.dart
import 'package:flutter/material.dart';
import 'package:orvano_flutter/orvano_flutter.dart';
/// Shows the signed in user, or the sign up and sign in forms.
class Home extends StatefulWidget {
/// Creates the home screen.
const Home({super.key, required this.orvano});
/// The app's Orvano client.
final Orvano orvano;
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
bool _loading = true;
User? _user;
@override
void initState() {
super.initState();
_refresh();
}
/// Loads the signed in user, or null when nobody is signed in.
Future<void> _refresh() async {
User? user;
if (await widget.orvano.client.getSession() != null) {
try {
user = await widget.orvano.account.get();
} on OrvanoException catch (error) {
if (error.status != 401) rethrow;
}
}
if (!mounted) return;
setState(() {
_loading = false;
_user = user;
});
}
/// Creates the user and signs them in; the client stores the session.
Future<void> _signUp(String name, String email, String password) async {
await widget.orvano.account.create(
CreateAccountRequest(name: name, email: email, password: password),
);
await _refresh();
}
/// Signs an existing user in with their email and password.
Future<void> _signIn(String email, String password) async {
await widget.orvano.account.createPasswordSession(
CreatePasswordSessionRequest(email: email, password: password),
);
await _refresh();
}
/// Ends this session on Orvano and forgets it on the device.
Future<void> _signOut() async {
await widget.orvano.account.deleteCurrentSession();
await _refresh();
}
@override
Widget build(BuildContext context) {
final user = _user;
return Scaffold(
appBar: AppBar(title: const Text('Orvano Flutter quickstart')),
body: SafeArea(
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(24),
children: user == null
? [
AuthForm(
title: 'Sign up',
askName: true,
onSubmit: (name, email, password) =>
_signUp(name, email, password),
),
const SizedBox(height: 32),
AuthForm(
title: 'Sign in',
onSubmit: (_, email, password) =>
_signIn(email, password),
),
]
: [
Text(
"You're signed in",
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text('Name: ${user.name ?? 'No name'}'),
Text('Email: ${user.email ?? ''}'),
const SizedBox(height: 16),
FilledButton(
onPressed: _signOut,
child: const Text('Sign out'),
),
],
),
),
);
}
}
/// A form with email and password fields (and a name, for sign up) that shows
/// Orvano's message when the call fails, like a taken email or a wrong password.
class AuthForm extends StatefulWidget {
/// Creates the form.
const AuthForm({
super.key,
required this.title,
required this.onSubmit,
this.askName = false,
});
/// The heading and the button's label.
final String title;
/// Whether the form has a name field.
final bool askName;
/// Called with the name (empty without a name field), email, and password.
final Future<void> Function(String name, String email, String password)
onSubmit;
@override
State<AuthForm> createState() => _AuthFormState();
}
class _AuthFormState extends State<AuthForm> {
final _name = TextEditingController();
final _email = TextEditingController();
final _password = TextEditingController();
bool _busy = false;
String? _error;
@override
void dispose() {
_name.dispose();
_email.dispose();
_password.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_busy = true;
_error = null;
});
try {
await widget.onSubmit(_name.text, _email.text, _password.text);
} on OrvanoException catch (error) {
if (mounted) setState(() => _error = error.message);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
if (widget.askName)
TextField(
key: Key('${widget.title} name'),
controller: _name,
decoration: const InputDecoration(labelText: 'Name'),
autofillHints: const [AutofillHints.name],
),
TextField(
key: Key('${widget.title} email'),
controller: _email,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
),
TextField(
key: Key('${widget.title} password'),
controller: _password,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
),
const SizedBox(height: 12),
FilledButton(
key: Key('${widget.title} button'),
onPressed: _busy ? null : _submit,
child: Text(widget.title),
),
if (_error case final error?)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
error,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
);
}

Run the app with your project ID:

Terminal window
flutter run -d chrome --web-port 5050 --dart-define=ORVANO_PROJECT=your-project-id
  1. Fill in the Sign up form and select Sign up. You should see “You’re signed in” with your name and email.
  2. Restart the app. You should still be signed in: the session is in secure storage.
  3. Select Sign out. You should see the two forms again.
  4. Fill in the Sign in form with the same email and password. You should see your name and email again.
  5. Try signing in with a wrong password. You should see Orvano’s message under the form.

The new user is in the console too: open your project’s Users page.