Documentation

Build a sign-in flow

A complete login screen, token storage, and route gating, end to end.

This recipe assembles the pieces from the Flutter SDK section into a working sign-in screen and route gate. The result: a user can enter email + password, the app stores tokens securely, and protected screens are inaccessible until they do.

It targets a customer app (/profile/customer/signin). Swap to /profile/user/signin for staff. The shape is identical.

What you'll build

  • A SignInScreen with email + password fields, validation, and error display.
  • An AuthProvider that holds session state and exposes signIn / signOut.
  • A root gate that routes to home or sign-in based on isAuthenticated.
  • Restored sessions on cold start — no re-prompting if the JWT is still valid.

The full code lives in appmint_mobile/lib/screens/auth/login_screen.dart and the shape below is the consumer-app equivalent.

Project layout

lib/
├── config/
│   └── environment.dart
├── models/
│   └── auth_response.dart
├── services/
│   ├── auth_service.dart
│   └── http_client.dart
├── providers/
│   └── auth_provider.dart
├── screens/
│   ├── auth/
│   │   └── sign_in_screen.dart
│   └── home_screen.dart
└── main.dart

http_client.dart, environment.dart, and auth_service.dart are covered in the SDK pages — assume they exist.

Step-by-step

  1. 1

    Wire AuthProvider into main.dart

    Bootstrap auth before the first frame so the gate sees the right state.

    // lib/main.dart
    import 'package:flutter/foundation.dart';
    import 'package:flutter/material.dart';
    import 'package:provider/provider.dart';
    
    import 'config/environment.dart';
    import 'providers/auth_provider.dart';
    import 'screens/auth/sign_in_screen.dart';
    import 'screens/home_screen.dart';
    
    Future<void> main() async {
      WidgetsFlutterBinding.ensureInitialized();
      EnvironmentConfig.setEnvironment(
        kReleaseMode ? Environment.production : Environment.development,
      );
    
      final auth = AuthProvider();
      await auth.bootstrap();
    
      runApp(
        ChangeNotifierProvider.value(value: auth, child: const MyApp()),
      );
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'My App',
          home: Consumer<AuthProvider>(
            builder: (_, auth, __) =>
                auth.isAuthenticated ? const HomeScreen() : const SignInScreen(),
          ),
        );
      }
    }
    
  2. 2

    Build the sign-in screen

    Validate inputs, show inline errors, and disable the submit button while the request is in flight.

    // lib/screens/auth/sign_in_screen.dart
    import 'package:flutter/material.dart';
    import 'package:provider/provider.dart';
    
    import '../../providers/auth_provider.dart';
    
    class SignInScreen extends StatefulWidget {
      const SignInScreen({super.key});
      @override
      State<SignInScreen> createState() => _SignInScreenState();
    }
    
    class _SignInScreenState extends State<SignInScreen> {
      final _formKey = GlobalKey<FormState>();
      final _orgId = TextEditingController(text: 'demo');
      final _email = TextEditingController();
      final _password = TextEditingController();
    
      bool _busy = false;
      String? _error;
    
      @override
      void dispose() {
        _orgId.dispose();
        _email.dispose();
        _password.dispose();
        super.dispose();
      }
    
      Future<void> _submit() async {
        if (!_formKey.currentState!.validate()) return;
        setState(() {
          _busy = true;
          _error = null;
        });
    
        try {
          await context.read<AuthProvider>().signIn(
                orgId: _orgId.text.trim(),
                email: _email.text.trim(),
                password: _password.text,
              );
          // The MaterialApp Consumer will swap to HomeScreen automatically.
        } catch (e) {
          setState(() => _error = _humanize(e));
        } finally {
          if (mounted) setState(() => _busy = false);
        }
      }
    
      String _humanize(Object e) {
        final s = e.toString();
        if (s.contains('401') || s.contains('Invalid')) {
          return 'Wrong email or password.';
        }
        if (s.contains('SocketException')) return 'No connection.';
        return 'Could not sign in. Try again.';
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(24),
              child: Form(
                key: _formKey,
                child: Column(
                  children: [
                    const SizedBox(height: 32),
                    const Text(
                      'Sign in',
                      style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700),
                    ),
                    const SizedBox(height: 24),
                    TextFormField(
                      controller: _orgId,
                      decoration: const InputDecoration(labelText: 'Workspace'),
                      validator: (v) =>
                          (v == null || v.isEmpty) ? 'Required' : null,
                    ),
                    const SizedBox(height: 12),
                    TextFormField(
                      controller: _email,
                      decoration: const InputDecoration(labelText: 'Email'),
                      keyboardType: TextInputType.emailAddress,
                      autocorrect: false,
                      validator: (v) =>
                          (v == null || !v.contains('@')) ? 'Email required' : null,
                    ),
                    const SizedBox(height: 12),
                    TextFormField(
                      controller: _password,
                      decoration: const InputDecoration(labelText: 'Password'),
                      obscureText: true,
                      validator: (v) =>
                          (v == null || v.length < 6) ? 'Min 6 characters' : null,
                    ),
                    if (_error != null) ...[
                      const SizedBox(height: 16),
                      Text(_error!,
                          style: const TextStyle(color: Colors.red, fontSize: 14)),
                    ],
                    const SizedBox(height: 24),
                    FilledButton(
                      onPressed: _busy ? null : _submit,
                      child: _busy
                          ? const SizedBox(
                              width: 18,
                              height: 18,
                              child: CircularProgressIndicator(
                                  strokeWidth: 2, color: Colors.white))
                          : const Text('Sign in'),
                    ),
                  ],
                ),
              ),
            ),
          ),
        );
      }
    }
    
  3. 3

    Define the home screen with sign-out

    A bare-bones home screen that shows the user's email and a sign-out button.

    // lib/screens/home_screen.dart
    import 'package:flutter/material.dart';
    import 'package:provider/provider.dart';
    
    import '../providers/auth_provider.dart';
    
    class HomeScreen extends StatelessWidget {
      const HomeScreen({super.key});
      @override
      Widget build(BuildContext context) {
        final auth = context.watch<AuthProvider>();
        return Scaffold(
          appBar: AppBar(
            title: const Text('Home'),
            actions: [
              IconButton(
                tooltip: 'Sign out',
                icon: const Icon(Icons.logout),
                onPressed: () => context.read<AuthProvider>().signOut(),
              ),
            ],
          ),
          body: Center(
            child: Text('Signed in as ${auth.user?['email'] ?? '...'}'),
          ),
        );
      }
    }
    
  4. 4

    Test the cold-start path

    Run the app, sign in, force-quit it, and reopen. The app should land directly on HomeScreen without prompting again — AuthProvider.bootstrap() reads the JWT from secure storage, validates it (or refreshes), and starts the session.

    If the refresh token has been revoked or has expired, the bootstrap silently fails and the app shows the sign-in screen. No error toast — that's the right experience for a returning user whose session simply timed out.

  5. 5

    Gate protected routes

    For multi-screen apps, keep the gate in the navigator instead of swapping the home widget. Route guards work via an onGenerateRoute that checks auth.isAuthenticated before delivering the route:

    class MyApp extends StatelessWidget {
      const MyApp({super.key});
      @override
      Widget build(BuildContext context) {
        final auth = context.watch<AuthProvider>();
        return MaterialApp(
          onGenerateRoute: (settings) {
            if (!auth.isAuthenticated) {
              return MaterialPageRoute(builder: (_) => const SignInScreen());
            }
            switch (settings.name) {
              case '/':
                return MaterialPageRoute(builder: (_) => const HomeScreen());
              case '/leads':
                return MaterialPageRoute(builder: (_) => const LeadsScreen());
              default:
                return MaterialPageRoute(builder: (_) => const HomeScreen());
            }
          },
        );
      }
    }
    

    When signOut() is called, auth.isAuthenticated flips to false and the Consumer/watch rebuild causes the next navigation to land on the sign-in screen.

Common pitfalls

  • Wrong endpoint: /profile/customer/signin for end-users, /profile/user/signin for staff. Mixing them yields a token that won't work for the app's endpoints. See Authentication.
  • Forgetting orgid on the sign-in request: the AppEngine controller requires the header even for the public endpoint. The AuthService.signInCustomer shown in the SDK page calls _http.setAuth('', orgId) before the POST to attach the header without a JWT.
  • Trying to read tokens from shared_preferences: tokens go in flutter_secure_storage (Keychain / EncryptedSharedPreferences). Never the plain prefs box.
  • Holding tokens in widget state: the AppmintHttpClient singleton is the source of truth for the current access token. State that lives elsewhere will go stale on refresh.

Optional: magic-link sign-in

For a passwordless flow, AppEngine exposes a magic-code endpoint:

GET/profile/user/magic-link?email={email}&type=codeNo auth
POST/profile/user/magic-link/redirectNo auth

The first sends a 6-digit code by email; the second accepts { email, code } and returns the same AuthResponse shape as password sign-in. Wire it the same way — only the screen changes.

Future<void> sendCode(String email) async {
  await _http.get('/profile/user/magic-link',
      queryParams: {'email': email, 'type': 'code'},
      requireAuth: false);
}

Future<AuthResponse> verifyCode(String email, String code) async {
  final response = await _http.post('/profile/user/magic-link/redirect',
      body: {'email': email, 'code': code}, requireAuth: false);
  return AuthResponse.fromJson(response);
}

The customer-side variant is /profile/customer/magic-link/* if you want passwordless for end-users.

With sign-in working, the next recipe — Browse products — adds a real protected screen that reads from the storefront.