syncra_addons/creche_app/lib/features/auth/invite_pending_screen.dart

310 lines
12 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 '/core/auth_provider.dart';
import '/models/invite.dart';
// ─── Cores ───────────────────────────────────────────────
const _bg = Color(0xFF0D1117);
const _card = Color(0xFF161B22);
const _blue = Color(0xFF4FC3F7);
String _roleLabel(String r) {
switch (r) {
case 'teacher': return 'Educadora';
case 'staff': return 'Auxiliar';
case 'admin': return 'Administrador';
case 'parent': return 'Encarregado de Educação';
default: return r;
}
}
String _roleDesc(String r) {
switch (r) {
case 'teacher': return 'Gerir turmas, registar presenças, escrever diários diários das crianças.';
case 'staff': return 'Acesso operacional à app da creche com funcionalidades essenciais.';
case 'admin': return 'Gestão de pagamentos, relatórios e utilizadores do sistema.';
case 'parent': return 'Consultar o diário do seu filho, ver presenças e comunicar com a equipa.';
default: return '';
}
}
IconData _roleIcon(String r) {
switch (r) {
case 'teacher': return Icons.school_outlined;
case 'staff': return Icons.cleaning_services_outlined;
case 'admin': return Icons.admin_panel_settings_outlined;
case 'parent': return Icons.family_restroom;
default: return Icons.person_outline;
}
}
Color _roleColor(String r) {
switch (r) {
case 'teacher': return _blue;
case 'staff': return const Color(0xFFA5D6A7);
case 'admin': return const Color(0xFFFF7043);
case 'parent': return const Color(0xFFFFB300);
default: return Colors.grey;
}
}
/// Verificar convites pendentes quando o utilizador entra pela primeira vez
class InvitePendingScreen extends ConsumerStatefulWidget {
final Invite invite;
const InvitePendingScreen({super.key, required this.invite});
@override
ConsumerState<InvitePendingScreen> createState() => _State();
}
class _State extends ConsumerState<InvitePendingScreen> with SingleTickerProviderStateMixin {
late AnimationController _anim;
late Animation<double> _fade;
late Animation<Offset> _slide;
bool _accepting = false;
bool _rejecting = false;
@override
void initState() {
super.initState();
_anim = AnimationController(vsync: this, duration: const Duration(milliseconds: 700));
_fade = CurvedAnimation(parent: _anim, curve: Curves.easeOut);
_slide = Tween<Offset>(begin: const Offset(0, 0.06), end: Offset.zero)
.animate(CurvedAnimation(parent: _anim, curve: Curves.easeOut));
_anim.forward();
}
@override
void dispose() { _anim.dispose(); super.dispose(); }
Future<void> _accept() async {
setState(() => _accepting = true);
final supabase = Supabase.instance.client;
final user = supabase.auth.currentUser!;
try {
// 1 ─ Actualizar o convite: accepted + expirar imediatamente
await supabase.from('invites').update({
'status': 'accepted',
'accepted_at': DateTime.now().toIso8601String(),
'expires_at': DateTime.now().toIso8601String(), // expirar agora → nunca mais aparece
}).eq('id', widget.invite.id);
// 2 ─ Verificar se já tem perfil
final existing = await supabase.from('profiles').select().eq('user_id', user.id).maybeSingle();
if (existing == null) {
// 3a ─ Criar o perfil com a função do convite
await supabase.from('profiles').insert({
'user_id': user.id,
'full_name': user.email?.split('@').first ?? 'Utilizador',
'role': widget.invite.role,
'phone': widget.invite.phone,
});
} else {
// 3b ─ Actualizar role do perfil existente
await supabase.from('profiles').update({'role': widget.invite.role}).eq('user_id', user.id);
}
// 4 ─ Se for encarregado e tiver criança, criar ligação
if (widget.invite.role == 'parent' && widget.invite.childId != null) {
await supabase.from('child_guardians').upsert({
'child_id': widget.invite.childId,
'guardian_id': (await supabase.from('profiles').select('id').eq('user_id', user.id).single())['id'],
'relationship': 'parent',
});
}
// 5 ─ Invalidar o provider e navegar
ref.invalidate(currentProfileProvider);
ref.invalidate(currentSessionProvider);
if (mounted) {
_showSnack('Bem-vindo à equipa! 🎉', success: true);
await Future.delayed(const Duration(milliseconds: 800));
if (mounted) context.go('/home');
}
} catch (e) {
setState(() => _accepting = false);
_showSnack('Erro ao aceitar convite: $e');
}
}
Future<void> _reject() async {
setState(() => _rejecting = true);
try {
await Supabase.instance.client.from('invites').update({
'status': 'rejected',
'expires_at': DateTime.now().toIso8601String(), // expirar agora
}).eq('id', widget.invite.id);
await ref.read(authNotifierProvider.notifier).signOut();
if (mounted) context.go('/login');
} catch (_) {
setState(() => _rejecting = false);
}
}
void _showSnack(String msg, {bool success = false}) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(msg, style: const TextStyle(color: Colors.white)),
backgroundColor: success ? const Color(0xFF2ECC71) : Colors.red,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
));
}
@override
Widget build(BuildContext context) {
final inv = widget.invite;
final color = _roleColor(inv.role);
return Scaffold(
backgroundColor: _bg,
body: SafeArea(
child: FadeTransition(
opacity: _fade,
child: SlideTransition(
position: _slide,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(children: [
const SizedBox(height: 32),
// Logo / header
Container(
width: 80, height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(colors: [color.withOpacity(0.2), Colors.transparent]),
border: Border.all(color: color.withOpacity(0.4), width: 2),
),
child: Icon(_roleIcon(inv.role), color: color, size: 36),
),
const SizedBox(height: 20),
const Text('Convite Recebido 🎉',
style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('A Diretora convidou-te para a equipa',
style: TextStyle(color: Colors.white.withOpacity(0.45), fontSize: 14)),
const SizedBox(height: 32),
// Card do convite
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _card,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withOpacity(0.3), width: 1.5),
boxShadow: [BoxShadow(color: color.withOpacity(0.1), blurRadius: 30, offset: const Offset(0, 8))],
),
child: Column(children: [
// Role badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(_roleIcon(inv.role), color: color, size: 16),
const SizedBox(width: 8),
Text(_roleLabel(inv.role),
style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14, letterSpacing: 0.5)),
]),
),
const SizedBox(height: 20),
Text(_roleDesc(inv.role),
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14, height: 1.6)),
const SizedBox(height: 20),
const Divider(color: Color(0xFF222244)),
const SizedBox(height: 16),
// Detalhes
_DetailRow(icon: Icons.mail_outline, label: 'Email', value: inv.email),
if (inv.phone != null) ...[
const SizedBox(height: 8),
_DetailRow(icon: Icons.phone_outlined, label: 'Telefone', value: inv.phone!),
],
const SizedBox(height: 8),
_DetailRow(
icon: Icons.timer_outlined,
label: 'Expira em',
value: '${inv.expiresAt.difference(DateTime.now()).inDays} dias',
),
]),
),
const SizedBox(height: 28),
// Botões
_GradientBtn(
label: 'Aceitar e Entrar na Equipa',
color: color,
isLoading: _accepting,
icon: Icons.check_circle_outline,
onTap: _accept,
),
const SizedBox(height: 12),
GestureDetector(
onTap: _rejecting ? null : _reject,
child: Container(
height: 50, width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.red.withOpacity(0.4)),
),
child: Center(child: _rejecting
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(color: Colors.red, strokeWidth: 2))
: const Text('Recusar convite', style: TextStyle(color: Colors.red, fontSize: 14))),
),
),
const SizedBox(height: 32),
]),
),
),
),
),
);
}
}
class _DetailRow extends StatelessWidget {
final IconData icon; final String label, value;
const _DetailRow({required this.icon, required this.label, required this.value});
@override
Widget build(BuildContext context) => Row(children: [
Icon(icon, size: 16, color: const Color(0xFF888888)),
const SizedBox(width: 8),
Text('$label: ', style: const TextStyle(color: Color(0xFF888888), fontSize: 12)),
Expanded(child: Text(value, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis)),
]);
}
class _GradientBtn extends StatelessWidget {
final String label; final Color color; final bool isLoading;
final IconData icon; final VoidCallback onTap;
const _GradientBtn({required this.label, required this.color, required this.isLoading, required this.icon, required this.onTap});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: isLoading ? null : onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 54, width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(colors: isLoading ? [color.withOpacity(0.4), color.withOpacity(0.4)] : [color, color.withOpacity(0.7)]),
borderRadius: BorderRadius.circular(14),
boxShadow: isLoading ? [] : [BoxShadow(color: color.withOpacity(0.3), blurRadius: 20, offset: const Offset(0, 6))],
),
child: Center(child: isLoading
? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5))
: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(icon, color: Colors.white, size: 20),
const SizedBox(width: 10),
Text(label, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
])),
),
);
}