diff --git a/creche_app/lib/features/auth/login_screen.dart b/creche_app/lib/features/auth/login_screen.dart deleted file mode 100644 index c109ad3..0000000 --- a/creche_app/lib/features/auth/login_screen.dart +++ /dev/null @@ -1,449 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; -import '/core/auth_provider.dart'; - -class LoginScreen extends ConsumerStatefulWidget { - const LoginScreen({super.key}); - @override - ConsumerState createState() => _LoginScreenState(); -} - -class _LoginScreenState extends ConsumerState - with SingleTickerProviderStateMixin { - final _emailCtrl = TextEditingController(); - final _passCtrl = TextEditingController(); - final _nameCtrl = TextEditingController(); // só no registo - bool _isRegister = false; // toggle login/registo - bool _obscure = true; - bool _loading = false; - bool _biometricLoading = false; - String? _error; - late AnimationController _animCtrl; - late Animation _fadeAnim; - late Animation _slideAnim; - - @override - void initState() { - super.initState(); - _animCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 700)); - _fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut); - _slideAnim = Tween(begin: const Offset(0, 0.07), end: Offset.zero) - .animate(CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut)); - _animCtrl.forward(); - } - - @override - void dispose() { - _animCtrl.dispose(); - _emailCtrl.dispose(); - _passCtrl.dispose(); - _nameCtrl.dispose(); - super.dispose(); - } - - // ── toggle entre Login e Registo ───────────────────────────── - void _toggleMode() { - setState(() { _isRegister = !_isRegister; _error = null; }); - _animCtrl.forward(from: 0); - } - - // ── Login ───────────────────────────────────────────────────── - Future _signIn() async { - if (_emailCtrl.text.trim().isEmpty || _passCtrl.text.isEmpty) { - setState(() => _error = 'Preencha o email e a senha.'); - return; - } - setState(() { _loading = true; _error = null; }); - try { - await ref.read(authNotifierProvider.notifier).signIn( - _emailCtrl.text.trim(), _passCtrl.text, - ); - if (mounted) context.go('/home'); - } on WaitingApprovalException { - if (mounted) context.go('/waiting'); - } catch (e) { - setState(() => _error = _friendly(e.toString())); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - // ── Registo ─────────────────────────────────────────────────── - Future _signUp() async { - final email = _emailCtrl.text.trim(); - final name = _nameCtrl.text.trim(); - final pass = _passCtrl.text; - - if (email.isEmpty || pass.isEmpty || name.isEmpty) { - setState(() => _error = 'Preencha todos os campos.'); - return; - } - if (pass.length < 6) { - setState(() => _error = 'A senha deve ter pelo menos 6 caracteres.'); - return; - } - - setState(() { _loading = true; _error = null; }); - try { - final sb = Supabase.instance.client; - - // 1. Criar conta no Supabase Auth - final res = await sb.auth.signUp(email: email, password: pass); - final user = res.user; - if (user == null) throw Exception('Erro ao criar conta.'); - - // 2. Criar perfil (sem role — será atribuído pelo convite) - await sb.from('profiles').upsert({ - 'user_id': user.id, - 'full_name': name, - 'role': 'parent', // role temporário; convite vai actualizar - }); - - // 3. Ir para o splash — ele detecta convites pendentes automaticamente - if (mounted) { - _showSnack('Conta criada! A verificar convites...', ok: true); - await Future.delayed(const Duration(milliseconds: 800)); - if (mounted) context.go('/splash'); - } - } catch (e) { - setState(() => _error = _friendly(e.toString())); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - // ── Recuperar senha ─────────────────────────────────────────── - Future _forgotPassword() async { - final email = _emailCtrl.text.trim(); - if (email.isEmpty) { - setState(() => _error = 'Digite o seu email primeiro.'); - return; - } - try { - await Supabase.instance.client.auth.resetPasswordForEmail(email); - if (mounted) _showSnack('Email de recuperação enviado para $email.', ok: true); - } catch (e) { - setState(() => _error = 'Erro ao enviar email: $e'); - } - } - - // ── Biometria ──────────────────────────────────────────────── - Future _biometricSignIn() async { - if (kIsWeb) { - setState(() => _error = 'Biometria não disponível no Web.'); - return; - } - setState(() { _biometricLoading = true; _error = null; }); - try { - await ref.read(authNotifierProvider.notifier).biometricSignIn(); - if (mounted) context.go('/home'); - } on WaitingApprovalException { - if (mounted) context.go('/waiting'); - } catch (e) { - setState(() => _error = _friendly(e.toString())); - } finally { - if (mounted) setState(() => _biometricLoading = false); - } - } - - void _showSnack(String msg, {bool ok = false}) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(msg, style: const TextStyle(color: Colors.white)), - backgroundColor: ok ? const Color(0xFF2ECC71) : Colors.red, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - duration: const Duration(seconds: 3), - )); - } - - String _friendly(String e) { - if (e.contains('Invalid login')) return 'Email ou senha incorrectos.'; - if (e.contains('Email not confirmed')) return 'Confirme o seu email primeiro.'; - if (e.contains('already registered')) return 'Este email já tem conta. Faça login.'; - if (e.contains('Password should')) return 'A senha deve ter pelo menos 6 caracteres.'; - if (e.contains('IP')) return e; - return 'Erro. Tente novamente.'; - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF0D1117), - body: Stack(children: [ - Positioned(top: -80, right: -60, child: _Orb(size: 280, color: const Color(0xFF4FC3F7).withOpacity(0.11))), - Positioned(bottom: -60, left: -40, child: _Orb(size: 220, color: const Color(0xFFA5D6A7).withOpacity(0.09))), - SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: FadeTransition( - opacity: _fadeAnim, - child: SlideTransition( - position: _slideAnim, - child: Column(children: [ - const SizedBox(height: 44), - - // ── Logo ────────────────────────────────────── - Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient(colors: [ - const Color(0xFF4FC3F7).withOpacity(0.18), Colors.transparent, - ]), - border: Border.all(color: const Color(0xFF4FC3F7).withOpacity(0.25), width: 1.5), - ), - child: Image.asset('assets/logo.png', height: 80, - errorBuilder: (_, __, ___) => const Icon(Icons.child_care, size: 75, color: Color(0xFF4FC3F7))), - ), - const SizedBox(height: 20), - const Text('Creche e Berçário', - style: TextStyle(color: Color(0xFF4FC3F7), fontSize: 13, letterSpacing: 2.5, fontWeight: FontWeight.w500)), - const SizedBox(height: 3), - const Text('SEMENTES DO FUTURO', - style: TextStyle(color: Colors.white, fontSize: 21, fontWeight: FontWeight.w900, letterSpacing: 1.2)), - const SizedBox(height: 5), - Text('"Conforto, cuidado e aprendizagem"', - style: TextStyle(color: Colors.white.withOpacity(0.35), fontSize: 11, fontStyle: FontStyle.italic)), - const SizedBox(height: 36), - - // ── Card principal ──────────────────────────── - Container( - padding: const EdgeInsets.all(26), - decoration: BoxDecoration( - color: const Color(0xFF161B22), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withOpacity(0.07)), - boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.35), blurRadius: 40, offset: const Offset(0, 16))], - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - - // Título dinâmico - Text( - _isRegister ? 'Criar conta 📝' : 'Bem-vindo de volta 👋', - style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - _isRegister - ? 'Cria a tua conta — um convite vai atribuir o teu acesso' - : 'Faz login para continuar', - style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 12), - ), - const SizedBox(height: 22), - - // Erro - if (_error != null) ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.red.withOpacity(0.3))), - child: Row(children: [ - const Icon(Icons.error_outline, color: Colors.red, size: 16), - const SizedBox(width: 8), - Expanded(child: Text(_error!, style: const TextStyle(color: Colors.red, fontSize: 12))), - GestureDetector(onTap: () => setState(() => _error = null), - child: const Icon(Icons.close, color: Colors.red, size: 14)), - ]), - ), - const SizedBox(height: 16), - ], - - // Nome (só no registo) - if (_isRegister) ...[ - _Field(ctrl: _nameCtrl, label: 'Nome completo', icon: Icons.person_outline), - const SizedBox(height: 14), - ], - - // Email - _Field(ctrl: _emailCtrl, label: 'Email', icon: Icons.alternate_email, type: TextInputType.emailAddress), - const SizedBox(height: 14), - - // Senha - _Field( - ctrl: _passCtrl, label: 'Senha', icon: Icons.lock_outline, obscure: _obscure, - onSubmitted: (_) => _isRegister ? _signUp() : _signIn(), - suffix: IconButton( - icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility, color: Colors.white38, size: 20), - onPressed: () => setState(() => _obscure = !_obscure), - ), - ), - - if (!_isRegister) ...[ - const SizedBox(height: 12), - Align( - alignment: Alignment.centerRight, - child: GestureDetector( - onTap: _forgotPassword, - child: const Text('Esqueci a senha', - style: TextStyle(color: Color(0xFF4FC3F7), fontSize: 12, fontWeight: FontWeight.w500)), - ), - ), - ], - - const SizedBox(height: 24), - - // Botão principal - _GradButton( - label: _isRegister ? 'Criar Conta' : 'Entrar', - isLoading: _loading, - onTap: _isRegister ? _signUp : _signIn, - ), - - if (!_isRegister) ...[ - const SizedBox(height: 14), - Row(children: [ - Expanded(child: Divider(color: Colors.white.withOpacity(0.09))), - Padding(padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text('ou', style: TextStyle(color: Colors.white.withOpacity(0.25), fontSize: 11))), - Expanded(child: Divider(color: Colors.white.withOpacity(0.09))), - ]), - const SizedBox(height: 14), - _BiometricBtn(isLoading: _biometricLoading, onTap: _biometricSignIn), - ], - ]), - ), - - const SizedBox(height: 22), - - // ── Toggle Login / Registo ───────────────────── - GestureDetector( - onTap: _toggleMode, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - decoration: BoxDecoration( - color: const Color(0xFF161B22), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white.withOpacity(0.07)), - ), - child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - _isRegister ? 'Já tens conta? ' : 'Ainda não tens conta? ', - style: TextStyle(color: Colors.white.withOpacity(0.45), fontSize: 13), - ), - Text( - _isRegister ? 'Fazer login' : 'Criar conta', - style: const TextStyle(color: Color(0xFF4FC3F7), fontSize: 13, fontWeight: FontWeight.bold), - ), - ]), - ), - ), - - const SizedBox(height: 20), - // Aviso do fluxo de convites - if (_isRegister) Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFF4FC3F7).withOpacity(0.06), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFF4FC3F7).withOpacity(0.2)), - ), - child: const Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.info_outline, color: Color(0xFF4FC3F7), size: 16), - SizedBox(width: 10), - Expanded(child: Text( - 'Após criar a conta, se tiveres um convite da Diretora, verás automaticamente o ecrã de convite para aceitar o teu acesso.', - style: TextStyle(color: Color(0xFF888888), fontSize: 11, height: 1.5), - )), - ]), - ), - - const SizedBox(height: 20), - Text('v1.0 · Diário do Candengue', - style: TextStyle(color: Colors.white.withOpacity(0.15), fontSize: 10)), - const SizedBox(height: 24), - ]), - ), - ), - ), - ), - ]), - ); - } -} - -// ── Widgets auxiliares ───────────────────────────────────────────── - -class _Field extends StatelessWidget { - final TextEditingController ctrl; - final String label; - final IconData icon; - final bool obscure; - final TextInputType type; - final Widget? suffix; - final Function(String)? onSubmitted; - const _Field({required this.ctrl, required this.label, required this.icon, - this.obscure = false, this.type = TextInputType.text, this.suffix, this.onSubmitted}); - @override - Widget build(BuildContext context) => TextField( - controller: ctrl, obscureText: obscure, keyboardType: type, onSubmitted: onSubmitted, - style: const TextStyle(color: Colors.white, fontSize: 14), - decoration: InputDecoration( - labelText: label, - labelStyle: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 13), - prefixIcon: Icon(icon, color: const Color(0xFF4FC3F7).withOpacity(0.7), size: 19), - suffixIcon: suffix, - filled: true, fillColor: Colors.white.withOpacity(0.05), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.white.withOpacity(0.1))), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF4FC3F7), width: 1.5)), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), - ), - ); -} - -class _GradButton extends StatelessWidget { - final String label; final bool isLoading; final VoidCallback onTap; - const _GradButton({required this.label, required this.isLoading, required this.onTap}); - @override - Widget build(BuildContext context) => GestureDetector( - onTap: isLoading ? null : onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), height: 52, - decoration: BoxDecoration( - gradient: LinearGradient(colors: isLoading - ? [const Color(0xFF2A6A8A), const Color(0xFF2A6A8A)] - : [const Color(0xFF4FC3F7), const Color(0xFF0288D1)]), - borderRadius: BorderRadius.circular(14), - boxShadow: isLoading ? [] : [BoxShadow(color: const Color(0xFF4FC3F7).withOpacity(0.3), blurRadius: 18, offset: const Offset(0, 6))], - ), - child: Center(child: isLoading - ? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) - : Text(label, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold))), - ), - ); -} - -class _BiometricBtn extends StatelessWidget { - final bool isLoading; final VoidCallback onTap; - const _BiometricBtn({required this.isLoading, required this.onTap}); - @override - Widget build(BuildContext context) => GestureDetector( - onTap: isLoading ? null : onTap, - child: Container( - height: 50, - decoration: BoxDecoration(color: Colors.white.withOpacity(0.04), borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white.withOpacity(0.1))), - child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - isLoading - ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Color(0xFF4FC3F7), strokeWidth: 2)) - : const Icon(Icons.fingerprint, color: Color(0xFF4FC3F7), size: 22), - const SizedBox(width: 10), - Text(kIsWeb ? 'Biometria (só mobile)' : 'Entrar com biometria', - style: TextStyle(color: kIsWeb ? Colors.white24 : Colors.white60, fontSize: 13)), - ]), - ), - ); -} - -class _Orb extends StatelessWidget { - final double size; final Color color; - const _Orb({required this.size, required this.color}); - @override - Widget build(BuildContext context) => Container( - width: size, height: size, - decoration: BoxDecoration(shape: BoxShape.circle, color: color, - boxShadow: [BoxShadow(color: color, blurRadius: size / 2)]), - ); -}