import 'dart:async'; import 'package:flutter/material.dart'; import 'colors/app_colors.dart'; import 'package:flutter/services.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'auth_gate.dart' show pendingPrivacyUserId, pendingTermsUserId; import 'main.dart' show supabase; import 'privacy_gate_prefs.dart'; import 'terms_gate_prefs.dart'; import 'colors/app_gradients.dart'; import 'strings/auth_strings.dart'; import 'widgets/entrance.dart'; import 'widgets/liquid_waves_background.dart'; import 'widgets/name_input_formatter.dart'; import 'widgets/pill_snackbar.dart'; import 'widgets/tap_bounce.dart'; const Color _teal = AppColors.teal; const Color _pink = AppColors.pink; /// Nomes só podem ter letras (incluindo acentuadas) e espaços — sem números. final RegExp _namePattern = RegExp(r"^[a-zA-ZÀ-ÖØ-öø-ÿ' -]+$"); /// A conta existe no Supabase Auth mas os dados (perfil) já não existem na /// base de dados — tratada como conta inexistente para efeitos de login. class _AccountNotFoundException implements Exception { const _AccountNotFoundException(); } class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { final _formKey = GlobalKey(); final _nameController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); bool _isLogin = true; bool _loading = false; @override void dispose() { _nameController.dispose(); _emailController.dispose(); _passwordController.dispose(); super.dispose(); } void _switchTab(bool isLogin) { if (_isLogin == isLogin || _loading) return; setState(() => _isLogin = isLogin); _formKey.currentState?.reset(); } Future _persistRegistrationData({ required String uid, required String name, required String email, }) async { await supabase .from('profiles') .upsert({'id': uid, 'name': name, 'email': email}) .timeout(const Duration(seconds: 20)); } Future _submit() async { if (!(_formKey.currentState?.validate() ?? false)) return; setState(() => _loading = true); try { final email = _emailController.text.trim(); final password = _passwordController.text; if (_isLogin) { final result = await supabase.auth.signInWithPassword( email: email, password: password, ); final user = result.user; if (user != null) { final profile = await supabase .from('profiles') .select('id') .eq('id', user.id) .maybeSingle(); if (profile == null) { // A conta existe no Auth mas os dados foram apagados (ex.: via // "Apagar dados da conta" ou diretamente na base de dados). // Trata como inexistente: limpa qualquer resquício e bloqueia. unawaited( supabase .from('children') .delete() .eq('owner_id', user.id) .catchError((_) => >[]), ); await supabase.auth.signOut(); throw const _AccountNotFoundException(); } } } else { final name = _nameController.text.trim(); final response = await supabase.auth .signUp(email: email, password: password, data: {'name': name}) .timeout(const Duration(seconds: 20)); final user = response.user; if (user == null) { throw StateError(AuthStrings.userNotFoundAfterSignUp); } // Antes de persistir o perfil (o que já faz o AuthGate considerar a // sessão válida), marca esta conta como pendente de aceitação dos // Termos e de consentimento de privacidade — o AuthGate mostra // primeiro o TermsGateScreen e, só depois de aceite, o // PrivacyGateScreen, em vez do LoggedHome, enquanto estes uids // estiverem marcados. Grava também em disco (não só em memória) // para os gates sobreviverem caso o processo seja morto antes de o // utilizador responder. pendingPrivacyUserId.value = user.id; unawaited(PrivacyGatePrefs.setPendingUid(user.id)); pendingTermsUserId.value = user.id; unawaited(TermsGatePrefs.setPendingUid(user.id)); // Precisa de terminar antes de navegar: o AuthGate só mostra a app // depois de confirmar que existe um perfil na base de dados. await _persistRegistrationData(uid: user.id, name: name, email: email); } } on _AccountNotFoundException { if (!mounted) return; showPillSnackBar( context, AuthStrings.accountNoLongerExists, ); } on AuthException catch (e) { if (!mounted) return; showPillSnackBar(context, _friendlyAuthError(e)); } on TimeoutException { if (!mounted) return; showPillSnackBar( context, AuthStrings.timeoutError, ); } catch (e) { if (!mounted) return; showPillSnackBar(context, AuthStrings.genericError(e)); } finally { if (mounted) setState(() => _loading = false); } } String _friendlyAuthError(AuthException e) { switch (e.code) { case 'invalid_credentials': return AuthStrings.invalidCredentials; case 'user_not_found': return AuthStrings.userNotFound; case 'email_exists': case 'user_already_exists': return AuthStrings.emailAlreadyInUse; case 'weak_password': return AuthStrings.weakPassword; default: return e.message; } } @override Widget build(BuildContext context) { return Scaffold( body: Stack( clipBehavior: Clip.none, children: [ Positioned.fill(child: Container(color: AppColors.background)), const LiquidWavesBackground(), SafeArea( child: LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 28, 24, 20), child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, ), child: IntrinsicHeight( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const FadeSlideIn( child: Text( AuthStrings.appTitle, textAlign: TextAlign.center, style: TextStyle( fontSize: 26, fontWeight: FontWeight.w900, color: _pink, height: 1.0, letterSpacing: -0.5, ), ), ), const SizedBox(height: 8), FadeSlideIn( delay: const Duration(milliseconds: 80), child: Text( AuthStrings.appSubtitle, textAlign: TextAlign.center, style: TextStyle( fontSize: 13.5, fontWeight: FontWeight.w700, color: Colors.black.withValues(alpha: 0.55), ), ), ), const SizedBox(height: 26), FadeSlideIn( delay: const Duration(milliseconds: 140), child: _AuthTabSwitch( isLogin: _isLogin, onChanged: _switchTab, ), ), const SizedBox(height: 18), FadeSlideIn( delay: const Duration(milliseconds: 190), child: _AuthForm( formKey: _formKey, isLogin: _isLogin, loading: _loading, nameController: _nameController, emailController: _emailController, passwordController: _passwordController, onSubmit: _submit, ), ), ], ), ), ), ); }, ), ), ], ), ); } } class _AuthTabSwitch extends StatelessWidget { const _AuthTabSwitch({required this.isLogin, required this.onChanged}); final bool isLogin; final ValueChanged onChanged; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(999), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.06), blurRadius: 14, offset: const Offset(0, 6), ), ], ), child: Row( children: [ Expanded( child: _AuthTab( label: AuthStrings.login, selected: isLogin, onTap: () => onChanged(true), ), ), Expanded( child: _AuthTab( label: AuthStrings.createAccount, selected: !isLogin, onTap: () => onChanged(false), ), ), ], ), ); } } class _AuthTab extends StatelessWidget { const _AuthTab({ required this.label, required this.selected, required this.onTap, }); final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return TapBounce( scale: 0.97, child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(999), onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, height: 42, alignment: Alignment.center, decoration: BoxDecoration( color: selected ? _teal : Colors.transparent, borderRadius: BorderRadius.circular(999), ), child: Text( label, style: TextStyle( fontWeight: FontWeight.w800, fontSize: 14, color: selected ? Colors.white : _teal, ), ), ), ), ), ); } } class _AuthForm extends StatelessWidget { const _AuthForm({ required this.formKey, required this.isLogin, required this.loading, required this.nameController, required this.emailController, required this.passwordController, required this.onSubmit, }); final GlobalKey formKey; final bool isLogin; final bool loading; final TextEditingController nameController; final TextEditingController emailController; final TextEditingController passwordController; final VoidCallback onSubmit; @override Widget build(BuildContext context) { return Form( key: formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ AnimatedSize( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, alignment: Alignment.topCenter, child: !isLogin ? Column( children: [ _AuthTextField( controller: nameController, hintText: AuthStrings.nameHint, icon: Icons.person_outline_rounded, textInputAction: TextInputAction.next, textCapitalization: TextCapitalization.sentences, inputFormatters: [CapitalizeFirstLetterFormatter()], validator: (v) { final value = (v ?? '').trim(); if (value.isEmpty) return AuthStrings.nameRequired; if (value.length < 2) return AuthStrings.nameTooShort; if (!_namePattern.hasMatch(value)) { return AuthStrings.nameNoNumbers; } return null; }, ), const SizedBox(height: 12), ], ) : const SizedBox.shrink(), ), _AuthTextField( controller: emailController, hintText: AuthStrings.emailHint, icon: Icons.mail_outline_rounded, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, validator: (v) { final value = (v ?? '').trim(); if (value.isEmpty) return AuthStrings.emailRequired; if (!value.contains('@')) return AuthStrings.emailInvalid; return null; }, ), const SizedBox(height: 12), _AuthTextField( controller: passwordController, hintText: AuthStrings.passwordHint, icon: Icons.lock_outline_rounded, obscureText: true, textInputAction: TextInputAction.done, validator: (v) { final value = v ?? ''; if (value.isEmpty) return AuthStrings.passwordRequired; if (value.length < 6) return AuthStrings.passwordTooShort; return null; }, ), const SizedBox(height: 20), TapBounce( child: ClipRRect( borderRadius: BorderRadius.circular(999), child: DecoratedBox( decoration: const BoxDecoration(gradient: kGreenButtonGradient), child: SizedBox( height: 50, child: FilledButton( style: FilledButton.styleFrom( backgroundColor: Colors.transparent, foregroundColor: Colors.white, shape: const StadiumBorder(), textStyle: const TextStyle( fontWeight: FontWeight.w800, fontSize: 15, ), ).copyWith( animationDuration: const Duration(milliseconds: 180), splashFactory: InkSparkle.splashFactory, overlayColor: WidgetStateProperty.resolveWith( (states) { if (states.contains(WidgetState.pressed)) { return Colors.white.withValues(alpha: 0.14); } if (states.contains(WidgetState.hovered) || states.contains(WidgetState.focused)) { return Colors.white.withValues(alpha: 0.08); } return null; }, ), ), onPressed: loading ? null : onSubmit, child: loading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2.2, color: Colors.white, ), ) : Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text(isLogin ? AuthStrings.login : AuthStrings.createAccount), const SizedBox(width: 8), const Icon(Icons.arrow_forward_rounded, size: 18), ], ), ), ), ), ), ), ], ), ); } } class _AuthTextField extends StatelessWidget { const _AuthTextField({ required this.controller, required this.hintText, required this.icon, required this.validator, this.obscureText = false, this.keyboardType, this.textInputAction, this.textCapitalization = TextCapitalization.none, this.inputFormatters, }); final TextEditingController controller; final String hintText; final IconData icon; final FormFieldValidator validator; final bool obscureText; final TextInputType? keyboardType; final TextInputAction? textInputAction; final TextCapitalization textCapitalization; final List? inputFormatters; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.92), borderRadius: BorderRadius.circular(14), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 4), ), ], ), child: TextFormField( controller: controller, obscureText: obscureText, keyboardType: keyboardType, textInputAction: textInputAction, textCapitalization: textCapitalization, inputFormatters: inputFormatters, validator: validator, style: const TextStyle(fontWeight: FontWeight.w700), decoration: InputDecoration( hintText: hintText, hintStyle: TextStyle( fontWeight: FontWeight.w600, color: Colors.black.withValues(alpha: 0.35), ), prefixIcon: Icon(icon, color: _teal, size: 20), border: InputBorder.none, errorBorder: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, contentPadding: const EdgeInsets.symmetric(vertical: 16), ), ), ); } }