The guest button was nearly invisible (grey text, fontSize 13 on dark background). Now uses white70, fontSize 15, TextButton with proper tap padding. Also guards wishlist toggle on event detail page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
679 lines
26 KiB
Dart
679 lines
26 KiB
Dart
// lib/screens/login_screen.dart
|
|
|
|
import 'dart:ui';
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:video_player/video_player.dart';
|
|
import '../features/auth/services/auth_service.dart';
|
|
import '../core/auth/auth_guard.dart';
|
|
import 'home_screen.dart';
|
|
|
|
class LoginScreen extends StatefulWidget {
|
|
const LoginScreen({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
class _LoginScreenState extends State<LoginScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
final TextEditingController _emailCtrl = TextEditingController();
|
|
final TextEditingController _passCtrl = TextEditingController();
|
|
final FocusNode _emailFocus = FocusNode();
|
|
final FocusNode _passFocus = FocusNode();
|
|
|
|
final AuthService _auth = AuthService();
|
|
bool _loading = false;
|
|
bool _obscurePassword = true;
|
|
bool _rememberMe = false;
|
|
|
|
late VideoPlayerController _videoController;
|
|
bool _videoInitialized = false;
|
|
|
|
// Glassmorphism color palette
|
|
static const _darkBg = Color(0xFF0A0A0A);
|
|
static const _glassBg = Color(0x1AFFFFFF); // 10% white
|
|
static const _glassBorder = Color(0x33FFFFFF); // 20% white
|
|
static const _inputBg = Color(0x14FFFFFF); // 8% white
|
|
static const _inputBorder = Color(0x26FFFFFF); // 15% white
|
|
static const _textWhite = Colors.white;
|
|
static const _textMuted = Color(0xFFAAAAAA);
|
|
static const _textHint = Color(0xFF888888);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initVideo();
|
|
}
|
|
|
|
Future<void> _initVideo() async {
|
|
_videoController = VideoPlayerController.asset('assets/login-bg.mp4');
|
|
await _videoController.initialize();
|
|
_videoController.setLooping(true);
|
|
_videoController.setVolume(0);
|
|
_videoController.play();
|
|
if (mounted) setState(() => _videoInitialized = true);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_videoController.dispose();
|
|
_emailCtrl.dispose();
|
|
_passCtrl.dispose();
|
|
_emailFocus.dispose();
|
|
_passFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String? _emailValidator(String? v) {
|
|
if (v == null || v.trim().isEmpty) return 'Enter email';
|
|
final email = v.trim();
|
|
final emailRegex = RegExp(r"^[^@]+@[^@]+\.[^@]+");
|
|
if (!emailRegex.hasMatch(email)) return 'Enter a valid email';
|
|
return null;
|
|
}
|
|
|
|
String? _passwordValidator(String? v) {
|
|
if (v == null || v.trim().isEmpty) return 'Enter password';
|
|
if (v.length < 6) return 'Password must be at least 6 characters';
|
|
return null;
|
|
}
|
|
|
|
Future<void> _performLogin() async {
|
|
final form = _formKey.currentState;
|
|
if (form == null) return;
|
|
if (!form.validate()) return;
|
|
|
|
final email = _emailCtrl.text.trim();
|
|
final password = _passCtrl.text;
|
|
|
|
setState(() => _loading = true);
|
|
|
|
try {
|
|
await _auth.login(email, password);
|
|
|
|
if (!mounted) return;
|
|
await Future.delayed(const Duration(milliseconds: 150));
|
|
|
|
Navigator.of(context).pushReplacement(PageRouteBuilder(
|
|
pageBuilder: (context, a1, a2) => const HomeScreen(),
|
|
transitionDuration: const Duration(milliseconds: 650),
|
|
reverseTransitionDuration: const Duration(milliseconds: 350),
|
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
return FadeTransition(opacity: animation, child: child);
|
|
},
|
|
));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final message = e.toString().replaceAll('Exception: ', '');
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
|
} finally {
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
void _openRegister() {
|
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const RegisterScreen(isDesktop: false)));
|
|
}
|
|
|
|
void _showComingSoon() {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Coming soon'), duration: Duration(seconds: 1)),
|
|
);
|
|
}
|
|
|
|
/// Glassmorphism pill-shaped input decoration
|
|
InputDecoration _glassInputDecoration({
|
|
required String hint,
|
|
required IconData prefixIcon,
|
|
Widget? suffixIcon,
|
|
}) {
|
|
const borderRadius = BorderRadius.all(Radius.circular(28));
|
|
return InputDecoration(
|
|
hintText: hint,
|
|
hintStyle: const TextStyle(color: _textHint, fontSize: 14),
|
|
prefixIcon: Padding(
|
|
padding: const EdgeInsets.only(left: 16, right: 8),
|
|
child: Icon(prefixIcon, color: _textMuted, size: 20),
|
|
),
|
|
prefixIconConstraints: const BoxConstraints(minWidth: 44),
|
|
suffixIcon: suffixIcon != null
|
|
? Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: suffixIcon,
|
|
)
|
|
: null,
|
|
filled: true,
|
|
fillColor: _inputBg,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
|
border: OutlineInputBorder(
|
|
borderRadius: borderRadius,
|
|
borderSide: BorderSide(color: _inputBorder),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: borderRadius,
|
|
borderSide: BorderSide(color: _inputBorder),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: borderRadius,
|
|
borderSide: BorderSide(color: _glassBorder, width: 1.5),
|
|
),
|
|
errorBorder: OutlineInputBorder(
|
|
borderRadius: borderRadius,
|
|
borderSide: const BorderSide(color: Colors.redAccent),
|
|
),
|
|
focusedErrorBorder: OutlineInputBorder(
|
|
borderRadius: borderRadius,
|
|
borderSide: const BorderSide(color: Colors.redAccent, width: 1.5),
|
|
),
|
|
errorStyle: const TextStyle(color: Colors.redAccent, fontSize: 11),
|
|
);
|
|
}
|
|
|
|
/// Glassmorphism social button
|
|
Widget _socialButton({
|
|
required String label,
|
|
required Widget icon,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return Expanded(
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
decoration: BoxDecoration(
|
|
color: _inputBg,
|
|
borderRadius: BorderRadius.circular(28),
|
|
border: Border.all(color: _inputBorder),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
icon,
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: _textWhite,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: _darkBg,
|
|
body: Stack(
|
|
children: [
|
|
// Video background
|
|
if (_videoInitialized)
|
|
Positioned.fill(
|
|
child: FittedBox(
|
|
fit: BoxFit.cover,
|
|
child: SizedBox(
|
|
width: _videoController.value.size.width,
|
|
height: _videoController.value.size.height,
|
|
child: VideoPlayer(_videoController),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Dark gradient overlay for readability
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.black.withOpacity(0.50),
|
|
Colors.black.withOpacity(0.65),
|
|
Colors.black.withOpacity(0.70),
|
|
],
|
|
stops: const [0.0, 0.4, 1.0],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Main content
|
|
SafeArea(
|
|
child: Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 400),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Brand name
|
|
Center(
|
|
child: Text(
|
|
'Eventify',
|
|
style: TextStyle(
|
|
color: _textWhite.withOpacity(0.7),
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
fontStyle: FontStyle.italic,
|
|
letterSpacing: 1.5,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Heading
|
|
const Center(
|
|
child: Text(
|
|
'Log In, Start Your\nJourney',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: _textWhite,
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.bold,
|
|
fontStyle: FontStyle.italic,
|
|
height: 1.2,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
|
|
// Email label
|
|
const Padding(
|
|
padding: EdgeInsets.only(left: 4, bottom: 8),
|
|
child: Text(
|
|
'Email',
|
|
style: TextStyle(color: _textMuted, fontSize: 13),
|
|
),
|
|
),
|
|
// Email input
|
|
TextFormField(
|
|
controller: _emailCtrl,
|
|
focusNode: _emailFocus,
|
|
keyboardType: TextInputType.emailAddress,
|
|
autofillHints: const [AutofillHints.email],
|
|
style: const TextStyle(color: _textWhite, fontSize: 14),
|
|
cursorColor: Colors.white54,
|
|
decoration: _glassInputDecoration(
|
|
hint: 'Enter your email',
|
|
prefixIcon: Icons.mail_outline_rounded,
|
|
),
|
|
validator: _emailValidator,
|
|
textInputAction: TextInputAction.next,
|
|
onFieldSubmitted: (_) {
|
|
FocusScope.of(context).requestFocus(_passFocus);
|
|
},
|
|
),
|
|
const SizedBox(height: 18),
|
|
|
|
// Password label
|
|
const Padding(
|
|
padding: EdgeInsets.only(left: 4, bottom: 8),
|
|
child: Text(
|
|
'Password',
|
|
style: TextStyle(color: _textMuted, fontSize: 13),
|
|
),
|
|
),
|
|
// Password input
|
|
TextFormField(
|
|
controller: _passCtrl,
|
|
focusNode: _passFocus,
|
|
obscureText: _obscurePassword,
|
|
style: const TextStyle(color: _textWhite, fontSize: 14),
|
|
cursorColor: Colors.white54,
|
|
decoration: _glassInputDecoration(
|
|
hint: 'Enter your password',
|
|
prefixIcon: Icons.lock_outline_rounded,
|
|
suffixIcon: IconButton(
|
|
icon: Icon(
|
|
_obscurePassword ? Icons.visibility_off_outlined : Icons.visibility_outlined,
|
|
color: _textMuted,
|
|
size: 20,
|
|
),
|
|
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
|
),
|
|
),
|
|
validator: _passwordValidator,
|
|
textInputAction: TextInputAction.done,
|
|
onFieldSubmitted: (_) => _performLogin(),
|
|
),
|
|
const SizedBox(height: 14),
|
|
|
|
// Remember me + Forgot Password row
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
// Remember me
|
|
GestureDetector(
|
|
onTap: () => setState(() => _rememberMe = !_rememberMe),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 18,
|
|
height: 18,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: _glassBorder),
|
|
color: _rememberMe ? Colors.white24 : Colors.transparent,
|
|
),
|
|
child: _rememberMe
|
|
? const Icon(Icons.check, size: 14, color: _textWhite)
|
|
: null,
|
|
),
|
|
const SizedBox(width: 8),
|
|
const Text(
|
|
'Remember me',
|
|
style: TextStyle(color: _textMuted, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Forgot Password
|
|
GestureDetector(
|
|
onTap: _showComingSoon,
|
|
child: const Text(
|
|
'Forgot Password?',
|
|
style: TextStyle(color: _textMuted, fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Login button — dark gradient pill
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(28),
|
|
gradient: const LinearGradient(
|
|
colors: [Color(0xFF2A2A2A), Color(0xFF1A1A1A)],
|
|
),
|
|
border: Border.all(color: const Color(0x33FFFFFF)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.4),
|
|
blurRadius: 16,
|
|
offset: const Offset(0, 6),
|
|
),
|
|
],
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(28),
|
|
onTap: _loading ? null : _performLogin,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Center(
|
|
child: _loading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: _textWhite,
|
|
),
|
|
)
|
|
: const Text(
|
|
'Login',
|
|
style: TextStyle(
|
|
color: _textWhite,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// "Or continue with" divider
|
|
Row(
|
|
children: [
|
|
Expanded(child: Divider(color: Colors.white.withOpacity(0.12))),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
child: Text(
|
|
'Or continue with',
|
|
style: TextStyle(
|
|
color: _textMuted.withOpacity(0.7),
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
Expanded(child: Divider(color: Colors.white.withOpacity(0.12))),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
|
|
// Social buttons — side by side
|
|
Row(
|
|
children: [
|
|
_socialButton(
|
|
label: 'Google',
|
|
icon: const Text(
|
|
'G',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: Color(0xFF4285F4),
|
|
),
|
|
),
|
|
onTap: _showComingSoon,
|
|
),
|
|
const SizedBox(width: 12),
|
|
_socialButton(
|
|
label: 'Apple',
|
|
icon: const Icon(Icons.apple, color: _textWhite, size: 20),
|
|
onTap: _showComingSoon,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 28),
|
|
|
|
// Don't have an account? Create an account
|
|
Center(
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
"Don't have an account? ",
|
|
style: TextStyle(color: _textMuted, fontSize: 13),
|
|
),
|
|
GestureDetector(
|
|
onTap: _openRegister,
|
|
child: const Text(
|
|
'Create an account',
|
|
style: TextStyle(
|
|
color: _textWhite,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: _textWhite,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Continue as Guest
|
|
Center(
|
|
child: TextButton(
|
|
onPressed: () {
|
|
AuthGuard.setGuest(true);
|
|
Navigator.of(context).pushAndRemoveUntil(
|
|
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
|
(route) => false,
|
|
);
|
|
},
|
|
style: TextButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
),
|
|
child: const Text(
|
|
'Continue as Guest',
|
|
style: TextStyle(
|
|
color: Colors.white70,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w500,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: Colors.white70,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Register screen calls backend register endpoint via AuthService.register
|
|
class RegisterScreen extends StatefulWidget {
|
|
final bool isDesktop;
|
|
const RegisterScreen({Key? key, this.isDesktop = false}) : super(key: key);
|
|
|
|
@override
|
|
State<RegisterScreen> createState() => _RegisterScreenState();
|
|
}
|
|
|
|
class _RegisterScreenState extends State<RegisterScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final TextEditingController _emailCtrl = TextEditingController();
|
|
final TextEditingController _phoneCtrl = TextEditingController();
|
|
final TextEditingController _passCtrl = TextEditingController();
|
|
final TextEditingController _confirmCtrl = TextEditingController();
|
|
final AuthService _auth = AuthService();
|
|
|
|
bool _loading = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_emailCtrl.dispose();
|
|
_phoneCtrl.dispose();
|
|
_passCtrl.dispose();
|
|
_confirmCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _performRegister() async {
|
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
|
|
|
final email = _emailCtrl.text.trim();
|
|
final phone = _phoneCtrl.text.trim();
|
|
final pass = _passCtrl.text;
|
|
final confirm = _confirmCtrl.text;
|
|
|
|
if (pass != confirm) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
|
|
return;
|
|
}
|
|
|
|
setState(() => _loading = true);
|
|
|
|
try {
|
|
await _auth.register(
|
|
email: email,
|
|
phoneNumber: phone,
|
|
password: pass,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final message = e.toString().replaceAll('Exception: ', '');
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
|
} finally {
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
String? _emailValidator(String? v) {
|
|
if (v == null || v.trim().isEmpty) return 'Enter email';
|
|
final emailRegex = RegExp(r"^[^@]+@[^@]+\.[^@]+");
|
|
if (!emailRegex.hasMatch(v.trim())) return 'Enter a valid email';
|
|
return null;
|
|
}
|
|
|
|
String? _phoneValidator(String? v) {
|
|
if (v == null || v.trim().isEmpty) return 'Enter phone number';
|
|
if (v.trim().length < 7) return 'Enter a valid phone number';
|
|
return null;
|
|
}
|
|
|
|
String? _passwordValidator(String? v) {
|
|
if (v == null || v.isEmpty) return 'Enter password';
|
|
if (v.length < 6) return 'Password must be at least 6 characters';
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Register')),
|
|
body: SafeArea(
|
|
child: Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(20),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 520),
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18.0),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
children: [
|
|
TextFormField(controller: _emailCtrl, decoration: const InputDecoration(labelText: 'Email'), validator: _emailValidator, keyboardType: TextInputType.emailAddress),
|
|
const SizedBox(height: 8),
|
|
TextFormField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone'), validator: _phoneValidator, keyboardType: TextInputType.phone),
|
|
const SizedBox(height: 8),
|
|
TextFormField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password'), validator: _passwordValidator),
|
|
const SizedBox(height: 8),
|
|
TextFormField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password'), validator: _passwordValidator),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: _loading ? null : _performRegister,
|
|
child: _loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Register'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|