336 lines
14 KiB
Dart
336 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import '/core/auth_provider.dart';
|
|
import '/core/supabase_client.dart';
|
|
import '/shared/widgets/custom_button.dart';
|
|
|
|
const _bg = Color(0xFF0D1117);
|
|
const _card = Color(0xFF161B22);
|
|
const _blue = Color(0xFF4FC3F7);
|
|
|
|
String _roleLabel(String r) {
|
|
switch (r) {
|
|
case 'principal': return 'Diretora';
|
|
case 'admin': return 'Administrador';
|
|
case 'teacher': return 'Educadora';
|
|
case 'staff': return 'Auxiliar';
|
|
case 'parent': return 'Encarregado';
|
|
default: return r;
|
|
}
|
|
}
|
|
|
|
Color _roleColor(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 const Color(0xFFFFB300);
|
|
default: return Colors.grey;
|
|
}
|
|
}
|
|
|
|
class ProfileScreen extends ConsumerStatefulWidget {
|
|
const ProfileScreen({super.key});
|
|
@override
|
|
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
|
|
}
|
|
|
|
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|
final _nameCtrl = TextEditingController();
|
|
final _phoneCtrl = TextEditingController();
|
|
final _newPassCtrl = TextEditingController();
|
|
final _confPassCtrl = TextEditingController();
|
|
bool _isSaving = false;
|
|
bool _changingPw = false;
|
|
bool _showPwForm = false;
|
|
bool _obscureNew = true;
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameCtrl.dispose(); _phoneCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final profileAsync = ref.watch(currentProfileProvider);
|
|
return profileAsync.when(
|
|
data: (profile) {
|
|
if (profile == null) {
|
|
return const Scaffold(backgroundColor: _bg,
|
|
body: Center(child: Text('Perfil não encontrado', style: TextStyle(color: Colors.white))));
|
|
}
|
|
// só preenche se vazio (evita reset ao rebuild)
|
|
if (_nameCtrl.text.isEmpty) _nameCtrl.text = profile.fullName;
|
|
if (_phoneCtrl.text.isEmpty) _phoneCtrl.text = profile.phone ?? '';
|
|
final roleColor = _roleColor(profile.role);
|
|
|
|
return Scaffold(
|
|
backgroundColor: _bg,
|
|
appBar: AppBar(
|
|
backgroundColor: _card,
|
|
title: const Text('O meu perfil', style: TextStyle(color: _blue, fontWeight: FontWeight.bold)),
|
|
elevation: 0,
|
|
),
|
|
body: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(children: [
|
|
|
|
// ── Avatar ─────────────────────────────────────────
|
|
Center(child: Stack(children: [
|
|
Container(
|
|
width: 100, height: 100,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: roleColor.withOpacity(0.5), width: 2.5),
|
|
color: roleColor.withOpacity(0.1),
|
|
),
|
|
child: profile.avatarUrl != null
|
|
? ClipOval(child: Image.network(profile.avatarUrl!, fit: BoxFit.cover))
|
|
: Center(child: Text(
|
|
profile.fullName.isNotEmpty ? profile.fullName[0].toUpperCase() : 'U',
|
|
style: TextStyle(color: roleColor, fontSize: 38, fontWeight: FontWeight.bold))),
|
|
),
|
|
Positioned(bottom: 0, right: 0, child: GestureDetector(
|
|
onTap: () => _pickAvatar(profile.id),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(7),
|
|
decoration: BoxDecoration(color: _blue, shape: BoxShape.circle,
|
|
border: Border.all(color: _bg, width: 2)),
|
|
child: const Icon(Icons.camera_alt, color: Colors.white, size: 16),
|
|
),
|
|
)),
|
|
])),
|
|
const SizedBox(height: 12),
|
|
|
|
// Role badge (só visualização — não pode mudar)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: roleColor.withOpacity(0.12),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: roleColor.withOpacity(0.3)),
|
|
),
|
|
child: Text(_roleLabel(profile.role).toUpperCase(),
|
|
style: TextStyle(color: roleColor, fontSize: 11, fontWeight: FontWeight.bold, letterSpacing: 1.2)),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text('A tua função é atribuída pela Diretora',
|
|
style: TextStyle(color: Colors.white.withOpacity(0.3), fontSize: 11)),
|
|
const SizedBox(height: 28),
|
|
|
|
// ── Dados pessoais ─────────────────────────────────
|
|
_Section(title: 'Dados Pessoais', children: [
|
|
_Field(ctrl: _nameCtrl, label: 'Nome completo', icon: Icons.person_outline),
|
|
const SizedBox(height: 14),
|
|
_Field(ctrl: _phoneCtrl, label: 'Telefone', icon: Icons.phone_outlined,
|
|
type: TextInputType.phone),
|
|
const SizedBox(height: 14),
|
|
// Email — só leitura
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.03),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
|
),
|
|
child: Row(children: [
|
|
Icon(Icons.alternate_email, color: _blue.withOpacity(0.5), size: 19),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text('Email', style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 11)),
|
|
const SizedBox(height: 2),
|
|
Text(Supabase.instance.client.auth.currentUser?.email ?? '',
|
|
style: const TextStyle(color: Colors.white70, fontSize: 14)),
|
|
])),
|
|
const Icon(Icons.lock_outline, color: Colors.white24, size: 14),
|
|
]),
|
|
),
|
|
const SizedBox(height: 20),
|
|
CustomButton(text: 'Guardar Alterações', isLoading: _isSaving,
|
|
onPressed: () => _save(profile.id), icon: Icons.save_outlined),
|
|
]),
|
|
const SizedBox(height: 16),
|
|
|
|
// ── Alterar Senha ──────────────────────────────────
|
|
_Section(
|
|
title: 'Segurança',
|
|
trailing: TextButton(
|
|
onPressed: () => setState(() => _showPwForm = !_showPwForm),
|
|
child: Text(_showPwForm ? 'Cancelar' : 'Alterar senha',
|
|
style: const TextStyle(color: _blue, fontSize: 12)),
|
|
),
|
|
children: [
|
|
if (!_showPwForm)
|
|
Text('Podes alterar a tua senha a qualquer momento.',
|
|
style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 13))
|
|
else ...[
|
|
_Field(ctrl: _newPassCtrl, label: 'Nova senha', icon: Icons.lock_outline,
|
|
obscure: _obscureNew,
|
|
suffix: IconButton(
|
|
icon: Icon(_obscureNew ? Icons.visibility_off : Icons.visibility,
|
|
color: Colors.white38, size: 18),
|
|
onPressed: () => setState(() => _obscureNew = !_obscureNew),
|
|
)),
|
|
const SizedBox(height: 12),
|
|
_Field(ctrl: _confPassCtrl, label: 'Confirmar nova senha', icon: Icons.lock_outline,
|
|
obscure: _obscureNew),
|
|
const SizedBox(height: 16),
|
|
CustomButton(text: 'Actualizar Senha', isLoading: _changingPw,
|
|
onPressed: _changePassword, icon: Icons.security),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// ── Sair ───────────────────────────────────────────
|
|
GestureDetector(
|
|
onTap: () async {
|
|
await ref.read(authNotifierProvider.notifier).signOut();
|
|
if (context.mounted) context.go('/login');
|
|
},
|
|
child: Container(
|
|
height: 50, width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.red.withOpacity(0.4)),
|
|
color: Colors.red.withOpacity(0.06),
|
|
),
|
|
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
|
Icon(Icons.logout, color: Colors.red, size: 18),
|
|
SizedBox(width: 8),
|
|
Text('Terminar Sessão', style: TextStyle(color: Colors.red, fontSize: 14, fontWeight: FontWeight.w500)),
|
|
]),
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
]),
|
|
),
|
|
);
|
|
},
|
|
loading: () => const Scaffold(backgroundColor: _bg,
|
|
body: Center(child: CircularProgressIndicator(color: _blue))),
|
|
error: (e, _) => Scaffold(backgroundColor: _bg,
|
|
body: Center(child: Text('Erro: $e', style: const TextStyle(color: Colors.red)))),
|
|
);
|
|
}
|
|
|
|
Future<void> _pickAvatar(String profileId) async {
|
|
final picker = ImagePicker();
|
|
final file = await picker.pickImage(source: ImageSource.gallery, imageQuality: 70);
|
|
if (file == null) return;
|
|
final supabase = ref.read(supabaseProvider);
|
|
final bytes = await file.readAsBytes();
|
|
final path = 'avatars/${const Uuid().v4()}.jpg';
|
|
await supabase.storage.from('photos').uploadBinary(path, bytes);
|
|
final url = supabase.storage.from('photos').getPublicUrl(path);
|
|
await supabase.from('profiles').update({'avatar_url': url}).eq('id', profileId);
|
|
ref.invalidate(currentProfileProvider);
|
|
}
|
|
|
|
Future<void> _save(String profileId) async {
|
|
setState(() => _isSaving = true);
|
|
try {
|
|
final supabase = ref.read(supabaseProvider);
|
|
await supabase.from('profiles').update({
|
|
'full_name': _nameCtrl.text.trim(),
|
|
'phone': _phoneCtrl.text.trim(),
|
|
// NÃO inclui 'role' — utilizador não pode mudar o próprio role
|
|
}).eq('id', profileId);
|
|
ref.invalidate(currentProfileProvider);
|
|
if (mounted) _snack('Perfil actualizado! ✓', ok: true);
|
|
} catch (e) {
|
|
if (mounted) _snack('Erro: $e');
|
|
} finally {
|
|
if (mounted) setState(() => _isSaving = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _changePassword() async {
|
|
final newPass = _newPassCtrl.text;
|
|
final confPass = _confPassCtrl.text;
|
|
if (newPass.length < 6) { _snack('A senha deve ter pelo menos 6 caracteres.'); return; }
|
|
if (newPass != confPass) { _snack('As senhas não coincidem.'); return; }
|
|
|
|
setState(() => _changingPw = true);
|
|
try {
|
|
await Supabase.instance.client.auth.updateUser(UserAttributes(password: newPass));
|
|
_newPassCtrl.clear();
|
|
_confPassCtrl.clear();
|
|
setState(() => _showPwForm = false);
|
|
_snack('Senha alterada com sucesso! ✓', ok: true);
|
|
} catch (e) {
|
|
_snack('Erro ao alterar senha: $e');
|
|
} finally {
|
|
if (mounted) setState(() => _changingPw = false);
|
|
}
|
|
}
|
|
|
|
void _snack(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)),
|
|
));
|
|
}
|
|
}
|
|
|
|
class _Section extends StatelessWidget {
|
|
final String title;
|
|
final Widget? trailing;
|
|
final List<Widget> children;
|
|
const _Section({required this.title, required this.children, this.trailing});
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
color: _card, borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: Colors.white.withOpacity(0.07)),
|
|
),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Row(children: [
|
|
Text(title, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold)),
|
|
const Spacer(),
|
|
if (trailing != null) trailing!,
|
|
]),
|
|
const SizedBox(height: 14),
|
|
...children,
|
|
]),
|
|
);
|
|
}
|
|
|
|
class _Field extends StatelessWidget {
|
|
final TextEditingController ctrl;
|
|
final String label;
|
|
final IconData icon;
|
|
final bool obscure;
|
|
final TextInputType type;
|
|
final Widget? suffix;
|
|
const _Field({required this.ctrl, required this.label, required this.icon,
|
|
this.obscure = false, this.type = TextInputType.text, this.suffix});
|
|
@override
|
|
Widget build(BuildContext context) => TextField(
|
|
controller: ctrl, obscureText: obscure, keyboardType: type,
|
|
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: _blue.withOpacity(0.7), size: 19),
|
|
suffixIcon: suffix,
|
|
filled: true, fillColor: Colors.white.withOpacity(0.04),
|
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: Colors.white.withOpacity(0.09))),
|
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: _blue, width: 1.5)),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
),
|
|
);
|
|
}
|