Eliminar creche_app/lib/features/users/users_management_screen.dart

This commit is contained in:
Alberto 2026-03-11 19:26:21 +00:00
parent 49b3a21dfe
commit 6606a41df9
1 changed files with 0 additions and 820 deletions

View File

@ -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<UsersManagementScreen> createState() => _ScreenState();
}
class _ScreenState extends ConsumerState<UsersManagementScreen>
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<List<Map<String, dynamic>>>(
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 = <String, List<Profile>>{};
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<Profile> profiles;
const _StatsRow({required this.profiles});
@override
Widget build(BuildContext context) {
final c = <String,int>{};
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<List<Map<String, dynamic>>>(
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<List<Map<String, dynamic>>>(
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<Map<String,dynamic>> _children = [];
@override
void initState() { super.initState(); _fetchChildren(); }
@override
void dispose() { _eCtrl.dispose(); _pCtrl.dispose(); _nCtrl.dispose(); super.dispose(); }
Future<void> _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<Map<String,dynamic>>.from(d));
} catch (_) {}
}
Future<void> _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<String, dynamic>?;
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<String>(
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<String>(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)),
));