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
SignInScreenwith email + password fields, validation, and error display. - An
AuthProviderthat holds session state and exposessignIn/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
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
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
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
Test the cold-start path
Run the app, sign in, force-quit it, and reopen. The app should land directly on
HomeScreenwithout 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
Gate protected routes
For multi-screen apps, keep the gate in the navigator instead of swapping the home widget. Route guards work via an
onGenerateRoutethat checksauth.isAuthenticatedbefore 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.isAuthenticatedflips to false and theConsumer/watchrebuild causes the next navigation to land on the sign-in screen.
Common pitfalls
- Wrong endpoint:
/profile/customer/signinfor end-users,/profile/user/signinfor staff. Mixing them yields a token that won't work for the app's endpoints. See Authentication. - Forgetting
orgidon the sign-in request: the AppEngine controller requires the header even for the public endpoint. TheAuthService.signInCustomershown 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 influtter_secure_storage(Keychain / EncryptedSharedPreferences). Never the plain prefs box. - Holding tokens in widget state: the
AppmintHttpClientsingleton 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:
/profile/user/magic-link?email={email}&type=codeNo auth/profile/user/magic-link/redirectNo authThe 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.