385 lines
14 KiB
Dart
385 lines
14 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:playmaker/classe/theme.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart'; // 👇 IMPORTAÇÃO PARA CACHE
|
|
import 'package:shared_preferences/shared_preferences.dart'; // 👇 IMPORTAÇÃO PARA MEMÓRIA RÁPIDA
|
|
|
|
import '../utils/size_extension.dart';
|
|
import 'login.dart';
|
|
import '../main.dart';
|
|
|
|
class SettingsScreen extends StatefulWidget {
|
|
const SettingsScreen({super.key});
|
|
|
|
@override
|
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
|
}
|
|
|
|
class _SettingsScreenState extends State<SettingsScreen> {
|
|
|
|
File? _localImageFile;
|
|
String? _uploadedImageUrl;
|
|
bool _isUploadingImage = false;
|
|
bool _isMemoryLoaded = false; // 👇 VARIÁVEL MÁGICA CONTRA O PISCAR
|
|
|
|
final supabase = Supabase.instance.client;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadUserAvatar();
|
|
}
|
|
|
|
// 👇 LÊ A IMAGEM DA MEMÓRIA INSTANTANEAMENTE E CONFIRMA NA BD
|
|
Future<void> _loadUserAvatar() async {
|
|
// 1. Lê da memória rápida primeiro!
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final savedUrl = prefs.getString('meu_avatar_guardado');
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
if (savedUrl != null) _uploadedImageUrl = savedUrl;
|
|
_isMemoryLoaded = true; // Avisa que já leu a memória
|
|
});
|
|
}
|
|
|
|
final userId = supabase.auth.currentUser?.id;
|
|
if (userId == null) return;
|
|
|
|
try {
|
|
final data = await supabase
|
|
.from('profiles')
|
|
.select('avatar_url')
|
|
.eq('id', userId)
|
|
.maybeSingle();
|
|
|
|
if (mounted && data != null && data['avatar_url'] != null) {
|
|
final urlDoSupabase = data['avatar_url'];
|
|
|
|
// Atualiza a memória se a foto na base de dados for diferente
|
|
if (urlDoSupabase != savedUrl) {
|
|
await prefs.setString('meu_avatar_guardado', urlDoSupabase);
|
|
setState(() {
|
|
_uploadedImageUrl = urlDoSupabase;
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print("Erro ao carregar avatar: $e");
|
|
}
|
|
}
|
|
|
|
Future<void> _handleImageChange() async {
|
|
final ImagePicker picker = ImagePicker();
|
|
|
|
final XFile? pickedFile = await picker.pickImage(source: ImageSource.gallery);
|
|
if (pickedFile == null || !mounted) return;
|
|
|
|
try {
|
|
setState(() {
|
|
_localImageFile = File(pickedFile.path);
|
|
_isUploadingImage = true;
|
|
});
|
|
|
|
final userId = supabase.auth.currentUser?.id;
|
|
if (userId == null) throw Exception("Utilizador não autenticado.");
|
|
|
|
final String storagePath = '$userId/profile_picture.png';
|
|
|
|
await supabase.storage.from('avatars').upload(
|
|
storagePath,
|
|
_localImageFile!,
|
|
fileOptions: const FileOptions(cacheControl: '3600', upsert: true)
|
|
);
|
|
|
|
final String publicUrl = supabase.storage.from('avatars').getPublicUrl(storagePath);
|
|
|
|
await supabase
|
|
.from('profiles')
|
|
.upsert({
|
|
'id': userId,
|
|
'avatar_url': publicUrl
|
|
});
|
|
|
|
// 👇 MÁGICA: GUARDA LOGO O NOVO URL NA MEMÓRIA PARA A HOME SABER!
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('meu_avatar_guardado', publicUrl);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_uploadedImageUrl = publicUrl;
|
|
_isUploadingImage = false;
|
|
_localImageFile = null;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text("Foto atualizada!"), backgroundColor: Colors.green)
|
|
);
|
|
}
|
|
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isUploadingImage = false;
|
|
_localImageFile = null;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text("Erro: $e"), backgroundColor: AppTheme.primaryRed)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Color primaryRed = AppTheme.primaryRed;
|
|
final Color bgColor = Theme.of(context).scaffoldBackgroundColor;
|
|
final Color cardColor = Theme.of(context).cardTheme.color ?? Theme.of(context).colorScheme.surface;
|
|
final Color textColor = Theme.of(context).colorScheme.onSurface;
|
|
final Color textLightColor = textColor.withOpacity(0.6);
|
|
|
|
bool isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return Scaffold(
|
|
backgroundColor: bgColor,
|
|
appBar: AppBar(
|
|
backgroundColor: primaryRed,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
centerTitle: true,
|
|
title: Text(
|
|
"Perfil e Definições",
|
|
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.w600),
|
|
),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
body: SingleChildScrollView(
|
|
padding: EdgeInsets.symmetric(horizontal: 16.0 * context.sf, vertical: 24.0 * context.sf),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: EdgeInsets.all(20 * context.sf),
|
|
decoration: BoxDecoration(
|
|
color: cardColor,
|
|
borderRadius: BorderRadius.circular(16 * context.sf),
|
|
border: Border.all(color: Colors.grey.withOpacity(0.1)),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_buildTappableProfileAvatar(context, primaryRed),
|
|
SizedBox(width: 16 * context.sf),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
"Treinador",
|
|
style: TextStyle(fontSize: 18 * context.sf, fontWeight: FontWeight.bold, color: textColor),
|
|
),
|
|
SizedBox(height: 4 * context.sf),
|
|
Text(
|
|
supabase.auth.currentUser?.email ?? "sem@email.com",
|
|
style: TextStyle(color: textLightColor, fontSize: 14 * context.sf),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 32 * context.sf),
|
|
|
|
Padding(
|
|
padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf),
|
|
child: Text(
|
|
"Definições",
|
|
style: TextStyle(color: textLightColor, fontSize: 14 * context.sf, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: cardColor,
|
|
borderRadius: BorderRadius.circular(16 * context.sf),
|
|
border: Border.all(color: Colors.grey.withOpacity(0.1)),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
|
|
],
|
|
),
|
|
child: ListTile(
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 20 * context.sf, vertical: 8 * context.sf),
|
|
leading: Icon(isDark ? Icons.dark_mode : Icons.light_mode, color: primaryRed, size: 28 * context.sf),
|
|
title: Text(
|
|
"Modo Escuro",
|
|
style: TextStyle(fontWeight: FontWeight.bold, color: textColor, fontSize: 16 * context.sf),
|
|
),
|
|
subtitle: Text(
|
|
"Altera as cores da aplicação",
|
|
style: TextStyle(color: textLightColor, fontSize: 13 * context.sf),
|
|
),
|
|
trailing: Switch(
|
|
value: isDark,
|
|
activeColor: primaryRed,
|
|
onChanged: (bool value) {
|
|
themeNotifier.value = value ? ThemeMode.dark : ThemeMode.light;
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 32 * context.sf),
|
|
|
|
Padding(
|
|
padding: EdgeInsets.only(left: 4 * context.sf, bottom: 12 * context.sf),
|
|
child: Text(
|
|
"Conta",
|
|
style: TextStyle(color: textLightColor, fontSize: 14 * context.sf, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: cardColor,
|
|
borderRadius: BorderRadius.circular(16 * context.sf),
|
|
border: Border.all(color: Colors.grey.withOpacity(0.1)),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4)),
|
|
],
|
|
),
|
|
child: ListTile(
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 20 * context.sf, vertical: 4 * context.sf),
|
|
leading: Icon(Icons.logout_outlined, color: primaryRed, size: 26 * context.sf),
|
|
title: Text(
|
|
"Terminar Sessão",
|
|
style: TextStyle(color: primaryRed, fontWeight: FontWeight.bold, fontSize: 15 * context.sf),
|
|
),
|
|
onTap: () => _confirmLogout(context),
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 50 * context.sf),
|
|
|
|
Center(
|
|
child: Text(
|
|
"PlayMaker v1.0.0",
|
|
style: TextStyle(color: textLightColor.withOpacity(0.7), fontSize: 13 * context.sf),
|
|
),
|
|
),
|
|
SizedBox(height: 20 * context.sf),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 👇 AVATAR OTIMIZADO: SEM LAG, COM CACHE E MEMÓRIA
|
|
Widget _buildTappableProfileAvatar(BuildContext context, Color primaryRed) {
|
|
return GestureDetector(
|
|
onTap: () {
|
|
_handleImageChange();
|
|
},
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Container(
|
|
width: 72 * context.sf,
|
|
height: 72 * context.sf,
|
|
decoration: BoxDecoration(
|
|
color: primaryRed.withOpacity(0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: ClipOval(
|
|
child: _isUploadingImage && _localImageFile != null
|
|
// 1. Mostrar imagem local (galeria) ENQUANTO está a fazer upload
|
|
? Image.file(_localImageFile!, fit: BoxFit.cover)
|
|
|
|
// 2. Antes da memória carregar, fica só o fundo (evita piscar)
|
|
: !_isMemoryLoaded
|
|
? const SizedBox()
|
|
|
|
// 3. Depois da memória carregar, se houver URL, desenha com Cache!
|
|
: _uploadedImageUrl != null && _uploadedImageUrl!.isNotEmpty
|
|
? CachedNetworkImage(
|
|
imageUrl: _uploadedImageUrl!,
|
|
fit: BoxFit.cover,
|
|
fadeInDuration: Duration.zero, // Fica instantâneo!
|
|
placeholder: (context, url) => const SizedBox(),
|
|
errorWidget: (context, url, error) => Icon(Icons.person, color: primaryRed, size: 36 * context.sf),
|
|
)
|
|
|
|
// 4. Se não houver URL, mete o boneco
|
|
: Icon(Icons.person, color: primaryRed, size: 36 * context.sf),
|
|
),
|
|
),
|
|
|
|
// ÍCONE DE LÁPIS
|
|
Positioned(
|
|
bottom: 0,
|
|
right: 0,
|
|
child: Container(
|
|
padding: EdgeInsets.all(6 * context.sf),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
|
),
|
|
child: Icon(Icons.edit_outlined, color: primaryRed, size: 16 * context.sf),
|
|
),
|
|
),
|
|
|
|
// LOADING OVERLAY (Enquanto faz o upload)
|
|
if (_isUploadingImage)
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(color: Colors.black.withOpacity(0.4), shape: BoxShape.circle),
|
|
child: const Padding(
|
|
padding: EdgeInsets.all(16.0),
|
|
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 3),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmLogout(BuildContext context) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16 * context.sf)),
|
|
title: Text("Terminar Sessão", style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold)),
|
|
content: Text("Tens a certeza que queres sair da conta?", style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancelar", style: TextStyle(color: Colors.grey))),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryRed, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
|
onPressed: () async {
|
|
// Limpa a memória do Avatar ao sair para não aparecer na conta de outra pessoa!
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove('meu_avatar_guardado');
|
|
|
|
await Supabase.instance.client.auth.signOut();
|
|
if (ctx.mounted) {
|
|
Navigator.of(ctx).pushAndRemoveUntil(
|
|
MaterialPageRoute(builder: (context) => const LoginPage()),
|
|
(Route<dynamic> route) => false,
|
|
);
|
|
}
|
|
},
|
|
child: const Text("Sair", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold))
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |