Atulização do quiz | Porcentagem na base de dados | Telas de Contrato com o usuario
This commit is contained in:
317
lib/screens/privacy_gate_screen.dart
Normal file
317
lib/screens/privacy_gate_screen.dart
Normal file
@@ -0,0 +1,317 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../main.dart' show supabase;
|
||||
import '../privacy_gate_prefs.dart';
|
||||
import '../terms_gate_prefs.dart';
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
import '../widgets/privacy_content.dart';
|
||||
|
||||
/// Ecrã de bloqueio mostrado logo após o cadastro (primeira vez), antes do
|
||||
/// [TermsGateScreen] e antes de entrar na app. Só avança para o próximo
|
||||
/// passo se o utilizador consentir os três itens de privacidade; 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 => _accepted.length == kPrivacyConsentItems.length;
|
||||
|
||||
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) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _decline();
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(color: const Color(0xFFFAFAF7)),
|
||||
),
|
||||
Positioned(
|
||||
left: -size.width * 0.38,
|
||||
bottom: -size.width * 0.38,
|
||||
child: IgnorePointer(
|
||||
child: SizedBox(
|
||||
width: size.width * 1.05,
|
||||
height: size.width * 1.05,
|
||||
child: Transform.rotate(
|
||||
angle: 35 * math.pi / 180,
|
||||
child: Opacity(
|
||||
opacity: 0.95,
|
||||
child: Lottie.asset(
|
||||
'lottie/Liquid waves.json',
|
||||
fit: BoxFit.cover,
|
||||
repeat: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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(
|
||||
'Aceitar tudo',
|
||||
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(
|
||||
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('Avançar'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user