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:
parent
3e90415dcd
commit
d44b3f6a0e
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/main.dart
117
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<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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue