223 lines
7.4 KiB
Dart
223 lines
7.4 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import '../colors/app_colors.dart';
|
|
|
|
import '../main.dart' show supabase;
|
|
import '../strings/terms_strings.dart';
|
|
import '../terms_gate_prefs.dart';
|
|
import '../widgets/entrance.dart';
|
|
import '../widgets/liquid_waves_background.dart';
|
|
import '../widgets/tap_bounce.dart';
|
|
import '../widgets/terms_content.dart';
|
|
|
|
/// Ecrã de bloqueio mostrado logo após o cadastro (primeira vez), antes do
|
|
/// [PrivacyGateScreen] e antes de entrar na app. Só avança para o próximo
|
|
/// passo se o utilizador aceitar os Termos; 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 TermsGateScreen extends StatefulWidget {
|
|
const TermsGateScreen({
|
|
super.key,
|
|
required this.userId,
|
|
required this.onAccepted,
|
|
});
|
|
|
|
final String userId;
|
|
final VoidCallback onAccepted;
|
|
|
|
@override
|
|
State<TermsGateScreen> createState() => _TermsGateScreenState();
|
|
}
|
|
|
|
class _TermsGateScreenState extends State<TermsGateScreen> {
|
|
bool _accepted = false;
|
|
bool _declining = false;
|
|
|
|
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 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,
|
|
),
|
|
),
|
|
// O checkbox e o botão ficam fixos, fora do
|
|
// SingleChildScrollView — só o texto dos termos rola. Além
|
|
// de ser o padrão comum em ecrãs de Termos (ação sempre
|
|
// visível, sem precisar de chegar ao fundo do texto), evita
|
|
// que a ação de aceitar dependa de o scroll estar
|
|
// completamente parado.
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(24, 4, 24, 4),
|
|
child: Column(
|
|
children: [
|
|
const FadeSlideIn(child: TermsHeader()),
|
|
const SizedBox(height: 22),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 80),
|
|
child: const TermsBody(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
|
|
child: Column(
|
|
children: [
|
|
_AcceptCheckboxRow(
|
|
accepted: _accepted,
|
|
onChanged: _declining
|
|
? null
|
|
: (v) => setState(() => _accepted = v),
|
|
),
|
|
const SizedBox(height: 18),
|
|
_AdvanceButton(
|
|
enabled: _accepted && !_declining,
|
|
onPressed: widget.onAccepted,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_declining)
|
|
Positioned.fill(
|
|
child: ColoredBox(
|
|
color: Colors.black.withValues(alpha: 0.12),
|
|
child: const Center(
|
|
child: CircularProgressIndicator(color: kTermsPink),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AcceptCheckboxRow extends StatelessWidget {
|
|
const _AcceptCheckboxRow({required this.accepted, required this.onChanged});
|
|
|
|
final bool accepted;
|
|
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!(!accepted),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
// IgnorePointer: o Checkbox tem um tap target mínimo de
|
|
// 48x48 que, espremido neste SizedBox de 24x24 dentro do
|
|
// InkWell da linha toda, competia na arena de gestos e
|
|
// acabava por engolir o toque sem disparar nada. Aqui serve
|
|
// só para o visual (marcado/desmarcado); quem trata o toque
|
|
// é sempre o InkWell à volta de toda a linha.
|
|
child: IgnorePointer(
|
|
child: Checkbox(
|
|
value: accepted,
|
|
onChanged: onChanged == null ? null : (_) {},
|
|
activeColor: kTermsPink,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
const Text(
|
|
TermsStrings.acceptAll,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 15,
|
|
color: kTermsPink,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: kTermsPink,
|
|
disabledBackgroundColor: kTermsPink.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(TermsStrings.advance),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|