nkbmusic/lib/features/auth/login_screen.dart
metatroncubeswdev 178d2d00ab Nanga Kathai: Flutter app, self-hosted backend and song tools
- Flutter app (Android/iOS): home, explore, albums, search, player with
  equalizer, mini player, sign up/sign in with email codes, profile,
  encrypted offline downloads, About page
- server/: one-command deploy of self-hosted Supabase behind HestiaCP/nginx
  (or Caddy), migrations, backups, email templates
- supabase/migrations: schema, row-level security, profiles
- tools/: song scanner and importer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:39:26 -04:00

387 lines
16 KiB
Dart

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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
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<void> _run(Future<void> 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<void> _signIn() => _run(() async {
await _auth.signInWithPassword(email: _emailText, password: _password.text);
_finish();
});
Future<void> _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<void> _verifySignUp() => _run(() async {
await _auth.verifyOTP(type: OtpType.signup, email: _emailText, token: _code.text.trim());
_finish();
});
Future<void> _resendSignUp() => _run(() async {
await _auth.resend(type: OtpType.signup, email: _emailText);
setState(() => _info = 'New code sent.');
});
Future<void> _requestCode() => _run(() async {
await _auth.signInWithOtp(email: _emailText, shouldCreateUser: false);
_go(_Step.codeVerify);
});
Future<void> _verifyCode() => _run(() async {
await _auth.verifyOTP(type: OtpType.email, email: _emailText, token: _code.text.trim());
_finish();
});
Future<void> _requestReset() => _run(() async {
await _auth.resetPasswordForEmail(_emailText);
_go(_Step.resetVerify);
});
Future<void> _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<Widget> _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 [],
};
}
}