inou-mobile/lib/core/auth_gate.dart

391 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import '../services/biometric_service.dart';
import 'theme.dart';
/// Widget that wraps the app and handles biometric authentication
/// Shows biometric prompt on app launch and resume from background
class AuthGate extends StatefulWidget {
final Widget child;
const AuthGate({
super.key,
required this.child,
});
@override
State<AuthGate> createState() => _AuthGateState();
}
class _AuthGateState extends State<AuthGate> with WidgetsBindingObserver {
final BiometricService _biometricService = BiometricService();
bool _isLocked = true;
bool _isAuthenticating = false;
bool _isInitialized = false;
BiometricResult? _lastError;
int _failureCount = 0;
static const int _maxFailures = 3;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initialize();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Future<void> _initialize() async {
final required = await _biometricService.isAuthenticationRequired();
setState(() {
_isLocked = required;
_isInitialized = true;
});
if (required) {
// Small delay to ensure UI is ready
await Future.delayed(const Duration(milliseconds: 300));
_authenticate();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.resumed:
_onAppResumed();
break;
case AppLifecycleState.paused:
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
_onAppBackgrounded();
break;
case AppLifecycleState.detached:
break;
}
}
Future<void> _onAppResumed() async {
if (_isAuthenticating) return;
final required = await _biometricService.isAuthenticationRequired();
if (required && !_isLocked) {
setState(() {
_isLocked = true;
_lastError = null;
_failureCount = 0;
});
_authenticate();
} else if (_isLocked && !_isAuthenticating) {
// Still locked, try again
_authenticate();
}
}
void _onAppBackgrounded() {
// Record last activity time when going to background
_biometricService.recordActivity();
}
Future<void> _authenticate() async {
if (_isAuthenticating) return;
setState(() {
_isAuthenticating = true;
_lastError = null;
});
final result = await _biometricService.authenticate(
reason: 'Authenticate to access inou',
biometricOnly: false, // Allow PIN fallback
);
if (!mounted) return;
setState(() {
_isAuthenticating = false;
});
switch (result) {
case BiometricResult.success:
setState(() {
_isLocked = false;
_failureCount = 0;
_lastError = null;
});
break;
case BiometricResult.cancelled:
// User cancelled, don't count as failure
setState(() {
_lastError = result;
});
break;
case BiometricResult.failed:
setState(() {
_failureCount++;
_lastError = result;
});
break;
case BiometricResult.lockedOut:
case BiometricResult.permanentlyLockedOut:
case BiometricResult.notAvailable:
case BiometricResult.notEnrolled:
case BiometricResult.error:
setState(() {
_lastError = result;
});
break;
}
}
void _recordUserActivity() {
_biometricService.recordActivity();
}
@override
Widget build(BuildContext context) {
// Not yet initialized - show nothing (brief flash)
if (!_isInitialized) {
return const SizedBox.shrink();
}
// Not locked - show the app
if (!_isLocked) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _recordUserActivity,
onPanDown: (_) => _recordUserActivity(),
child: widget.child,
);
}
// Locked - show auth screen
return _buildLockScreen();
}
Widget _buildLockScreen() {
return Scaffold(
backgroundColor: AppTheme.backgroundColor,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// App icon/logo
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.3),
width: 2,
),
),
child: Icon(
Icons.lock_outline,
size: 48,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 32),
Text(
'inou',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: AppTheme.textColor,
),
),
const SizedBox(height: 8),
Text(
'Authentication Required',
style: TextStyle(
fontSize: 16,
color: AppTheme.textColor.withOpacity(0.7),
),
),
const SizedBox(height: 48),
// Error message
if (_lastError != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _getErrorColor(_lastError!).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getErrorColor(_lastError!).withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
_getErrorIcon(_lastError!),
color: _getErrorColor(_lastError!),
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_biometricService.getErrorMessage(_lastError!),
style: TextStyle(
color: _getErrorColor(_lastError!),
fontSize: 14,
),
),
),
],
),
),
const SizedBox(height: 24),
],
// Authenticate button
if (!_isAuthenticating) ...[
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _failureCount >= _maxFailures
? null
: _authenticate,
icon: const Icon(Icons.fingerprint, size: 28),
label: Text(
_failureCount >= _maxFailures
? 'Too many attempts'
: 'Authenticate',
style: const TextStyle(fontSize: 18),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.shade800,
disabledForegroundColor: Colors.grey.shade500,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
] else ...[
// Authenticating indicator
Column(
children: [
SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.primaryColor,
),
strokeWidth: 3,
),
),
const SizedBox(height: 16),
Text(
'Authenticating...',
style: TextStyle(
color: AppTheme.textColor.withOpacity(0.7),
fontSize: 16,
),
),
],
),
],
// Failure count indicator
if (_failureCount > 0 && _failureCount < _maxFailures) ...[
const SizedBox(height: 16),
Text(
'${_maxFailures - _failureCount} attempts remaining',
style: TextStyle(
color: Colors.orange.shade400,
fontSize: 14,
),
),
],
// Reset after max failures
if (_failureCount >= _maxFailures) ...[
const SizedBox(height: 24),
TextButton(
onPressed: () {
setState(() {
_failureCount = 0;
_lastError = null;
});
},
child: Text(
'Try Again',
style: TextStyle(
color: AppTheme.primaryColor,
fontSize: 16,
),
),
),
],
],
),
),
),
),
);
}
Color _getErrorColor(BiometricResult result) {
switch (result) {
case BiometricResult.cancelled:
return Colors.grey;
case BiometricResult.failed:
return Colors.orange;
case BiometricResult.lockedOut:
case BiometricResult.permanentlyLockedOut:
return Colors.red;
case BiometricResult.notAvailable:
case BiometricResult.notEnrolled:
return Colors.amber;
default:
return Colors.red;
}
}
IconData _getErrorIcon(BiometricResult result) {
switch (result) {
case BiometricResult.cancelled:
return Icons.cancel_outlined;
case BiometricResult.failed:
return Icons.error_outline;
case BiometricResult.lockedOut:
case BiometricResult.permanentlyLockedOut:
return Icons.lock_clock;
case BiometricResult.notAvailable:
return Icons.no_encryption;
case BiometricResult.notEnrolled:
return Icons.fingerprint;
default:
return Icons.warning_amber;
}
}
}