By the end of this you will have a Flutter app that signs a real person in against a real AppEngine organization, handles a verification code if their organization asks for one, keeps them signed in, and reads data as them.
It is about 200 lines in a single file. Every step below has been run exactly as written.
Flutter 3.27 or newer, and an organization with an app credential —
appId, appKey, appSecret — created in Studio Manager. The app cannot
talk to AppEngine at all without those three, so get them before you start.
Step 1 — Create the project
flutter create --org io.appmint --project-name my_appmint_app my_appmint_app
cd my_appmint_appStep 2 — Add the client
In pubspec.yaml, under dependencies:
dependencies:
flutter:
sdk: flutter
appmint_flutter_client:
git:
url: https://github.com/JacLight/appmint-client.git
path: appmint_flutter_clientflutter pub getStep 3 — Connect to your organization
Replace lib/main.dart with this. It does nothing yet except configure the
client and put a sign-in form on screen.
import 'package:appmint_flutter_client/appmint_flutter_client.dart';
import 'package:flutter/material.dart';
/// Credentials come in at build time, so nothing secret lives in your repository.
const config = AppmintConfig(
baseUrl: String.fromEnvironment('APPMINT_URL'),
orgId: String.fromEnvironment('APPMINT_ORG'),
appId: String.fromEnvironment('APPMINT_APP_ID'),
appKey: String.fromEnvironment('APPMINT_APP_KEY'),
appSecret: String.fromEnvironment('APPMINT_APP_SECRET'),
logRequests: true,
);
final appmint = Appmint(config);
void main() => runApp(const MyApp());That is all the app authentication you will write. The client fetches the app
token on the first call, renews it when it expires, and stamps orgid on
everything. You never touch a header.
Hardcoding them puts your organization's credentials in your git history, and in a public repository that is permanent. Note also that in a shipped mobile binary these are not secret — anyone can read them out of an APK. They identify your app; they do not protect it. What protects a request is the signed-in person's token.
Step 4 — Sign somebody in
Here is the part that matters, and the part every hand-rolled client gets wrong.
Sign-in has three endings, not two. Success, refusal, and a verification
code is needed. That third one comes back as an ordinary 200 with no token in
it — treat it as success and you get an app that looks signed in, holds nothing,
and throws the person out on the next call.
The client returns a sealed result, so the compiler will not let you forget:
Future<void> _signIn() async {
setState(() { _busy = true; _error = null; });
final result = await appmint.customers.signIn(
_email.text.trim(),
_password.text,
);
if (!mounted) return;
switch (result) {
case SignedIn():
// Nothing to do — the auth stream rebuilds onto the session page.
break;
case NeedsVerification(:final challenge):
setState(() => _busy = false);
final finished = await _askForCode(challenge);
if (!mounted) return;
if (finished is SignInRejected) setState(() => _error = finished.message);
return;
case SignInRejected(:final message):
setState(() => _error = message);
}
if (mounted) setState(() => _busy = false);
}appmint.customers signs in the people you serve. For employees, managers and
administrators use appmint.staff — they are different records on the server
with different sign-in routes, and they are not interchangeable.
message on a refusal is the server's own wording and is safe to show. It says
things like "bad password" rather than a code you have to translate.
Step 5 — Handle the verification code
A challenge carries everything needed to finish, so you never handle a token:
class _CodeDialog extends StatefulWidget {
const _CodeDialog({required this.challenge});
final VerificationChallenge challenge;
@override
State<_CodeDialog> createState() => _CodeDialogState();
}
class _CodeDialogState extends State<_CodeDialog> {
final _code = TextEditingController();
bool _busy = false;
String? _error;
@override
void dispose() { _code.dispose(); super.dispose(); }
Future<void> _verify() async {
setState(() { _busy = true; _error = null; });
final result = await widget.challenge.verify(_code.text.trim());
if (!mounted) return;
switch (result) {
case SignedIn():
Navigator.pop(context, result);
case SignInRejected(:final message):
// The challenge is still good — let them try again.
setState(() { _busy = false; _error = message; });
case NeedsVerification():
setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final challenge = widget.challenge;
return AlertDialog(
title: const Text('Verification required'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(challenge.message),
const SizedBox(height: 12),
TextField(
controller: _code,
autofocus: true,
maxLength: 6,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration:
const InputDecoration(hintText: '000000', counterText: ''),
),
if (_error != null)
Text(_error!, style: const TextStyle(color: Colors.red)),
if (challenge.method.canResend)
TextButton(
onPressed: _busy ? null : challenge.resend,
child: const Text('Send it again'),
),
],
),
actions: [
TextButton(
onPressed: _busy
? null
: () { challenge.cancel(); Navigator.pop(context); },
child: const Text('Cancel'),
),
FilledButton(
onPressed: _busy ? null : _verify,
child: const Text('Verify'),
),
],
);
}
}Three details that make the difference between a dialog people can use and one they get stuck in:
A wrong code is not the end. verify returns SignInRejected and the
challenge stays alive — keep the dialog open so they can try again.
challenge.method.canResend is false for an authenticator app. That code is
computed on their device; offering "send it again" would be a lie.
Give them a way out. If their second factor is on a phone that is lost or
flat, challenge.sendByAnotherMethod(VerificationMethod.email) sends a code
somewhere they can reach. Any enrolled factor can answer the challenge.
Step 6 — Show the session, and read something
Wrap the app in the auth stream so it swaps pages by itself:
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: StreamBuilder<AppmintUser?>(
stream: appmint.auth.changes,
initialData: appmint.auth.currentUser,
builder: (context, snapshot) {
final user = snapshot.data;
return user == null ? const SignInPage() : SessionPage(user: user);
},
),
);
}
}Then read data as that person. AppEngine has no fixed set of tables — every record has a datatype, and the same call works on all of them, including ones you define:
Future<void> _load() async {
try {
final page = await appmint.repository.find('setting', pageSize: 5);
setState(() => _status = 'Read ${page.items.length} of ${page.total}.');
} on AppmintException catch (e) {
setState(() => _status = e.message);
}
}Step 7 — Stay signed in, and sign out
One line at startup brings back a stored session:
@override
void initState() {
super.initState();
appmint.auth.restore();
}And signing out is one call:
IconButton(
icon: const Icon(Icons.logout),
onPressed: appmint.auth.signOut,
)signOut clears the tokens and the cached person and nothing else. It will
not empty your preference store — an app keeps its own settings there, and
wiping them on sign-out has in practice left a configured till unable to trade
until somebody re-paired the hardware.
Run it
flutter run -d chrome \
--dart-define=APPMINT_URL=https://appengine.appmint.io \
--dart-define=APPMINT_ORG=your-org \
--dart-define=APPMINT_APP_ID=your-app-id \
--dart-define=APPMINT_APP_KEY=your-app-key \
--dart-define=APPMINT_APP_SECRET=your-app-secretWhat you should see, in order:
- A sign-in form.
- On Sign in,
[appmint] POST /profile/app/keyin the console — the app authenticating itself, which you never wrote code for — thenPOST /profile/customer/signin. - Either the session page, or the verification dialog if the organization asks for a code.
- After Load settings, something like
Read 2 of 2.
When it does not work
| What you see | What it means | What to do |
|---|---|---|
| The app could not authenticate with appengine. Check appId, key, secret and orgId | The three credentials or the org id are wrong | Check them against Studio Manager. Nothing will work until this passes |
| Could not reach appengine at … | The request never left — wrong URL, or nothing listening | Check APPMINT_URL, including the scheme and no trailing slash |
| bad password | The server refused the sign-in | The credentials are the person's, not the app's |
| The dialog appears but no code arrives | The organization sent it to the address on that account | Check the account's email; on a dev deployment mail may not be delivered at all |
| That code is not right | Wrong code, and the challenge is still alive | Let them retype it — do not close the dialog |
| That verification session has expired | More than ten minutes passed | Start the sign-in again |
| Please sign in again mid-session | The session could not be renewed | Expected after a long gap; send them to sign-in |
Set logRequests: true (as above) and every call prints with its status. To put
that on screen instead, use appmint.http.onCall — see
watching what it does.