Files
CheckTheethKids/lib/screens/settings_screen.dart
2026-07-16 12:58:46 +01:00

373 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import '../colors/app_colors.dart';
import '../main.dart' show supabase;
import '../strings/common_strings.dart';
import '../strings/settings_strings.dart';
import '../widgets/app_dialogs.dart';
import '../widgets/entrance.dart';
import '../widgets/pill_snackbar.dart';
import '../widgets/tap_bounce.dart';
import 'credits_screen.dart';
import 'terms_screen.dart';
const Color _teal = AppColors.teal;
const Color _accentPink = AppColors.pink;
/// Conteúdo da aba de Configurações, para ser embutido na bottom navigation
/// do LoggedHomeScreen (sem Scaffold/AppBar próprios).
class SettingsBody extends StatefulWidget {
const SettingsBody({super.key});
@override
State<SettingsBody> createState() => _SettingsBodyState();
}
class _SettingsBodyState extends State<SettingsBody> {
bool _deletingAccount = false;
Future<void> _signOut() async {
await supabase.auth.signOut();
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
}
Future<void> _confirmDeleteAccountData() async {
final confirmed = await showConfirmDialog(
context,
title: SettingsStrings.deleteAccountData,
message: SettingsStrings.deleteAccountMessage,
confirmLabel: SettingsStrings.delete,
confirmColor: _accentPink,
);
if (confirmed != true) return;
if (!mounted) return;
final password = await _promptPassword(context);
if (password == null || password.isEmpty) return;
if (!mounted) return;
final email = (supabase.auth.currentUser?.email ?? '').trim();
if (email.isEmpty) return;
setState(() => _deletingAccount = true);
try {
// Reautentica com a palavra-passe introduzida antes de apagar nada —
// sem esta verificação, qualquer pessoa com o telemóvel desbloqueado
// (sessão já iniciada) conseguia apagar a conta sem confirmar que é
// mesmo o dono.
try {
await supabase.auth.signInWithPassword(email: email, password: password);
} catch (_) {
if (mounted) showPillSnackBar(context, SettingsStrings.wrongPassword);
return;
}
if (!mounted) return;
// A remoção de `auth.users` exige a service role key, que o app nunca
// deve carregar — por isso corre numa Edge Function (server-side); ver
// supabase/functions/delete-account. Sem isto, apagar só as linhas de
// `profiles`/`children` deixava o e-mail "ocupado" no Supabase Auth,
// impedindo criar uma nova conta com o mesmo e-mail.
final response = await supabase.functions.invoke('delete-account');
final data = response.data;
final errorMessage = (data is Map) ? data['error']?.toString() : null;
if (response.status != 200 || errorMessage != null) {
throw StateError(
errorMessage ??
SettingsStrings.errorDeletingAccount(response.status),
);
}
await supabase.auth.signOut();
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
} catch (e) {
if (mounted) showPillSnackBar(context, SettingsStrings.errorDeleting(e));
} finally {
if (mounted) setState(() => _deletingAccount = false);
}
}
@override
Widget build(BuildContext context) {
final user = supabase.auth.currentUser;
final name = (user?.userMetadata?['name'] ?? '').toString().trim();
final email = (user?.email ?? '').trim();
return ListView(
padding: const EdgeInsets.all(16),
children: [
FadeSlideIn(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SectionLabel(SettingsStrings.account),
_SettingsCard(
children: [
_InfoTile(
icon: Icons.person_outline_rounded,
title: name.isEmpty ? SettingsStrings.noName : name,
subtitle: email,
),
const Divider(height: 1),
_ActionTile(
icon: Icons.logout_rounded,
title: SettingsStrings.signOut,
onTap: _signOut,
),
],
),
],
),
),
const SizedBox(height: 20),
FadeSlideIn(
delay: const Duration(milliseconds: 80),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SectionLabel(SettingsStrings.about),
_SettingsCard(
children: [
_ActionTile(
icon: Icons.description_outlined,
title: SettingsStrings.termsOfService,
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const TermsScreen(),
),
),
),
const Divider(height: 1),
_ActionTile(
icon: Icons.diversity_3_outlined,
title: SettingsStrings.creatorsAndContributors,
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const CreditsScreen(),
),
),
),
const Divider(height: 1),
const _InfoTile(
icon: Icons.info_outline_rounded,
title: SettingsStrings.appVersion,
subtitle: '1.0.0',
),
],
),
],
),
),
const SizedBox(height: 20),
FadeSlideIn(
delay: const Duration(milliseconds: 160),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SectionLabel(SettingsStrings.userData),
_SettingsCard(
children: [
_ActionTile(
icon: Icons.delete_forever_rounded,
title: SettingsStrings.deleteAccountData,
titleColor: _accentPink,
loading: _deletingAccount,
onTap: _deletingAccount ? null : _confirmDeleteAccountData,
),
],
),
],
),
),
const SizedBox(height: 12),
],
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
text,
style: const TextStyle(
color: _teal,
fontWeight: FontWeight.w900,
fontSize: 14,
),
),
);
}
}
class _SettingsCard extends StatelessWidget {
const _SettingsCard({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
elevation: 6,
shadowColor: Colors.black.withValues(alpha: 0.12),
child: Column(children: children),
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile({required this.icon, required this.title, this.subtitle});
final IconData icon;
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon, color: _teal),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
subtitle: (subtitle == null || subtitle!.isEmpty)
? null
: Text(subtitle!),
);
}
}
class _ActionTile extends StatelessWidget {
const _ActionTile({
required this.icon,
required this.title,
required this.onTap,
this.titleColor,
this.loading = false,
});
final IconData icon;
final String title;
final VoidCallback? onTap;
final Color? titleColor;
final bool loading;
@override
Widget build(BuildContext context) {
return TapBounce(
scale: 0.98,
child: ListTile(
leading: Icon(icon, color: titleColor ?? _teal),
title: Text(
title,
style: TextStyle(fontWeight: FontWeight.w800, color: titleColor),
),
trailing: loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chevron_right_rounded),
onTap: onTap,
),
);
}
}
/// Pede a palavra-passe atual antes de uma ação irreversível (apagar dados
/// da conta) — devolve a palavra-passe introduzida, ou `null` se cancelado.
/// Só valida que o campo não está vazio; a palavra-passe em si é validada
/// depois via `signInWithPassword`, pelo chamador.
Future<String?> _promptPassword(BuildContext context) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) {
var obscure = true;
String? errorText;
return StatefulBuilder(
builder: (ctx, setState) {
void submit() {
if (controller.text.isEmpty) {
setState(() => errorText = SettingsStrings.passwordRequired);
return;
}
Navigator.of(ctx).pop(controller.text);
}
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
title: const Text(
SettingsStrings.confirmPasswordTitle,
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.w900, color: _accentPink),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
SettingsStrings.confirmPasswordMessage,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.black.withValues(alpha: 0.72)),
),
const SizedBox(height: 14),
TextField(
controller: controller,
obscureText: obscure,
autofocus: true,
onSubmitted: (_) => submit(),
decoration: InputDecoration(
labelText: SettingsStrings.password,
errorText: errorText,
suffixIcon: IconButton(
icon: Icon(
obscure
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
),
onPressed: () => setState(() => obscure = !obscure),
),
),
),
],
),
actionsAlignment: MainAxisAlignment.center,
actions: [
TapBounce(
child: TextButton(
style: TextButton.styleFrom(foregroundColor: _teal),
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text(CommonStrings.cancel),
),
),
TapBounce(
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: _accentPink,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(fontWeight: FontWeight.w800),
),
onPressed: submit,
child: const Text(SettingsStrings.confirm),
),
),
],
);
},
);
},
);
}