Files
CheckTheethKids/lib/screens/privacy_gate_screen.dart
Carlos Correia a8af2c6845 CKT String|Colors
2026-07-15 19:13:15 +01:00

313 lines
10 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../colors/app_colors.dart';
import '../main.dart' show supabase;
import '../privacy_gate_prefs.dart';
import '../strings/privacy_strings.dart';
import '../terms_gate_prefs.dart';
import '../widgets/entrance.dart';
import '../widgets/liquid_waves_background.dart';
import '../widgets/tap_bounce.dart';
import '../widgets/privacy_content.dart';
/// Ecrã de bloqueio mostrado logo após o cadastro (primeira vez), depois
/// de aceites os Termos ([TermsGateScreen]) e antes de entrar na app. Só
/// avança para a Home se o utilizador consentir os itens obrigatórios de
/// privacidade (o terceiro item é opcional); se recusar (botão ou seta de
/// voltar), a conta acabada de criar é removida (perfil apagado + sessão
/// terminada) e volta-se ao login.
class PrivacyGateScreen extends StatefulWidget {
const PrivacyGateScreen({
super.key,
required this.userId,
required this.onAccepted,
});
final String userId;
final VoidCallback onAccepted;
@override
State<PrivacyGateScreen> createState() => _PrivacyGateScreenState();
}
class _PrivacyGateScreenState extends State<PrivacyGateScreen> {
final Set<String> _accepted = {};
bool _declining = false;
bool get _allAccepted => kPrivacyConsentItems
.where((item) => item.required)
.every((item) => _accepted.contains(item.id));
void _toggle(String id, bool value) {
setState(() {
if (value) {
_accepted.add(id);
} else {
_accepted.remove(id);
}
});
}
void _acceptAll() {
setState(() {
_accepted.addAll(kPrivacyConsentItems.map((e) => e.id));
});
}
Future<void> _decline() async {
if (_declining) return;
setState(() => _declining = true);
try {
await supabase
.from('profiles')
.delete()
.eq('id', widget.userId)
.timeout(const Duration(seconds: 20));
} catch (_) {
// Mesmo que a limpeza do perfil falhe, termina a sessão de qualquer
// forma — o AuthGate trata contas sem perfil como inexistentes.
}
await PrivacyGatePrefs.clearPendingUid();
// A conta vai ser apagada — não faz sentido deixar o utilizador cair
// no ecrã de Termos logo a seguir com uma sessão já sem perfil.
await TermsGatePrefs.clearPendingUid();
await supabase.auth.signOut();
// Não faz setState depois disto: o widget é removido da árvore assim
// que o AuthGate reage à sessão terminada.
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _decline();
},
child: Scaffold(
body: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(child: Container(color: AppColors.background)),
const LiquidWavesBackground(),
SafeArea(
child: Column(
children: [
Align(
alignment: Alignment.topLeft,
child: IconButton(
onPressed: _declining ? null : _decline,
icon: const Icon(Icons.close_rounded),
color: Colors.black54,
),
),
// Só o cabeçalho e a lista de itens rolam — os botões
// ficam fixos, fora do SingleChildScrollView, tal como
// no TermsGateScreen.
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 4),
child: Column(
children: [
const FadeSlideIn(child: PrivacyHeader()),
const SizedBox(height: 22),
FadeSlideIn(
delay: const Duration(milliseconds: 80),
child: _PrivacyConsentList(
accepted: _accepted,
onChanged: _declining ? null : _toggle,
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
child: Column(
children: [
TapBounce(
child: TextButton(
onPressed: _declining ? null : _acceptAll,
child: const Text(
PrivacyStrings.acceptAll,
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15,
color: kPrivacyTeal,
),
),
),
),
const SizedBox(height: 8),
_AdvanceButton(
enabled: _allAccepted && !_declining,
onPressed: widget.onAccepted,
),
],
),
),
],
),
),
if (_declining)
Positioned.fill(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.12),
child: const Center(
child: CircularProgressIndicator(color: kPrivacyTeal),
),
),
),
],
),
),
);
}
}
class _PrivacyConsentList extends StatelessWidget {
const _PrivacyConsentList({required this.accepted, required this.onChanged});
final Set<String> accepted;
final void Function(String id, bool value)? onChanged;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (var i = 0; i < kPrivacyConsentItems.length; i++) ...[
if (i > 0) const SizedBox(height: 14),
_PrivacyConsentRow(
item: kPrivacyConsentItems[i],
checked: accepted.contains(kPrivacyConsentItems[i].id),
onChanged: onChanged == null
? null
: (v) => onChanged!(kPrivacyConsentItems[i].id, v),
),
],
],
);
}
}
class _PrivacyConsentRow extends StatelessWidget {
const _PrivacyConsentRow({
required this.item,
required this.checked,
required this.onChanged,
});
final PrivacyConsentItem item;
final bool checked;
final ValueChanged<bool>? onChanged;
@override
Widget build(BuildContext context) {
return TapBounce(
scale: 0.98,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onChanged == null ? null : () => onChanged!(!checked),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 14,
offset: const Offset(0, 6),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 24,
height: 24,
// IgnorePointer: o Checkbox tem um tap target mínimo de
// 48x48 que competia na arena de gestos com o InkWell da
// linha toda — o mesmo problema já resolvido no
// TermsGateScreen. Aqui serve só para o visual; quem trata
// o toque é sempre o InkWell à volta de toda a linha.
child: IgnorePointer(
child: Checkbox(
value: checked,
onChanged: onChanged == null ? null : (_) {},
activeColor: kPrivacyTeal,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: item.required
? PrivacyStrings.requiredPrefix
: PrivacyStrings.optionalPrefix,
style: TextStyle(
fontWeight: FontWeight.w900,
color: item.required
? kPrivacyTeal
: Colors.black.withValues(alpha: 0.45),
),
),
TextSpan(text: item.text),
],
),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
height: 1.45,
color: Colors.black.withValues(alpha: 0.8),
),
),
),
],
),
),
),
);
}
}
class _AdvanceButton extends StatelessWidget {
const _AdvanceButton({required this.enabled, required this.onPressed});
final bool enabled;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return TapBounce(
child: SizedBox(
width: double.infinity,
height: 52,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: kPrivacyTeal,
disabledBackgroundColor: kPrivacyTeal.withValues(alpha: 0.35),
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 16,
),
),
onPressed: enabled ? onPressed : null,
child: const Text(PrivacyStrings.advance),
),
),
);
}
}