glp-companion/lib/features/auth/presentation/screens/login_screen.dart

216 lines
7.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart' show AuthException;
import '../controllers/auth_controller.dart';
/// Which authentication flow is currently executing, so the relevant button
/// can render a spinner while the request is in flight.
enum _AuthAction { google, apple, microsoft, magicLink }
/// Passwordless sign-in surface: federated OAuth (Google, Apple, Microsoft)
/// and email magic links. Wired to [authControllerProvider] for execution and
/// progress/error feedback.
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final TextEditingController _emailController = TextEditingController();
/// The action currently awaiting completion, or `null` when idle.
_AuthAction? _pendingAction;
static final RegExp _emailPattern = RegExp(
r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$",
);
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// The controller records failures in its AsyncValue state instead of
// throwing, so a single listener surfaces every flow's error feedback.
ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) {
final error = next.error;
if (error == null) return;
_showSnack(_messageFor(error), isError: true);
});
final isBusy = ref.watch(authControllerProvider).isLoading;
return Scaffold(
body: Center(
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.health_and_safety,
size: 64, color: Colors.teal),
const SizedBox(height: 16),
const Text(
'GLP-1 Companion',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Passwordless & Biometric Sign In',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 32),
_oauthButton(_AuthAction.google, Icons.g_mobiledata,
'Continue with Google'),
const SizedBox(height: 12),
_oauthButton(
_AuthAction.apple, Icons.apple, 'Continue with Apple'),
const SizedBox(height: 12),
_oauthButton(_AuthAction.microsoft, Icons.window,
'Continue with Microsoft'),
const SizedBox(height: 24),
const Row(
children: [
Expanded(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Text('OR'),
),
Expanded(child: Divider()),
],
),
const SizedBox(height: 24),
TextField(
controller: _emailController,
enabled: !isBusy,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'Email Address',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email_outlined),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: isBusy ? null : _sendMagicLink,
child: _pendingAction == _AuthAction.magicLink
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Send Magic Link'),
),
],
),
),
),
),
),
);
}
/// Builds an OAuth button bound to [action]; disabled while any flow runs
/// and swapped for a spinner while [action] itself is executing.
Widget _oauthButton(_AuthAction action, IconData icon, String label) {
final isBusy = ref.watch(authControllerProvider).isLoading;
return ElevatedButton.icon(
onPressed: isBusy ? null : () => _start(action, _runnerFor(action)),
icon: _pendingAction == action
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(icon),
label: Text(label),
);
}
/// Resolves the controller method backing [action].
Future<void> Function() _runnerFor(_AuthAction action) {
final notifier = ref.read(authControllerProvider.notifier);
return switch (action) {
_AuthAction.google => notifier.signInWithGoogle,
_AuthAction.apple => notifier.signInWithApple,
_AuthAction.microsoft => notifier.signInWithMicrosoft,
_AuthAction.magicLink =>
() => notifier.signInWithMagicLink(_emailController.text.trim()),
};
}
/// Runs [execute] while marking [action] as pending.
///
/// A completed flow without a recorded error counts as success; optional
/// [successMessage] is surfaced via SnackBar. Errors are reported by the
/// listener in [build], so they are not repeated here.
Future<void> _start(
_AuthAction action,
Future<void> Function() execute, {
String? successMessage,
}) async {
if (_pendingAction != null) return;
setState(() => _pendingAction = action);
try {
await execute();
if (!mounted) return;
final result = ref.read(authControllerProvider);
if (!result.hasError && successMessage != null) {
_showSnack(successMessage);
}
} finally {
if (mounted) setState(() => _pendingAction = null);
}
}
Future<void> _sendMagicLink() async {
final email = _emailController.text.trim();
if (!_emailPattern.hasMatch(email)) {
_showSnack(
'Enter a valid email address to receive a magic link.',
isError: true,
);
return;
}
await _start(
_AuthAction.magicLink,
_runnerFor(_AuthAction.magicLink),
successMessage:
'Magic link sent to $email. Check your inbox to finish signing in.',
);
}
void _showSnack(String message, {bool isError = false}) {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Theme.of(context).colorScheme.error : null,
),
);
}
String _messageFor(Object error) {
if (error is AuthException) return error.message;
return 'Authentication failed. Please try again.';
}
}