import 'dart:async'; import 'package:flutter/material.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 'widgets/app_gradients.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 = Color(0xFF2F9E94); const Color _pink = Color(0xFFFF55A7); /// 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('Utilizador não encontrado após criar a conta.'); } // 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, 'Esta conta já não existe. Verifique o email ou crie uma nova conta.', ); } on AuthException catch (e) { if (!mounted) return; showPillSnackBar(context, _friendlyAuthError(e)); } on TimeoutException { if (!mounted) return; showPillSnackBar( context, 'Tempo esgotado. Verifique a sua ligação e tente novamente.', ); } catch (e) { if (!mounted) return; showPillSnackBar(context, 'Erro: $e'); } finally { if (mounted) setState(() => _loading = false); } } String _friendlyAuthError(AuthException e) { switch (e.code) { case 'invalid_credentials': return 'Email ou palavra-passe incorretos.'; case 'user_not_found': return 'Utilizador não encontrado.'; case 'email_exists': case 'user_already_exists': return 'Este email já está em uso.'; case 'weak_password': return 'Palavra-passe fraca. Utilize pelo menos 6 caracteres.'; default: return e.message; } } @override Widget build(BuildContext context) { return Scaffold( body: Stack( clipBehavior: Clip.none, children: [ Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))), 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( 'Check-Teeth Kids', 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( 'Deteção e Prevenção da Má Oclusão Infantil', 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: 'Entrar', selected: isLogin, onTap: () => onChanged(true), ), ), Expanded( child: _AuthTab( label: 'Criar Conta', 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: 'Introduza o seu nome', icon: Icons.person_outline_rounded, textInputAction: TextInputAction.next, textCapitalization: TextCapitalization.sentences, inputFormatters: [CapitalizeFirstLetterFormatter()], validator: (v) { final value = (v ?? '').trim(); if (value.isEmpty) return 'Indique o seu nome'; if (value.length < 2) return 'Nome muito curto'; if (!_namePattern.hasMatch(value)) { return 'O nome não pode conter números'; } return null; }, ), const SizedBox(height: 12), ], ) : const SizedBox.shrink(), ), _AuthTextField( controller: emailController, hintText: 'Introduza o seu email', icon: Icons.mail_outline_rounded, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, validator: (v) { final value = (v ?? '').trim(); if (value.isEmpty) return 'Indique o seu email'; if (!value.contains('@')) return 'Email inválido'; return null; }, ), const SizedBox(height: 12), _AuthTextField( controller: passwordController, hintText: 'Introduza a sua palavra-passe', icon: Icons.lock_outline_rounded, obscureText: true, textInputAction: TextInputAction.done, validator: (v) { final value = v ?? ''; if (value.isEmpty) return 'Indique a sua palavra-passe'; if (value.length < 6) return 'Mínimo de 6 caracteres'; 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 ? 'Entrar' : 'Criar Conta'), 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), ), ), ); } }