Files
sure/mobile/lib/screens/login_screen.dart
2026-02-18 15:51:17 +01:00

506 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../providers/auth_provider.dart';
import '../services/api_config.dart';
class LoginScreen extends StatefulWidget {
final VoidCallback? onGoToSettings;
const LoginScreen({super.key, this.onGoToSettings});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'user@example.com');
final _passwordController = TextEditingController(text: 'Password1!');
final _otpController = TextEditingController();
bool _obscurePassword = true;
late final TapGestureRecognizer _signUpTapRecognizer;
@override
void initState() {
super.initState();
_signUpTapRecognizer = TapGestureRecognizer()..onTap = _openSignUpPage;
}
@override
void dispose() {
_signUpTapRecognizer.dispose();
_emailController.dispose();
_passwordController.dispose();
_otpController.dispose();
super.dispose();
}
Future<void> _openSignUpPage() async {
final signUpUrl = Uri.parse('${ApiConfig.defaultBaseUrl}/registration/new');
final launched = await launchUrl(
signUpUrl,
mode: LaunchMode.externalApplication,
);
if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unable to open sign up page')),
);
}
}
void _showApiKeyDialog() {
final apiKeyController = TextEditingController();
final outerContext = context;
bool isLoading = false;
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (_, setDialogState) {
return AlertDialog(
title: const Text('API Key Login'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Enter your API key to sign in.',
style:
Theme.of(outerContext).textTheme.bodyMedium?.copyWith(
color: Theme.of(outerContext)
.colorScheme
.onSurfaceVariant,
),
),
const SizedBox(height: 16),
TextField(
controller: apiKeyController,
decoration: const InputDecoration(
labelText: 'API Key',
prefixIcon: Icon(Icons.vpn_key_outlined),
),
obscureText: true,
maxLines: 1,
enabled: !isLoading,
),
],
),
actions: [
TextButton(
onPressed: isLoading
? null
: () {
apiKeyController.dispose();
Navigator.of(dialogContext).pop();
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: isLoading
? null
: () async {
final apiKey = apiKeyController.text.trim();
if (apiKey.isEmpty) return;
setDialogState(() {
isLoading = true;
});
final authProvider = Provider.of<AuthProvider>(
outerContext,
listen: false,
);
final success = await authProvider.loginWithApiKey(
apiKey: apiKey,
);
if (!dialogContext.mounted) return;
final errorMsg = authProvider.errorMessage;
apiKeyController.dispose();
Navigator.of(dialogContext).pop();
if (!success && mounted) {
ScaffoldMessenger.of(outerContext).showSnackBar(
SnackBar(
content: Text(
errorMsg ?? 'Invalid API key',
),
backgroundColor:
Theme.of(outerContext).colorScheme.error,
),
);
}
},
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign In'),
),
],
);
},
);
},
);
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final hadOtpCode =
authProvider.showMfaInput && _otpController.text.isNotEmpty;
final success = await authProvider.login(
email: _emailController.text.trim(),
password: _passwordController.text,
otpCode: authProvider.showMfaInput ? _otpController.text.trim() : null,
);
// Check if widget is still mounted after async operation
if (!mounted) return;
// Clear OTP field if login failed and user had entered an OTP code
// This allows user to easily try again with a new code
if (!success && hadOtpCode && authProvider.errorMessage != null) {
_otpController.clear();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: SafeArea(
child: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 48),
// Logo/Title
SvgPicture.asset(
'assets/images/logomark.svg',
width: 80,
height: 80,
),
const SizedBox(height: 24),
Text.rich(
TextSpan(
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
children: [
const TextSpan(text: 'Demo account or '),
TextSpan(
text: 'Sign Up',
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
recognizer: _signUpTapRecognizer,
),
const TextSpan(text: '!'),
],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
// Error Message
Consumer<AuthProvider>(
builder: (context, authProvider, _) {
if (authProvider.errorMessage != null) {
return Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colorScheme.error,
),
const SizedBox(width: 12),
Expanded(
child: Text(
authProvider.errorMessage!,
style: TextStyle(
color: colorScheme.onErrorContainer),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => authProvider.clearError(),
iconSize: 20,
),
],
),
);
}
return const SizedBox.shrink();
},
),
// Email Field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
// Password and OTP Fields with Consumer
Consumer<AuthProvider>(
builder: (context, authProvider, _) {
final showOtp = authProvider.showMfaInput;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Password Field
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: showOtp
? TextInputAction.next
: TextInputAction.done,
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;
},
onFieldSubmitted:
showOtp ? null : (_) => _handleLogin(),
),
// OTP Field (shown when MFA is required)
if (showOtp) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.security,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Two-factor authentication is enabled. Enter your code.',
style: TextStyle(
color: colorScheme.onSurface),
),
),
],
),
),
const SizedBox(height: 16),
TextFormField(
controller: _otpController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: 'Authentication Code',
prefixIcon: Icon(Icons.pin_outlined),
),
validator: (value) {
if (showOtp &&
(value == null || value.isEmpty)) {
return 'Please enter your authentication code';
}
return null;
},
onFieldSubmitted: (_) => _handleLogin(),
),
],
],
);
},
),
const SizedBox(height: 24),
// Login Button
Consumer<AuthProvider>(
builder: (context, authProvider, _) {
return ElevatedButton(
onPressed:
authProvider.isLoading ? null : _handleLogin,
child: authProvider.isLoading
? const SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign In'),
);
},
),
const SizedBox(height: 16),
// Divider with "or"
Row(
children: [
Expanded(
child: Divider(color: colorScheme.outlineVariant)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'or',
style:
TextStyle(color: colorScheme.onSurfaceVariant),
),
),
Expanded(
child: Divider(color: colorScheme.outlineVariant)),
],
),
const SizedBox(height: 16),
// Google Sign-In button
Consumer<AuthProvider>(
builder: (context, authProvider, _) {
return OutlinedButton.icon(
onPressed: authProvider.isLoading
? null
: () =>
authProvider.startSsoLogin('google_oauth2'),
icon: SvgPicture.asset(
'assets/images/google_g_logo.svg',
width: 18,
height: 18,
),
label: const Text('Sign in with Google'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
},
),
const SizedBox(height: 24),
// Backend URL info
InkWell(
onTap: widget.onGoToSettings,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
'Sure server URL:',
style:
Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
ApiConfig.baseUrl,
style:
Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontFamily: 'monospace',
),
textAlign: TextAlign.center,
),
],
),
),
),
const SizedBox(height: 12),
// API Key Login Button
TextButton.icon(
onPressed: _showApiKeyDialog,
icon: const Icon(Icons.vpn_key_outlined, size: 18),
label: const Text('API-Key Login'),
),
],
),
),
),
Positioned(
right: 8,
top: 8,
child: IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Backend Settings',
onPressed: widget.onGoToSettings,
),
),
],
),
),
);
}
}