docs
/
Client Integration

Flutter tutorial

Build a working Flutter app on AppEngine from an empty folder — sign-in, verification codes, a session and a real read — in about twenty minutes.

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.

What you need first

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_app

Step 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_client
flutter pub get

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

Why the credentials come from `--dart-define`

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

What you should see, in order:

  1. A sign-in form.
  2. On Sign in, [appmint] POST /profile/app/key in the console — the app authenticating itself, which you never wrote code for — then POST /profile/customer/signin.
  3. Either the session page, or the verification dialog if the organization asks for a code.
  4. After Load settings, something like Read 2 of 2.

When it does not work

What you seeWhat it meansWhat to do
The app could not authenticate with appengine. Check appId, key, secret and orgIdThe three credentials or the org id are wrongCheck them against Studio Manager. Nothing will work until this passes
Could not reach appengine at …The request never left — wrong URL, or nothing listeningCheck APPMINT_URL, including the scheme and no trailing slash
bad passwordThe server refused the sign-inThe credentials are the person's, not the app's
The dialog appears but no code arrivesThe organization sent it to the address on that accountCheck the account's email; on a dev deployment mail may not be delivered at all
That code is not rightWrong code, and the challenge is still aliveLet them retype it — do not close the dialog
That verification session has expiredMore than ten minutes passedStart the sign-in again
Please sign in again mid-sessionThe session could not be renewedExpected 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.

Where to go next