216 lines
7.6 KiB
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.';
|
|
}
|
|
|
|
}
|