From 6606a41df90c45712c9ec79d8da12d0f06dc0196 Mon Sep 17 00:00:00 2001 From: Alberto Date: Wed, 11 Mar 2026 19:26:21 +0000 Subject: [PATCH] Eliminar creche_app/lib/features/users/users_management_screen.dart --- .../users/users_management_screen.dart | 820 ------------------ 1 file changed, 820 deletions(-) delete mode 100644 creche_app/lib/features/users/users_management_screen.dart diff --git a/creche_app/lib/features/users/users_management_screen.dart b/creche_app/lib/features/users/users_management_screen.dart deleted file mode 100644 index b940326..0000000 --- a/creche_app/lib/features/users/users_management_screen.dart +++ /dev/null @@ -1,820 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:uuid/uuid.dart'; -import '/core/auth_provider.dart'; -import '/models/profile.dart'; -import '/models/daily_access_approval.dart'; -import '/models/invite.dart'; - -// ── Paleta ──────────────────────────────────────────────────────────── -const _bg = Color(0xFF0D1117); -const _card = Color(0xFF161B22); -const _blue = Color(0xFF4FC3F7); -const _green = Color(0xFF2ECC71); -const _amber = Color(0xFFFFB300); -const _red = Color(0xFFE74C3C); - -// ── Role helpers ────────────────────────────────────────────────────── -Color _rc(String r) { - switch (r) { - case 'principal': return const Color(0xFFFFD700); - case 'admin': return const Color(0xFFFF7043); - case 'teacher': return _blue; - case 'staff': return const Color(0xFFA5D6A7); - case 'parent': return _amber; - default: return const Color(0xFF666688); - } -} - -String _rl(String r) { - switch (r) { - case 'principal': return 'Diretora'; - case 'admin': return 'Admin'; - case 'teacher': return 'Educadora'; - case 'staff': return 'Auxiliar'; - case 'parent': return 'Encarregado'; - default: return r; - } -} - -const _rolesMeta = [ - {'value': 'teacher', 'label': '👩‍🏫 Educadora', 'desc': 'Turmas, diários, presenças'}, - {'value': 'staff', 'label': '🧹 Auxiliar', 'desc': 'Acesso operacional básico'}, - {'value': 'admin', 'label': '⚙️ Administrador', 'desc': 'Pagamentos e relatórios'}, - {'value': 'parent', 'label': '👨‍👧 Encarregado', 'desc': 'Só diário do(s) filho(s)'}, -]; - -// ═════════════════════════════════════════════════════════════════════ -// SCREEN -// ═════════════════════════════════════════════════════════════════════ -class UsersManagementScreen extends ConsumerStatefulWidget { - const UsersManagementScreen({super.key}); - @override - ConsumerState createState() => _ScreenState(); -} - -class _ScreenState extends ConsumerState - with SingleTickerProviderStateMixin { - late TabController _tab; - - @override - void initState() { super.initState(); _tab = TabController(length: 4, vsync: this); } - @override - void dispose() { _tab.dispose(); super.dispose(); } - - @override - Widget build(BuildContext context) { - final myRole = ref.watch(currentProfileProvider).valueOrNull?.role ?? ''; - final canManage = myRole == 'principal' || myRole == 'admin'; - - return Scaffold( - backgroundColor: _bg, - appBar: AppBar( - backgroundColor: _card, elevation: 0, - title: const Text('Utilizadores', style: TextStyle(color: _blue, fontWeight: FontWeight.bold, fontSize: 18)), - bottom: TabBar( - controller: _tab, - indicatorColor: _blue, indicatorWeight: 3, - labelColor: _blue, unselectedLabelColor: const Color(0xFF555577), - labelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 11), - isScrollable: true, tabAlignment: TabAlignment.start, - tabs: const [ - Tab(icon: Icon(Icons.people, size: 17), text: 'Equipa'), - Tab(icon: Icon(Icons.schedule, size: 17), text: 'Acessos'), - Tab(icon: Icon(Icons.mail_outline, size: 17), text: 'Convites'), - Tab(icon: Icon(Icons.person_add, size: 17), text: 'Convidar'), - ], - ), - ), - body: TabBarView( - controller: _tab, - children: [ - _TeamTab(canManage: canManage), - const _AccessesTab(), - _InvitesTab(canManage: canManage), - _SendInviteTab(onSent: () => _tab.animateTo(2)), - ], - ), - ); - } -} - -// ═════════════════════════════════════════════════════════════════════ -// TAB 1 — EQUIPA -// ═════════════════════════════════════════════════════════════════════ -class _TeamTab extends ConsumerWidget { - final bool canManage; - const _TeamTab({required this.canManage}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final sb = Supabase.instance.client; - final myUid = sb.auth.currentUser?.id; - return StreamBuilder>>( - stream: sb.from('profiles').stream(primaryKey: ['id']), - builder: (ctx, snap) { - if (!snap.hasData) return const _Loader(); - final order = ['principal','admin','teacher','staff','parent']; - final list = snap.data!.map(Profile.fromMap).toList() - ..sort((a,b) => order.indexOf(a.role).compareTo(order.indexOf(b.role))); - final groups = >{}; - for (final p in list) (groups[p.role] ??= []).add(p); - - return ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), - children: [ - _StatsRow(profiles: list), - const SizedBox(height: 20), - for (final role in order) - if (groups[role] != null) ...[ - _SecHeader(label: _rl(role), color: _rc(role), count: groups[role]!.length), - ...groups[role]!.map((p) => _UserTile( - profile: p, isSelf: p.userId == myUid, - canDelete: canManage && p.role != 'principal' && p.userId != myUid, - canChangeRole: canManage && p.role != 'principal', - onDelete: () => _deleteDialog(ctx, ref, p, sb), - onChangeRole: () => _roleDialog(ctx, p, sb), - )), - const SizedBox(height: 6), - ], - ], - ); - }, - ); - } - - // ── Diálogo apagar ─────────────────────────────────────────────── - void _deleteDialog(BuildContext ctx, WidgetRef ref, Profile p, SupabaseClient sb) { - showDialog( - context: ctx, - builder: (_) => AlertDialog( - backgroundColor: _card, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: const Row(children: [ - Icon(Icons.warning_amber_rounded, color: _red, size: 24), - SizedBox(width: 10), - Text('Remover utilizador', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - ]), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - RichText(text: TextSpan(style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 14, height: 1.6), children: [ - const TextSpan(text: 'Vais remover '), - TextSpan(text: p.fullName, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), - const TextSpan(text: ' ('), - TextSpan(text: _rl(p.role), style: TextStyle(color: _rc(p.role))), - const TextSpan(text: ') do sistema.\nEsta acção é irreversível.'), - ])), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: _red.withOpacity(0.07), borderRadius: BorderRadius.circular(10), - border: Border.all(color: _red.withOpacity(0.3))), - child: const Text('⚠️ O perfil será apagado. Para apagar também a conta de autenticação vai ao painel Supabase → Authentication → Users.', - style: TextStyle(color: _red, fontSize: 11, height: 1.5)), - ), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar', style: TextStyle(color: Color(0xFF888888)))), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: _red, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), - onPressed: () async { - Navigator.pop(ctx); - try { - // 1. Remove child_guardians links - try { await sb.from('child_guardians').delete().eq('guardian_id', p.id); } catch (_) {} - // 2. Nullify invited_by on invites (can't delete — RLS only allows admin by id) - try { await sb.from('invites').update({'invited_by': null}).eq('invited_by', p.id); } catch (_) {} - // 3. Remove daily access requests - try { await sb.from('daily_access_approvals').delete().eq('user_id', p.id); } catch (_) {} - // 4. Remove the profile (this triggers cascade) - await sb.from('profiles').delete().eq('id', p.id); - // O utilizador perde o acesso imediatamente: - // sem perfil → authorize() retorna false → todas as queries falham → é redirecionado para login - if (ctx.mounted) _snack(ctx, '${p.fullName} removido. O acesso foi revogado.', ok: true); - } catch (e) { - if (ctx.mounted) _snack(ctx, 'Erro: ${e.toString().replaceAll('Exception: ', '')}'); - } - }, - child: const Text('Remover', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), - ), - ], - ), - ); - } - - // ── Diálogo alterar role ───────────────────────────────────────── - void _roleDialog(BuildContext ctx, Profile p, SupabaseClient sb) { - // Diretora não pode ter o role alterado por ninguém (excepto no SQL) - if (p.role == 'principal') { - ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar( - content: Text('A função da Diretora não pode ser alterada aqui.'), - backgroundColor: Color(0xFFE74C3C), behavior: SnackBarBehavior.floating)); - return; - } - // Admin não pode alterar o próprio role - final currentUser = sb.auth.currentUser; - if (currentUser != null && p.userId == currentUser.id) { - ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar( - content: Text('Não podes alterar a tua própria função.'), - backgroundColor: Color(0xFFE74C3C), behavior: SnackBarBehavior.floating)); - return; - } - String picked = p.role; - showDialog( - context: ctx, - builder: (_) => StatefulBuilder( - builder: (_, set) => AlertDialog( - backgroundColor: _card, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Text('Função de ${p.fullName.split(' ').first}', - style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)), - content: Column(mainAxisSize: MainAxisSize.min, - children: _rolesMeta.map((r) { - final sel = picked == r['value']; - final c = _rc(r['value']!); - return GestureDetector( - onTap: () => set(() => picked = r['value']!), - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: sel ? c.withOpacity(0.1) : Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: sel ? c : Colors.white.withOpacity(0.08)), - ), - child: Row(children: [ - Expanded(child: Text(r['label']!, style: TextStyle(color: sel ? c : Colors.white, fontWeight: FontWeight.w600, fontSize: 13))), - if (sel) Icon(Icons.check_circle, color: c, size: 18), - ]), - ), - ); - }).toList(), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar', style: TextStyle(color: Color(0xFF888888)))), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: _blue, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), - onPressed: () async { - Navigator.pop(ctx); - if (picked == p.role) return; - await sb.from('profiles').update({'role': picked}).eq('id', p.id); - if (ctx.mounted) _snack(ctx, 'Função actualizada para ${_rl(picked)}.', ok: true); - }, - child: const Text('Guardar', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), - ), - ], - ), - ), - ); - } -} - -class _StatsRow extends StatelessWidget { - final List profiles; - const _StatsRow({required this.profiles}); - @override - Widget build(BuildContext context) { - final c = {}; - for (final p in profiles) c[p.role] = (c[p.role] ?? 0) + 1; - return Row(children: [ - for (final t in [('principal','👑','Dir.'),('teacher','👩‍🏫','Educ.'),('staff','🧹','Aux.'),('parent','👨‍👧','Enc.')]) - Expanded(child: Container( - margin: const EdgeInsets.only(right: 8), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(12), border: Border.all(color: _rc(t.$1).withOpacity(0.2))), - child: Column(children: [ - Text(t.$2, style: const TextStyle(fontSize: 16)), - Text('${c[t.$1] ?? 0}', style: TextStyle(color: _rc(t.$1), fontWeight: FontWeight.bold, fontSize: 16)), - Text(t.$3, style: const TextStyle(color: Color(0xFF666688), fontSize: 9)), - ]), - )), - ]); - } -} - -class _SecHeader extends StatelessWidget { - final String label; final Color color; final int count; - const _SecHeader({required this.label, required this.color, required this.count}); - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(bottom: 8, top: 4), - child: Row(children: [ - Container(width: 3, height: 16, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))), - const SizedBox(width: 8), - Text(label.toUpperCase(), style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 11, letterSpacing: 1)), - const SizedBox(width: 8), - Container(padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration(color: color.withOpacity(0.12), borderRadius: BorderRadius.circular(10)), - child: Text('$count', style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.bold))), - ]), - ); -} - -class _UserTile extends StatelessWidget { - final Profile profile; - final bool isSelf, canDelete, canChangeRole; - final VoidCallback onDelete, onChangeRole; - const _UserTile({required this.profile, required this.isSelf, required this.canDelete, - required this.canChangeRole, required this.onDelete, required this.onChangeRole}); - - @override - Widget build(BuildContext context) { - final c = _rc(profile.role); - return Container( - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), - border: Border.all(color: isSelf ? _blue.withOpacity(0.5) : c.withOpacity(0.12))), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row(children: [ - // Avatar - Stack(children: [ - CircleAvatar(radius: 24, backgroundColor: c.withOpacity(0.12), - backgroundImage: profile.avatarUrl != null ? NetworkImage(profile.avatarUrl!) : null, - child: profile.avatarUrl == null - ? Text(profile.fullName.isNotEmpty ? profile.fullName[0].toUpperCase() : '?', - style: TextStyle(color: c, fontWeight: FontWeight.bold, fontSize: 18)) : null), - if (isSelf) Positioned(bottom: 0, right: 0, child: Container(width: 11, height: 11, - decoration: BoxDecoration(color: _green, shape: BoxShape.circle, border: Border.all(color: _card, width: 2)))), - ]), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Expanded(child: Text(profile.fullName, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14), - overflow: TextOverflow.ellipsis)), - if (isSelf) Container(margin: const EdgeInsets.only(left: 5), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration(color: _blue.withOpacity(0.15), borderRadius: BorderRadius.circular(8)), - child: const Text('eu', style: TextStyle(color: _blue, fontSize: 10, fontWeight: FontWeight.bold))), - ]), - Text(profile.phone ?? 'Sem telefone', - style: const TextStyle(color: Color(0xFF666688), fontSize: 12)), - ])), - const SizedBox(width: 8), - Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ - // Badge role (clicável para mudar) - GestureDetector( - onTap: canChangeRole ? onChangeRole : null, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), - decoration: BoxDecoration(color: c.withOpacity(0.12), borderRadius: BorderRadius.circular(20), - border: canChangeRole ? Border.all(color: c.withOpacity(0.35)) : null), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text(_rl(profile.role), style: TextStyle(color: c, fontSize: 11, fontWeight: FontWeight.bold)), - if (canChangeRole) ...[const SizedBox(width: 4), Icon(Icons.edit, size: 10, color: c.withOpacity(0.6))], - ]), - ), - ), - if (canDelete) ...[ - const SizedBox(height: 6), - GestureDetector( - onTap: onDelete, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration(color: _red.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: _red.withOpacity(0.35))), - child: const Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.person_remove, size: 11, color: _red), - SizedBox(width: 3), - Text('Remover', style: TextStyle(color: _red, fontSize: 10, fontWeight: FontWeight.bold)), - ]), - ), - ), - ], - ]), - ]), - ), - ); - } -} - -// ═════════════════════════════════════════════════════════════════════ -// TAB 2 — ACESSOS HOJE -// ═════════════════════════════════════════════════════════════════════ -class _AccessesTab extends ConsumerWidget { - const _AccessesTab(); - @override - Widget build(BuildContext context, WidgetRef ref) { - final sb = Supabase.instance.client; - final today = DateTime.now().toIso8601String().split('T')[0]; - return StreamBuilder>>( - stream: sb.from('daily_access_approvals').stream(primaryKey: ['id']) - .map((r) => r.where((x) => x['approval_date'] == today).toList()), - builder: (_, snap) { - if (!snap.hasData) return const _Loader(); - if (snap.data!.isEmpty) return _empty('Sem pedidos de acesso hoje', Icons.access_time_outlined); - final list = snap.data!.map(DailyAccessApproval.fromMap).toList() - ..sort((a,b) => a.status.compareTo(b.status)); - return ListView(padding: const EdgeInsets.all(16), - children: list.map((a) => _ApprovalTile(a: a, sb: sb)).toList()); - }, - ); - } -} - -class _ApprovalTile extends StatelessWidget { - final DailyAccessApproval a; final SupabaseClient sb; - const _ApprovalTile({required this.a, required this.sb}); - Color get _c => a.status == 'approved' ? _green : a.status == 'rejected' ? _red : _amber; - @override - Widget build(BuildContext context) => Container( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), border: Border.all(color: _c.withOpacity(0.25))), - child: Column(children: [ - Row(children: [ - CircleAvatar(radius: 18, backgroundColor: _c.withOpacity(0.12), child: Icon(Icons.person_outline, color: _c, size: 18)), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('ID: ${a.userId.length > 12 ? a.userId.substring(0,12) : a.userId}...', - style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500)), - if (a.ipAddress != null) Text('IP: ${a.ipAddress}', style: const TextStyle(color: Color(0xFF888888), fontSize: 11)), - ])), - _Badge(text: a.status.toUpperCase(), color: _c), - ]), - if (a.status == 'pending') ...[ - const SizedBox(height: 12), - const Divider(color: Color(0xFF1E2233), height: 1), - const SizedBox(height: 12), - Row(children: [ - Expanded(child: _Btn(label: '✓ Aprovar', color: _green, onTap: () => sb.from('daily_access_approvals').update({'status':'approved','approved_at':DateTime.now().toIso8601String(),'approved_by':sb.auth.currentUser?.id}).eq('id',a.id))), - const SizedBox(width: 8), - Expanded(child: _Btn(label: '✕ Rejeitar', color: _red, onTap: () => sb.from('daily_access_approvals').update({'status':'rejected'}).eq('id',a.id))), - ]), - ], - ]), - ); -} - -// ═════════════════════════════════════════════════════════════════════ -// TAB 3 — LISTA CONVITES -// ═════════════════════════════════════════════════════════════════════ -class _InvitesTab extends ConsumerWidget { - final bool canManage; - const _InvitesTab({required this.canManage}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final sb = Supabase.instance.client; - return StreamBuilder>>( - stream: sb.from('invites').stream(primaryKey: ['id']), - builder: (_, snap) { - if (snap.hasError) return _errBox('Tabela "invites" não existe.\nCorre o COMPLETE_MIGRATION.sql.'); - if (!snap.hasData) return const _Loader(); - if (snap.data!.isEmpty) return _empty('Nenhum convite enviado ainda', Icons.mail_outline); - final list = snap.data!.map(Invite.fromMap).toList() - ..sort((a,b) => b.createdAt.compareTo(a.createdAt)); - return ListView(padding: const EdgeInsets.all(16), - children: list.map((i) => _InviteTile(inv: i, sb: sb, canManage: canManage)).toList()); - }, - ); - } -} - -class _InviteTile extends StatelessWidget { - final Invite inv; final SupabaseClient sb; final bool canManage; - const _InviteTile({required this.inv, required this.sb, required this.canManage}); - String get _sl { if (inv.isExpired && inv.status=='pending') return 'EXPIRADO'; switch(inv.status){case 'accepted':return 'ACEITE';case 'rejected':return 'RECUSADO';default:return 'PENDENTE';} } - Color get _c { if (inv.isExpired) return Colors.grey; switch(inv.status){case 'accepted':return _green;case 'rejected':return _red;default:return _amber;} } - String _fmt(DateTime d) => '${d.day.toString().padLeft(2,'0')}/${d.month.toString().padLeft(2,'0')} ${d.hour.toString().padLeft(2,'0')}:${d.minute.toString().padLeft(2,'0')}'; - @override - Widget build(BuildContext context) => Container( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), border: Border.all(color: _c.withOpacity(0.2))), - child: Row(children: [ - CircleAvatar(radius: 20, backgroundColor: _rc(inv.role).withOpacity(0.12), - child: Text(inv.email[0].toUpperCase(), style: TextStyle(color: _rc(inv.role), fontWeight: FontWeight.bold))), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(inv.email, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), overflow: TextOverflow.ellipsis), - Text(_rl(inv.role), style: TextStyle(color: _rc(inv.role), fontSize: 11)), - Text('Exp: ${_fmt(inv.expiresAt)}', style: const TextStyle(color: Color(0xFF666688), fontSize: 10)), - ])), - const SizedBox(width: 8), - Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ - _Badge(text: _sl, color: _c), - if (canManage && inv.status == 'pending' && !inv.isExpired) ...[ - const SizedBox(height: 6), - GestureDetector(onTap: () => sb.from('invites').delete().eq('id', inv.id), - child: const Text('Cancelar', style: TextStyle(color: _red, fontSize: 11))), - ], - ]), - ]), - ); -} - -// ═════════════════════════════════════════════════════════════════════ -// TAB 4 — ENVIAR CONVITE -// ═════════════════════════════════════════════════════════════════════ -class _SendInviteTab extends ConsumerStatefulWidget { - final VoidCallback onSent; - const _SendInviteTab({required this.onSent}); - @override - ConsumerState<_SendInviteTab> createState() => _SendState(); -} - -class _SendState extends ConsumerState<_SendInviteTab> { - final _eCtrl = TextEditingController(); - final _pCtrl = TextEditingController(); - final _nCtrl = TextEditingController(); - String _role = 'teacher'; - bool _loading = false; - String? _err, _ok; - String? _childId; - List> _children = []; - - @override - void initState() { super.initState(); _fetchChildren(); } - @override - void dispose() { _eCtrl.dispose(); _pCtrl.dispose(); _nCtrl.dispose(); super.dispose(); } - - Future _fetchChildren() async { - try { - final d = await Supabase.instance.client.from('children').select('id,first_name,last_name').order('first_name'); - if (mounted) setState(() => _children = List>.from(d)); - } catch (_) {} - } - - Future _send() async { - final email = _eCtrl.text.trim(); - final name = _nCtrl.text.trim(); - if (email.isEmpty || name.isEmpty) { setState(() => _err = 'Preencha o nome e o email.'); return; } - if (!RegExp(r'^[\w\-\.]+@[\w\-\.]+\.\w{2,}$').hasMatch(email)) { setState(() => _err = 'Email inválido.'); return; } - if (_role == 'parent' && _childId == null) { setState(() => _err = 'Seleccione a criança.'); return; } - setState(() { _loading = true; _err = null; _ok = null; }); - try { - final sb = Supabase.instance.client; - final me = await sb.from('profiles').select('id').eq('user_id', sb.auth.currentUser!.id).single(); - final inviteId = const Uuid().v4(); - - // 1. Guardar na BD primeiro - await sb.from('invites').insert({ - 'id': inviteId, 'email': email, 'role': _role, - 'phone': _pCtrl.text.trim().isEmpty ? null : _pCtrl.text.trim(), - 'invited_by': me['id'], 'status': 'pending', - 'expires_at': DateTime.now().add(const Duration(days: 7)).toIso8601String(), - 'child_id': _childId, - }); - - // 2. Chamar Edge Function para enviar email - String emailStatus = ''; - try { - final accessToken = sb.auth.currentSession?.accessToken ?? ''; - final res = await sb.functions.invoke('send-invite', - headers: {'Authorization': 'Bearer $accessToken'}, - body: { - 'email': email, - 'role': _role, - 'name': name, - 'phone': _pCtrl.text.trim().isEmpty ? null : _pCtrl.text.trim(), - 'childId': _childId, - 'inviteId': inviteId, - }); - final data = res.data as Map?; - if (data?['userExists'] == true) { - emailStatus = '\nO utilizador já tem conta — o convite aparece ao fazer login.'; - } else { - emailStatus = '\n📧 Email enviado com link para criar conta!'; - } - } catch (emailErr) { - // Email falhou mas o convite está na BD — não é crítico - emailStatus = '\n⚠️ Email automático não disponível. Partilha o link da app manualmente.'; - } - - if (mounted) { - setState(() { - _ok = '✅ Convite criado para $email!$emailStatus'; - _eCtrl.clear(); _nCtrl.clear(); _pCtrl.clear(); _childId = null; - }); - widget.onSent(); - } - } catch (e) { - setState(() => _err = 'Erro: $e'); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: LinearGradient(colors: [_blue.withOpacity(0.1), Colors.transparent]), - borderRadius: BorderRadius.circular(16), border: Border.all(color: _blue.withOpacity(0.2)), - ), - child: const Row(children: [ - Icon(Icons.verified_user_outlined, color: _blue, size: 28), - SizedBox(width: 14), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Sistema de Convites', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)), - SizedBox(height: 3), - Text('A pessoa aceita o convite ao entrar na app. Válido 7 dias.', style: TextStyle(color: Color(0xFF888888), fontSize: 12)), - ])), - ]), - ), - const SizedBox(height: 20), - - if (_err != null) _fb(_err!, false), - if (_ok != null) _fb(_ok!, true), - - _lbl('Nome completo'), - _tf(_nCtrl, 'Ex: Maria da Silva', Icons.person_outline), - const SizedBox(height: 14), - _lbl('Email'), - _tf(_eCtrl, 'email@exemplo.com', Icons.alternate_email, TextInputType.emailAddress), - const SizedBox(height: 14), - _lbl('Telefone (opcional)'), - _tf(_pCtrl, '+244 9xx xxx xxx', Icons.phone_outlined, TextInputType.phone), - const SizedBox(height: 14), - _lbl('Função'), - ..._rolesMeta.map((r) { - final sel = _role == r['value']; - final c = _rc(r['value']!); - return GestureDetector( - onTap: () => setState(() { _role = r['value']!; _childId = null; }), - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: sel ? c.withOpacity(0.1) : _card, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: sel ? c : Colors.white.withOpacity(0.08), width: sel ? 1.5 : 1), - ), - child: Row(children: [ - Text(r['label']!, style: TextStyle(color: sel ? c : Colors.white, fontWeight: FontWeight.w600, fontSize: 14)), - const SizedBox(width: 6), - Expanded(child: Text(r['desc']!, style: const TextStyle(color: Color(0xFF666688), fontSize: 11))), - AnimatedContainer(duration: const Duration(milliseconds: 120), - width: 20, height: 20, - decoration: BoxDecoration(shape: BoxShape.circle, color: sel ? c : Colors.transparent, border: Border.all(color: sel ? c : Colors.white24, width: 2)), - child: sel ? const Icon(Icons.check, size: 12, color: Colors.white) : null), - ]), - ), - ); - }), - - if (_role == 'parent') ...[ - const SizedBox(height: 14), - _lbl('Criança associada *'), - Container( - decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white.withOpacity(0.1))), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _childId, isExpanded: true, dropdownColor: _card, - style: const TextStyle(color: Colors.white, fontSize: 14), - padding: const EdgeInsets.symmetric(horizontal: 14), - hint: const Text('Seleccione a criança', style: TextStyle(color: Color(0xFF888888))), - items: _children.map((c) => DropdownMenuItem(value: c['id'] as String, - child: Text('${c['first_name']} ${c['last_name']}'))).toList(), - onChanged: (v) => setState(() => _childId = v), - ), - ), - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: _blue.withOpacity(0.06), borderRadius: BorderRadius.circular(10), border: Border.all(color: _blue.withOpacity(0.15))), - child: const Row(children: [ - Icon(Icons.info_outline, color: _blue, size: 14), - SizedBox(width: 8), - Expanded(child: Text('O encarregado só acede ao diário, presenças e mensagens do seu filho.', style: TextStyle(color: Color(0xFF888888), fontSize: 11))), - ]), - ), - ], - - const SizedBox(height: 24), - GestureDetector( - onTap: _loading ? null : _send, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - height: 54, width: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient(colors: _loading ? [const Color(0xFF1A3A4A), const Color(0xFF1A3A4A)] : [_blue, const Color(0xFF0288D1)]), - borderRadius: BorderRadius.circular(14), - boxShadow: _loading ? [] : [BoxShadow(color: _blue.withOpacity(0.3), blurRadius: 20, offset: const Offset(0,6))], - ), - child: Center(child: _loading - ? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5)) - : const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.send_outlined, color: Colors.white, size: 18), - SizedBox(width: 8), - Text('Enviar Convite', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)), - ])), - ), - ), - const SizedBox(height: 24), - // Como funciona - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: _card, borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.white.withOpacity(0.05))), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Row(children: [Icon(Icons.help_outline, color: _blue, size: 15), SizedBox(width: 8), Text('Como funciona?', style: TextStyle(color: _blue, fontWeight: FontWeight.bold, fontSize: 13))]), - const SizedBox(height: 12), - ...[(_blue,'1','Envias o convite (email + função)'), (_amber,'2','A pessoa instala a app e cria conta'), (_green,'3','Ao entrar, vê automaticamente o convite'), (_blue,'4','Aceita → fica com o role atribuído'), (_green,'5','Acede às suas funcionalidades')].map((s) => Padding( - padding: const EdgeInsets.only(bottom: 7), - child: Row(children: [ - Container(width: 22, height: 22, decoration: BoxDecoration(color: s.$1.withOpacity(0.15), shape: BoxShape.circle), child: Center(child: Text(s.$2, style: TextStyle(color: s.$1, fontSize: 11, fontWeight: FontWeight.bold)))), - const SizedBox(width: 10), - Expanded(child: Text(s.$3, style: const TextStyle(color: Color(0xFFAAAAAA), fontSize: 12))), - ]), - )), - ]), - ), - const SizedBox(height: 40), - ]), - ); - } - - Widget _lbl(String t) => Padding(padding: const EdgeInsets.only(bottom: 7), - child: Text(t, style: const TextStyle(color: Color(0xFFAAAAAA), fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.5))); - - Widget _tf(TextEditingController c, String hint, IconData icon, [TextInputType? t]) => TextField( - controller: c, keyboardType: t, - style: const TextStyle(color: Colors.white, fontSize: 14), - decoration: InputDecoration( - hintText: hint, hintStyle: const TextStyle(color: Color(0xFF555577), fontSize: 13), - prefixIcon: Icon(icon, color: _blue.withOpacity(0.7), size: 20), - filled: true, fillColor: _card, - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.white.withOpacity(0.1))), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: _blue, width: 1.5)), - contentPadding: const EdgeInsets.symmetric(vertical: 14), - ), - ); - - Widget _fb(String msg, bool ok) => Container( - margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: (ok ? _green : _red).withOpacity(0.1), borderRadius: BorderRadius.circular(12), - border: Border.all(color: (ok ? _green : _red).withOpacity(0.4))), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(ok ? Icons.check_circle_outline : Icons.error_outline, color: ok ? _green : _red, size: 18), - const SizedBox(width: 10), - Expanded(child: Text(msg, style: TextStyle(color: ok ? _green : _red, fontSize: 12))), - GestureDetector(onTap: () => setState(() => ok ? _ok = null : _err = null), - child: const Icon(Icons.close, color: Colors.white30, size: 16)), - ]), - ); -} - -// ── Widgets comuns ───────────────────────────────────────────────────── -class _Badge extends StatelessWidget { - final String text; final Color color; - const _Badge({required this.text, required this.color}); - @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration(color: color.withOpacity(0.12), borderRadius: BorderRadius.circular(20)), - child: Text(text, style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.bold)), - ); -} - -class _Btn extends StatelessWidget { - final String label; final Color color; final VoidCallback onTap; - const _Btn({required this.label, required this.color, required this.onTap}); - @override - Widget build(BuildContext context) => GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(10), border: Border.all(color: color.withOpacity(0.4))), - child: Center(child: Text(label, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13))), - ), - ); -} - -class _Loader extends StatelessWidget { - const _Loader(); - @override - Widget build(BuildContext context) => const Center(child: CircularProgressIndicator(color: _blue)); -} - -Widget _empty(String msg, IconData icon) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 64, color: Colors.white.withOpacity(0.07)), const SizedBox(height: 12), - Text(msg, style: const TextStyle(color: Color(0xFF888888))), -])); - -Widget _errBox(String msg) => Center(child: Container( - margin: const EdgeInsets.all(24), padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: _red.withOpacity(0.07), borderRadius: BorderRadius.circular(14), border: Border.all(color: _red.withOpacity(0.3))), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.error_outline, color: _red, size: 32), const SizedBox(height: 10), - Text(msg, style: const TextStyle(color: _red, fontSize: 13), textAlign: TextAlign.center), - const SizedBox(height: 8), - const Text('Corre o COMPLETE_MIGRATION.sql no Supabase SQL Editor.', style: TextStyle(color: Color(0xFF888888), fontSize: 11), textAlign: TextAlign.center), - ]), -)); - -void _snack(BuildContext ctx, String msg, {bool ok = false}) => - ScaffoldMessenger.of(ctx).showSnackBar(SnackBar( - content: Text(msg, style: const TextStyle(color: Colors.white)), - backgroundColor: ok ? _green : _red, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ));