- 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>
387 lines
16 KiB
Dart
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 [],
|
|
};
|
|
}
|
|
}
|