382 lines
12 KiB
Dart
382 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, required this.onReplayTutorial});
|
|
|
|
/// Corre de novo o tutorial guiado da Home (ver [showCoachMarkTour]).
|
|
final VoidCallback onReplayTutorial;
|
|
|
|
@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.school_outlined,
|
|
title: SettingsStrings.replayTutorial,
|
|
onTap: widget.onReplayTutorial,
|
|
),
|
|
const Divider(height: 1),
|
|
_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.2.1',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|