From d44b3f6a0e91d0f89408f2acaf4932fd38bd5871 Mon Sep 17 00:00:00 2001 From: Johan Jongsma Date: Sun, 1 Feb 2026 08:50:07 +0000 Subject: [PATCH] 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 --- lib/features/auth/login_screen.dart | 381 ++++++++++++++++++++++++++++ lib/main.dart | 117 ++++++--- lib/services/auth_service.dart | 169 ++++++++++++ 3 files changed, 638 insertions(+), 29 deletions(-) create mode 100644 lib/features/auth/login_screen.dart create mode 100644 lib/services/auth_service.dart diff --git a/lib/features/auth/login_screen.dart b/lib/features/auth/login_screen.dart new file mode 100644 index 0000000..60f517c --- /dev/null +++ b/lib/features/auth/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + 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 _checkBiometricAvailability() async { + final canUse = await _authService.canUseBiometricLogin(); + if (mounted) { + setState(() => _canUseBiometric = canUse); + + // Auto-trigger biometric if available + if (canUse) { + _attemptBiometricLogin(); + } + } + } + + Future _attemptBiometricLogin() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final success = await _authService.biometricLogin(); + + if (!mounted) return; + + setState(() => _isLoading = false); + + if (success) { + widget.onLoginSuccess(); + } + } + + Future _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 _showBiometricSetupDialog() async { + final biometricName = await _biometricService.getBiometricTypeName(); + + if (!mounted) return; + + final result = await showDialog( + 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(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( + 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), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 0370b77..731f853 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { + 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 _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 _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 { 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? _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 { ); } - // 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(InouColors.accent), + ), + ], + ), + ), + ); + } +} + class MainScaffold extends StatefulWidget { const MainScaffold({super.key}); @@ -137,8 +199,6 @@ class MainScaffoldState extends State { _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 { ); } - /// Navigate to the WebView tab void navigateToHome() { setState(() { _currentIndex = 0; diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..4604235 --- /dev/null +++ b/lib/services/auth_service.dart @@ -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 get hasBiometricSetupCompleted async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_prefKeyBiometricSetup) ?? false; + } + + /// Initialize - check stored login state + Future 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 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 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 completeBiometricSetup() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefKeyBiometricSetup, true); + } + + /// Enable biometric authentication for future logins + Future 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 skipBiometricSetup() async { + await completeBiometricSetup(); + } + + /// Check if biometric login is available and enabled + Future canUseBiometricLogin() async { + final available = await _biometricService.isBiometricsAvailable(); + final enabled = await _biometricService.isBiometricEnabled(); + return available && enabled; + } + + /// Attempt biometric login (for returning users) + Future 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); + } +}