import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../../app/router.dart'; import '../../app/theme.dart'; import '../../core/env.dart'; import '../../shared/widgets/artwork.dart'; enum _Step { signIn, signUp, verifySignUp, // code from the sign-up email codeRequest, // sign in with an emailed code instead of a password codeVerify, forgot, resetVerify, // code from the reset email + new password } /// Sign up (name, email, password, then an email code), sign in with a password, /// sign in with a code, reset password, or preview without an account. class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key, this.startWithSignUp = false}); final bool startWithSignUp; @override ConsumerState createState() => _LoginScreenState(); } class _LoginScreenState extends ConsumerState { final _name = TextEditingController(); final _email = TextEditingController(); final _password = TextEditingController(); final _code = TextEditingController(); late _Step _step = widget.startWithSignUp ? _Step.signUp : _Step.signIn; bool _busy = false; bool _showPassword = false; String? _error; String? _info; GoTrueClient get _auth => Supabase.instance.client.auth; String get _emailText => _email.text.trim(); @override void dispose() { for (final c in [_name, _email, _password, _code]) { c.dispose(); } super.dispose(); } bool get _emailValid => RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(_emailText); bool get _passwordValid => _password.text.length >= 8; bool get _codeValid => RegExp(r'^\d{6}$').hasMatch(_code.text.trim()); void _go(_Step step) => setState(() { _step = step; _error = null; _info = null; _code.clear(); }); Future _run(Future Function() action) async { if (!Env.hasSupabase) { setState(() => _error = 'This build is not connected to the server. Tap "Preview without an account".'); return; } setState(() { _busy = true; _error = null; _info = null; }); try { await action(); } on AuthException catch (e) { setState(() => _error = _friendly(e)); } catch (_) { setState(() => _error = 'Something went wrong. Check your connection and try again.'); } finally { if (mounted) setState(() => _busy = false); } } String _friendly(AuthException e) { final m = e.message.toLowerCase(); if (m.contains('invalid login credentials')) { return 'Email or password is wrong. If you never set a password, use "Email me a code" ' 'and set one in your profile.'; } if (m.contains('email not confirmed')) return 'Please confirm your email first. We can send a new code.'; if (m.contains('expired') || m.contains('invalid') && m.contains('token')) { return 'That code is wrong or has expired. Check the latest email, or send a new code.'; } if (m.contains('rate limit') || m.contains('security purposes')) { return 'Please wait a minute before asking for another code.'; } return e.message; } Future _signIn() => _run(() async { await _auth.signInWithPassword(email: _emailText, password: _password.text); _finish(); }); Future _signUp() => _run(() async { final res = await _auth.signUp( email: _emailText, password: _password.text, data: {'display_name': _name.text.trim()}, ); // An existing, confirmed email comes back as a user with no identities. if (res.user != null && (res.user!.identities?.isEmpty ?? false)) { setState(() => _error = 'An account with this email already exists. Sign in instead.'); return; } _go(_Step.verifySignUp); }); Future _verifySignUp() => _run(() async { await _auth.verifyOTP(type: OtpType.signup, email: _emailText, token: _code.text.trim()); _finish(); }); Future _resendSignUp() => _run(() async { await _auth.resend(type: OtpType.signup, email: _emailText); setState(() => _info = 'New code sent.'); }); Future _requestCode() => _run(() async { await _auth.signInWithOtp(email: _emailText, shouldCreateUser: false); _go(_Step.codeVerify); }); Future _verifyCode() => _run(() async { await _auth.verifyOTP(type: OtpType.email, email: _emailText, token: _code.text.trim()); _finish(); }); Future _requestReset() => _run(() async { await _auth.resetPasswordForEmail(_emailText); _go(_Step.resetVerify); }); Future _verifyReset() => _run(() async { await _auth.verifyOTP(type: OtpType.recovery, email: _emailText, token: _code.text.trim()); await _auth.updateUser(UserAttributes(password: _password.text)); _finish(); }); void _finish() { ref.read(onboardingProvider.notifier).complete(); if (mounted) context.go('/home'); } void _preview() { ref.read(onboardingProvider.notifier).complete(); context.go('/home'); } ({String title, String subtitle, String action, VoidCallback? onAction}) get _copy => switch (_step) { _Step.signIn => ( title: 'Welcome back', subtitle: 'Sign in to listen to full songs and keep your likes.', action: 'Sign in', onAction: _emailValid && _password.text.isNotEmpty ? _signIn : null, ), _Step.signUp => ( title: 'Create your account', subtitle: 'Free to join. We will email you a code to confirm it is you.', action: 'Create account', onAction: _name.text.trim().isNotEmpty && _emailValid && _passwordValid ? _signUp : null, ), _Step.verifySignUp => ( title: 'Check your email', subtitle: 'Enter the 6-digit code we sent to $_emailText to finish signing up.', action: 'Verify and continue', onAction: _codeValid ? _verifySignUp : null, ), _Step.codeRequest => ( title: 'Sign in with a code', subtitle: 'We will email you a 6-digit code. No password needed.', action: 'Email me a code', onAction: _emailValid ? _requestCode : null, ), _Step.codeVerify => ( title: 'Check your email', subtitle: 'Enter the 6-digit code we sent to $_emailText.', action: 'Verify and sign in', onAction: _codeValid ? _verifyCode : null, ), _Step.forgot => ( title: 'Reset your password', subtitle: 'We will email you a code to set a new password.', action: 'Email me a reset code', onAction: _emailValid ? _requestReset : null, ), _Step.resetVerify => ( title: 'Set a new password', subtitle: 'Enter the code we sent to $_emailText and choose a new password.', action: 'Save password and sign in', onAction: _codeValid && _passwordValid ? _verifyReset : null, ), }; @override Widget build(BuildContext context) { final text = Theme.of(context).textTheme; final copy = _copy; final askName = _step == _Step.signUp; final askEmail = const [_Step.signIn, _Step.signUp, _Step.codeRequest, _Step.forgot].contains(_step); final askPassword = const [_Step.signIn, _Step.signUp, _Step.resetVerify].contains(_step); final askCode = const [_Step.verifySignUp, _Step.codeVerify, _Step.resetVerify].contains(_step); final newPassword = _step == _Step.signUp || _step == _Step.resetVerify; return Scaffold( resizeToAvoidBottomInset: true, body: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: 240, child: Stack( fit: StackFit.expand, children: [ Image.asset('assets/images/nilgiris_village.jpg', fit: BoxFit.cover, alignment: const Alignment(-0.4, 0.2)), const PhotoScrim(stops: [0.2, 1]), SafeArea( child: Padding( padding: const EdgeInsets.all(24), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipOval(child: Image.asset('assets/images/emblem.png', width: 52, height: 52)), const SizedBox(width: 12), Padding( padding: const EdgeInsets.only(top: 12), child: Text('Nanga Kathai', style: text.titleLarge?.copyWith(fontWeight: FontWeight.w800)), ), ], ), ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 32), child: AutofillGroup( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_step == _Step.signIn || _step == _Step.signUp) ...[ SegmentedButton<_Step>( segments: const [ ButtonSegment(value: _Step.signUp, label: Text('Sign up')), ButtonSegment(value: _Step.signIn, label: Text('Sign in')), ], selected: {_step}, showSelectedIcon: false, onSelectionChanged: _busy ? null : (s) => _go(s.first), ), const SizedBox(height: 20), ], Text(copy.title, style: text.headlineMedium), const SizedBox(height: 6), Text(copy.subtitle, style: text.bodyLarge?.copyWith(color: NkColors.muted, height: 1.5)), const SizedBox(height: 20), if (askName) ...[ TextField( controller: _name, textCapitalization: TextCapitalization.words, autofillHints: const [AutofillHints.name], textInputAction: TextInputAction.next, onChanged: (_) => setState(() {}), decoration: const InputDecoration(labelText: 'Your name'), ), const SizedBox(height: 12), ], if (askEmail) ...[ TextField( controller: _email, keyboardType: TextInputType.emailAddress, autofillHints: const [AutofillHints.email], textInputAction: askPassword ? TextInputAction.next : TextInputAction.done, onChanged: (_) => setState(() {}), decoration: const InputDecoration(labelText: 'Email', hintText: 'you@example.com'), ), const SizedBox(height: 12), ], if (askCode) ...[ TextField( controller: _code, keyboardType: TextInputType.number, autofillHints: const [AutofillHints.oneTimeCode], maxLength: 6, onChanged: (_) => setState(() {}), style: const TextStyle(fontSize: 24, letterSpacing: 8, fontWeight: FontWeight.w700), decoration: const InputDecoration(labelText: 'Code', counterText: ''), ), const SizedBox(height: 12), ], if (askPassword) TextField( controller: _password, obscureText: !_showPassword, autofillHints: [newPassword ? AutofillHints.newPassword : AutofillHints.password], onChanged: (_) => setState(() {}), onSubmitted: (_) => copy.onAction?.call(), decoration: InputDecoration( labelText: newPassword ? 'Choose a password' : 'Password', helperText: newPassword ? 'At least 8 characters' : null, suffixIcon: IconButton( tooltip: _showPassword ? 'Hide password' : 'Show password', icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility), onPressed: () => setState(() => _showPassword = !_showPassword), ), ), ), if (_error != null) ...[ const SizedBox(height: 12), Text(_error!, style: const TextStyle(color: Color(0xFFF2A69B))), ], if (_info != null) ...[ const SizedBox(height: 12), Text(_info!, style: const TextStyle(color: NkColors.teaLeaf)), ], const SizedBox(height: 20), FilledButton( onPressed: _busy ? null : copy.onAction, child: _busy ? const SizedBox.square(dimension: 22, child: CircularProgressIndicator(strokeWidth: 2.5)) : Text(copy.action), ), const SizedBox(height: 8), ..._links(), const SizedBox(height: 16), const Row(children: [ Expanded(child: Divider(color: NkColors.outline)), Padding( padding: EdgeInsets.symmetric(horizontal: 12), child: Text('or', style: TextStyle(color: NkColors.muted)), ), Expanded(child: Divider(color: NkColors.outline)), ]), const SizedBox(height: 16), OutlinedButton(onPressed: _busy ? null : _preview, child: const Text('Preview without an account')), const SizedBox(height: 6), const Text( 'Guests hear a 30-second preview of each song.', textAlign: TextAlign.center, style: TextStyle(color: NkColors.muted, fontSize: 13), ), ], ), ), ), ], ), ), ); } List _links() { TextButton link(String label, VoidCallback onTap) => TextButton(onPressed: _busy ? null : onTap, child: Text(label)); return switch (_step) { _Step.signIn => [ link('Forgot password?', () => _go(_Step.forgot)), link('Email me a code instead', () => _go(_Step.codeRequest)), ], _Step.verifySignUp => [ link('Send a new code', _resendSignUp), link('Use a different email', () => _go(_Step.signUp)), ], _Step.codeVerify || _Step.resetVerify => [ link('Use a different email', () => _go(_step == _Step.codeVerify ? _Step.codeRequest : _Step.forgot)), ], _Step.codeRequest || _Step.forgot => [link('Back to sign in', () => _go(_Step.signIn))], _Step.signUp => const [], }; } }