Add login screen with biometric setup flow

- Created AuthService for managing login state
- Created LoginScreen with email/password form
- Auto-prompts to enable biometric after first login
- Integrated login flow into main.dart
- Shows splash screen during initialization
- AuthGate wraps main app for biometric re-auth
This commit is contained in:
Johan Jongsma 2026-02-01 08:50:07 +00:00
parent 3e90415dcd
commit d44b3f6a0e
3 changed files with 638 additions and 29 deletions

View File

@ -0,0 +1,381 @@
import 'package:flutter/material.dart';
import '../../core/theme.dart';
import '../../services/auth_service.dart';
import '../../services/biometric_service.dart';
/// Login screen with email/password and biometric option
class LoginScreen extends StatefulWidget {
final VoidCallback onLoginSuccess;
const LoginScreen({
super.key,
required this.onLoginSuccess,
});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _authService = AuthService();
final _biometricService = BiometricService();
bool _isLoading = false;
bool _obscurePassword = true;
bool _canUseBiometric = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_checkBiometricAvailability();
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _checkBiometricAvailability() async {
final canUse = await _authService.canUseBiometricLogin();
if (mounted) {
setState(() => _canUseBiometric = canUse);
// Auto-trigger biometric if available
if (canUse) {
_attemptBiometricLogin();
}
}
}
Future<void> _attemptBiometricLogin() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final success = await _authService.biometricLogin();
if (!mounted) return;
setState(() => _isLoading = false);
if (success) {
widget.onLoginSuccess();
}
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
final success = await _authService.login(
_emailController.text.trim(),
_passwordController.text,
);
if (!mounted) return;
setState(() => _isLoading = false);
if (success) {
// Check if we should offer biometric setup
final biometricAvailable = await _biometricService.isBiometricsAvailable();
final setupCompleted = await _authService.hasBiometricSetupCompleted;
if (biometricAvailable && !setupCompleted && mounted) {
await _showBiometricSetupDialog();
}
if (mounted) {
widget.onLoginSuccess();
}
} else {
setState(() {
_errorMessage = _authService.error ?? 'Login failed';
});
}
}
Future<void> _showBiometricSetupDialog() async {
final biometricName = await _biometricService.getBiometricTypeName();
if (!mounted) return;
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text('Enable $biometricName?'),
content: Text(
'Would you like to use $biometricName to quickly log in next time?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Not now'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Enable'),
),
],
),
);
if (result == true) {
await _authService.enableBiometric();
} else {
await _authService.skipBiometricSetup();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: InouColors.background,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo
_buildLogo(),
const SizedBox(height: 48),
// Welcome text
Text(
'Welcome back',
style: Theme.of(context).textTheme.headlineLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Sign in to your inou account',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: InouColors.textMuted,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
// Error message
if (_errorMessage != null) ...[
_buildErrorBanner(),
const SizedBox(height: 16),
],
// Email field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'you@example.com',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
// Password field
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleLogin(),
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
),
const SizedBox(height: 24),
// Login button
SizedBox(
height: 52,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Sign In'),
),
),
// Biometric login option
if (_canUseBiometric) ...[
const SizedBox(height: 24),
_buildDivider(),
const SizedBox(height: 24),
_buildBiometricButton(),
],
const SizedBox(height: 32),
// Forgot password link
TextButton(
onPressed: () {
// TODO: Implement forgot password
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password reset coming soon'),
),
);
},
child: const Text('Forgot password?'),
),
],
),
),
),
),
),
);
}
Widget _buildLogo() {
return Container(
width: 80,
height: 80,
margin: const EdgeInsets.symmetric(horizontal: 120),
decoration: BoxDecoration(
color: InouColors.accent,
borderRadius: BorderRadius.circular(20),
),
child: const Center(
child: Text(
'i',
style: TextStyle(
color: Colors.white,
fontSize: 48,
fontWeight: FontWeight.w300,
),
),
),
);
}
Widget _buildErrorBanner() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: InouColors.danger.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: InouColors.danger.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: InouColors.danger,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(
color: InouColors.danger,
fontSize: 14,
),
),
),
],
),
);
}
Widget _buildDivider() {
return Row(
children: [
Expanded(
child: Divider(color: InouColors.textMuted.withOpacity(0.3)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'or',
style: TextStyle(
color: InouColors.textMuted,
fontSize: 14,
),
),
),
Expanded(
child: Divider(color: InouColors.textMuted.withOpacity(0.3)),
),
],
);
}
Widget _buildBiometricButton() {
return OutlinedButton.icon(
onPressed: _isLoading ? null : _attemptBiometricLogin,
icon: const Icon(Icons.fingerprint, size: 24),
label: FutureBuilder<String>(
future: _biometricService.getBiometricTypeName(),
builder: (context, snapshot) {
final name = snapshot.data ?? 'Biometrics';
return Text('Sign in with $name');
},
),
style: OutlinedButton.styleFrom(
foregroundColor: InouColors.accent,
side: BorderSide(color: InouColors.accent.withOpacity(0.5)),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
}

View File

@ -6,16 +6,18 @@ import 'core/auth_gate.dart';
import 'features/webview/webview_screen.dart';
import 'features/input/input_screen.dart';
import 'features/settings/settings_screen.dart';
import 'features/auth/login_screen.dart';
import 'services/auth_service.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Set system UI overlay style
// Set system UI overlay style for light theme
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: AppTheme.surfaceColor,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarIconBrightness: Brightness.dark,
));
runApp(const InouApp());
@ -32,28 +34,32 @@ class InouApp extends StatefulWidget {
}
class _InouAppState extends State<InouApp> {
final AuthService _authService = AuthService();
bool _isInitialized = false;
@override
void initState() {
super.initState();
_initDeepLinks();
_initialize();
}
void _initDeepLinks() {
// Handle initial deep link (app opened from link)
_handleInitialDeepLink();
Future<void> _initialize() async {
await _authService.initialize();
setState(() => _isInitialized = true);
// Listen for incoming deep links while app is running
// Note: In production, use app_links or uni_links package
// This is a placeholder for the deep link handling logic
// Listen for auth changes
_authService.addListener(_onAuthChanged);
}
Future<void> _handleInitialDeepLink() async {
// Get the initial link that launched the app
// In production, use PlatformDispatcher.instance.views.first.platformDispatcher
// or app_links package to get the initial URL
// For now, we'll just ensure the app starts normally
// Deep links will be handled via the onGenerateRoute
@override
void dispose() {
_authService.removeListener(_onAuthChanged);
super.dispose();
}
void _onAuthChanged() {
// Rebuild when auth state changes
if (mounted) setState(() {});
}
@override
@ -63,23 +69,39 @@ class _InouAppState extends State<InouApp> {
title: AppConfig.appName,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.light, // Use light theme to match inou.com
themeMode: ThemeMode.light,
debugShowCheckedModeBanner: false,
// TODO: Re-enable AuthGate for production
// home: const AuthGate(
// child: MainScaffold(),
// ),
home: const MainScaffold(),
home: _buildHome(),
onGenerateRoute: _onGenerateRoute,
);
}
Widget _buildHome() {
// Show loading while initializing
if (!_isInitialized) {
return const _SplashScreen();
}
// Show login if not authenticated
if (_authService.state != AuthState.loggedIn) {
return LoginScreen(
onLoginSuccess: () {
setState(() {}); // Rebuild to show main app
},
);
}
// Show main app wrapped with AuthGate for biometric re-auth
return const AuthGate(
child: MainScaffold(),
);
}
/// Handle deep links: inou.com/app/* should open in WebView
Route<dynamic>? _onGenerateRoute(RouteSettings settings) {
final uri = Uri.tryParse(settings.name ?? '');
if (uri != null && _isInouAppUrl(uri)) {
// Deep link to inou.com/app/*
return MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(
@ -99,16 +121,56 @@ class _InouAppState extends State<InouApp> {
);
}
// Default route handling
return null;
}
/// Check if URL is an inou app URL
bool _isInouAppUrl(Uri uri) {
return uri.host == 'inou.com' && uri.path.startsWith('/app');
}
}
/// Simple splash screen shown during initialization
class _SplashScreen extends StatelessWidget {
const _SplashScreen();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: InouColors.background,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: InouColors.accent,
borderRadius: BorderRadius.circular(24),
),
child: const Center(
child: Text(
'i',
style: TextStyle(
color: Colors.white,
fontSize: 60,
fontWeight: FontWeight.w300,
),
),
),
),
const SizedBox(height: 24),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(InouColors.accent),
),
],
),
),
);
}
}
class MainScaffold extends StatefulWidget {
const MainScaffold({super.key});
@ -137,8 +199,6 @@ class MainScaffoldState extends State<MainScaffold> {
_currentIndex = index;
});
},
backgroundColor: AppTheme.surfaceColor,
indicatorColor: AppTheme.primaryColor.withOpacity(0.2),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
@ -160,7 +220,6 @@ class MainScaffoldState extends State<MainScaffold> {
);
}
/// Navigate to the WebView tab
void navigateToHome() {
setState(() {
_currentIndex = 0;

View File

@ -0,0 +1,169 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'biometric_service.dart';
/// Authentication state
enum AuthState {
unknown, // Initial state, checking stored credentials
loggedOut, // No valid session
loggedIn, // Authenticated
}
/// Authentication service - manages login state and credentials
class AuthService extends ChangeNotifier {
static final AuthService _instance = AuthService._internal();
factory AuthService() => _instance;
AuthService._internal();
final BiometricService _biometricService = BiometricService();
AuthState _state = AuthState.unknown;
String? _userEmail;
String? _error;
// Preference keys
static const String _prefKeyLoggedIn = 'auth_logged_in';
static const String _prefKeyEmail = 'auth_email';
static const String _prefKeyBiometricSetup = 'auth_biometric_setup';
/// Current auth state
AuthState get state => _state;
/// Current user email (if logged in)
String? get userEmail => _userEmail;
/// Last error message
String? get error => _error;
/// Whether user has completed biometric setup prompt
Future<bool> get hasBiometricSetupCompleted async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_prefKeyBiometricSetup) ?? false;
}
/// Initialize - check stored login state
Future<void> initialize() async {
final prefs = await SharedPreferences.getInstance();
final isLoggedIn = prefs.getBool(_prefKeyLoggedIn) ?? false;
final email = prefs.getString(_prefKeyEmail);
if (isLoggedIn && email != null) {
_userEmail = email;
_state = AuthState.loggedIn;
} else {
_state = AuthState.loggedOut;
}
notifyListeners();
}
/// Login with email and password
/// In production, this would call your backend API
Future<bool> login(String email, String password) async {
_error = null;
// Basic validation
if (email.isEmpty || password.isEmpty) {
_error = 'Please enter email and password';
notifyListeners();
return false;
}
if (!_isValidEmail(email)) {
_error = 'Please enter a valid email address';
notifyListeners();
return false;
}
// TODO: Replace with actual API call
// For now, simulate a login delay and accept any non-empty credentials
await Future.delayed(const Duration(milliseconds: 800));
// Simulated authentication - in production, validate against backend
// For demo purposes, accept any email with password "demo" or length > 5
if (password.length < 6 && password != 'demo') {
_error = 'Invalid credentials';
notifyListeners();
return false;
}
// Store login state
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyLoggedIn, true);
await prefs.setString(_prefKeyEmail, email);
_userEmail = email;
_state = AuthState.loggedIn;
notifyListeners();
return true;
}
/// Logout - clear all auth state
Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefKeyLoggedIn);
await prefs.remove(_prefKeyEmail);
await prefs.remove(_prefKeyBiometricSetup);
// Also disable biometric
await _biometricService.setBiometricEnabled(false);
_biometricService.resetAuthState();
_userEmail = null;
_state = AuthState.loggedOut;
_error = null;
notifyListeners();
}
/// Mark biometric setup as completed (user saw the prompt)
Future<void> completeBiometricSetup() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyBiometricSetup, true);
}
/// Enable biometric authentication for future logins
Future<bool> enableBiometric() async {
// First verify user can authenticate
final result = await _biometricService.authenticate(
reason: 'Verify your identity to enable biometric login',
);
if (result == BiometricResult.success) {
await _biometricService.setBiometricEnabled(true);
await completeBiometricSetup();
return true;
}
return false;
}
/// Skip biometric setup
Future<void> skipBiometricSetup() async {
await completeBiometricSetup();
}
/// Check if biometric login is available and enabled
Future<bool> canUseBiometricLogin() async {
final available = await _biometricService.isBiometricsAvailable();
final enabled = await _biometricService.isBiometricEnabled();
return available && enabled;
}
/// Attempt biometric login (for returning users)
Future<bool> biometricLogin() async {
final canUse = await canUseBiometricLogin();
if (!canUse) return false;
final result = await _biometricService.authenticate(
reason: 'Login to inou',
);
return result == BiometricResult.success;
}
bool _isValidEmail(String email) {
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email);
}
}