279 lines
10 KiB
Dart
279 lines
10 KiB
Dart
import 'package:flutter/foundation.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';
|
|
import '../controllers/dev_auth_bypass.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);
|
|
});
|
|
|
|
// Deferred failures from a completed OAuth / magic-link round trip: when
|
|
// the browser returns an ?error=... callback (e.g. a provider handshake
|
|
// failure), Supabase Auth surfaces it as a stream error on
|
|
// onAuthStateChange long after the launch button returned. Without this
|
|
// listener the failure is silent and the screen appears to hang.
|
|
ref.listen(authStateProvider, (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'),
|
|
),
|
|
if (kDebugMode) ...[
|
|
const SizedBox(height: 24),
|
|
const Row(
|
|
children: [
|
|
Expanded(child: Divider()),
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
|
child: Text('DEV'),
|
|
),
|
|
Expanded(child: Divider()),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
OutlinedButton.icon(
|
|
onPressed: _continueInDevMode,
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: Colors.orange,
|
|
),
|
|
icon: const Icon(Icons.bug_report_outlined),
|
|
label: const Text('Continue Without Signing In'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'Development build only: skips authentication so the '
|
|
'app can be reviewed without a Supabase project. Not '
|
|
'available in release builds.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
/// Dev-only escape hatch: flips the bypass flag so the router's auth guard
|
|
/// admits the session without a Supabase round trip. The guard re-evaluates
|
|
/// through `refreshListenable`, which navigates away from `/login`.
|
|
void _continueInDevMode() {
|
|
ref.read(devAuthBypassProvider.notifier).state = true;
|
|
}
|
|
|
|
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) {
|
|
// Server-side handshake failures arrive late (via the deep-link
|
|
// callback) with raw upstream text; translate the known ones into
|
|
// actionable guidance.
|
|
if (error.message.contains('Error getting user email')) {
|
|
return 'Sign-in failed: the provider did not return an email '
|
|
'address for this account. This usually means the provider is '
|
|
'missing its email claim (e.g. the optional "email" claim in a '
|
|
'Microsoft Entra app registration). Try another sign-in method.';
|
|
}
|
|
if (error.message.isNotEmpty) {
|
|
return 'Sign-in failed: ${error.message}';
|
|
}
|
|
}
|
|
return 'Authentication failed. Please try again.';
|
|
}
|
|
|
|
}
|