554 lines
19 KiB
Dart
554 lines
19 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
|
|
import 'auth_gate.dart' show pendingPrivacyUserId, pendingTermsUserId;
|
|
import 'main.dart' show supabase;
|
|
import 'privacy_gate_prefs.dart';
|
|
import 'terms_gate_prefs.dart';
|
|
import 'widgets/app_gradients.dart';
|
|
import 'widgets/entrance.dart';
|
|
import 'widgets/liquid_waves_background.dart';
|
|
import 'widgets/name_input_formatter.dart';
|
|
import 'widgets/pill_snackbar.dart';
|
|
import 'widgets/tap_bounce.dart';
|
|
|
|
const Color _teal = Color(0xFF2F9E94);
|
|
const Color _pink = Color(0xFFFF55A7);
|
|
|
|
/// Nomes só podem ter letras (incluindo acentuadas) e espaços — sem números.
|
|
final RegExp _namePattern = RegExp(r"^[a-zA-ZÀ-ÖØ-öø-ÿ' -]+$");
|
|
|
|
/// A conta existe no Supabase Auth mas os dados (perfil) já não existem na
|
|
/// base de dados — tratada como conta inexistente para efeitos de login.
|
|
class _AccountNotFoundException implements Exception {
|
|
const _AccountNotFoundException();
|
|
}
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _nameController = TextEditingController();
|
|
final _emailController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
|
|
bool _isLogin = true;
|
|
bool _loading = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_emailController.dispose();
|
|
_passwordController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _switchTab(bool isLogin) {
|
|
if (_isLogin == isLogin || _loading) return;
|
|
setState(() => _isLogin = isLogin);
|
|
_formKey.currentState?.reset();
|
|
}
|
|
|
|
Future<void> _persistRegistrationData({
|
|
required String uid,
|
|
required String name,
|
|
required String email,
|
|
}) async {
|
|
await supabase
|
|
.from('profiles')
|
|
.upsert({'id': uid, 'name': name, 'email': email})
|
|
.timeout(const Duration(seconds: 20));
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
|
|
|
setState(() => _loading = true);
|
|
try {
|
|
final email = _emailController.text.trim();
|
|
final password = _passwordController.text;
|
|
|
|
if (_isLogin) {
|
|
final result = await supabase.auth.signInWithPassword(
|
|
email: email,
|
|
password: password,
|
|
);
|
|
|
|
final user = result.user;
|
|
if (user != null) {
|
|
final profile = await supabase
|
|
.from('profiles')
|
|
.select('id')
|
|
.eq('id', user.id)
|
|
.maybeSingle();
|
|
|
|
if (profile == null) {
|
|
// A conta existe no Auth mas os dados foram apagados (ex.: via
|
|
// "Apagar dados da conta" ou diretamente na base de dados).
|
|
// Trata como inexistente: limpa qualquer resquício e bloqueia.
|
|
unawaited(
|
|
supabase
|
|
.from('children')
|
|
.delete()
|
|
.eq('owner_id', user.id)
|
|
.catchError((_) => <Map<String, dynamic>>[]),
|
|
);
|
|
await supabase.auth.signOut();
|
|
throw const _AccountNotFoundException();
|
|
}
|
|
}
|
|
} else {
|
|
final name = _nameController.text.trim();
|
|
final response = await supabase.auth
|
|
.signUp(email: email, password: password, data: {'name': name})
|
|
.timeout(const Duration(seconds: 20));
|
|
|
|
final user = response.user;
|
|
if (user == null) {
|
|
throw StateError('Utilizador não encontrado após criar a conta.');
|
|
}
|
|
|
|
// Antes de persistir o perfil (o que já faz o AuthGate considerar a
|
|
// sessão válida), marca esta conta como pendente de consentimento
|
|
// de privacidade e de aceitação dos Termos — o AuthGate mostra
|
|
// primeiro o PrivacyGateScreen e, só depois de aceite, o
|
|
// TermsGateScreen, em vez do LoggedHome, enquanto estes uids
|
|
// estiverem marcados. Grava também em disco (não só em memória)
|
|
// para os gates sobreviverem caso o processo seja morto antes de o
|
|
// utilizador responder.
|
|
pendingPrivacyUserId.value = user.id;
|
|
unawaited(PrivacyGatePrefs.setPendingUid(user.id));
|
|
pendingTermsUserId.value = user.id;
|
|
unawaited(TermsGatePrefs.setPendingUid(user.id));
|
|
|
|
// Precisa de terminar antes de navegar: o AuthGate só mostra a app
|
|
// depois de confirmar que existe um perfil na base de dados.
|
|
await _persistRegistrationData(uid: user.id, name: name, email: email);
|
|
}
|
|
} on _AccountNotFoundException {
|
|
if (!mounted) return;
|
|
showPillSnackBar(
|
|
context,
|
|
'Esta conta já não existe. Verifique o email ou crie uma nova conta.',
|
|
);
|
|
} on AuthException catch (e) {
|
|
if (!mounted) return;
|
|
showPillSnackBar(context, _friendlyAuthError(e));
|
|
} on TimeoutException {
|
|
if (!mounted) return;
|
|
showPillSnackBar(
|
|
context,
|
|
'Tempo esgotado. Verifique a sua ligação e tente novamente.',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showPillSnackBar(context, 'Erro: $e');
|
|
} finally {
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
String _friendlyAuthError(AuthException e) {
|
|
switch (e.code) {
|
|
case 'invalid_credentials':
|
|
return 'Email ou palavra-passe incorretos.';
|
|
case 'user_not_found':
|
|
return 'Utilizador não encontrado.';
|
|
case 'email_exists':
|
|
case 'user_already_exists':
|
|
return 'Este email já está em uso.';
|
|
case 'weak_password':
|
|
return 'Palavra-passe fraca. Utilize pelo menos 6 caracteres.';
|
|
default:
|
|
return e.message;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
|
|
const LiquidWavesBackground(),
|
|
SafeArea(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(24, 28, 24, 20),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minHeight: constraints.maxHeight,
|
|
),
|
|
child: IntrinsicHeight(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const FadeSlideIn(
|
|
child: Text(
|
|
'Check-Teeth Kids',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 26,
|
|
fontWeight: FontWeight.w900,
|
|
color: _pink,
|
|
height: 1.0,
|
|
letterSpacing: -0.5,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 80),
|
|
child: Text(
|
|
'Organize a rotina de saúde oral com inteligência',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 13.5,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.black.withValues(alpha: 0.55),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 26),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 140),
|
|
child: _AuthTabSwitch(
|
|
isLogin: _isLogin,
|
|
onChanged: _switchTab,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 190),
|
|
child: _AuthForm(
|
|
formKey: _formKey,
|
|
isLogin: _isLogin,
|
|
loading: _loading,
|
|
nameController: _nameController,
|
|
emailController: _emailController,
|
|
passwordController: _passwordController,
|
|
onSubmit: _submit,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AuthTabSwitch extends StatelessWidget {
|
|
const _AuthTabSwitch({required this.isLogin, required this.onChanged});
|
|
|
|
final bool isLogin;
|
|
final ValueChanged<bool> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(999),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.06),
|
|
blurRadius: 14,
|
|
offset: const Offset(0, 6),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: _AuthTab(
|
|
label: 'Entrar',
|
|
selected: isLogin,
|
|
onTap: () => onChanged(true),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _AuthTab(
|
|
label: 'Criar Conta',
|
|
selected: !isLogin,
|
|
onTap: () => onChanged(false),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AuthTab extends StatelessWidget {
|
|
const _AuthTab({
|
|
required this.label,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return TapBounce(
|
|
scale: 0.97,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(999),
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOutCubic,
|
|
height: 42,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: selected ? _teal : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 14,
|
|
color: selected ? Colors.white : _teal,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AuthForm extends StatelessWidget {
|
|
const _AuthForm({
|
|
required this.formKey,
|
|
required this.isLogin,
|
|
required this.loading,
|
|
required this.nameController,
|
|
required this.emailController,
|
|
required this.passwordController,
|
|
required this.onSubmit,
|
|
});
|
|
|
|
final GlobalKey<FormState> formKey;
|
|
final bool isLogin;
|
|
final bool loading;
|
|
final TextEditingController nameController;
|
|
final TextEditingController emailController;
|
|
final TextEditingController passwordController;
|
|
final VoidCallback onSubmit;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Form(
|
|
key: formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOutCubic,
|
|
alignment: Alignment.topCenter,
|
|
child: !isLogin
|
|
? Column(
|
|
children: [
|
|
_AuthTextField(
|
|
controller: nameController,
|
|
hintText: 'Introduza o seu nome',
|
|
icon: Icons.person_outline_rounded,
|
|
textInputAction: TextInputAction.next,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
inputFormatters: [CapitalizeFirstLetterFormatter()],
|
|
validator: (v) {
|
|
final value = (v ?? '').trim();
|
|
if (value.isEmpty) return 'Indique o seu nome';
|
|
if (value.length < 2) return 'Nome muito curto';
|
|
if (!_namePattern.hasMatch(value)) {
|
|
return 'O nome não pode conter números';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
_AuthTextField(
|
|
controller: emailController,
|
|
hintText: 'Introduza o seu email',
|
|
icon: Icons.mail_outline_rounded,
|
|
keyboardType: TextInputType.emailAddress,
|
|
textInputAction: TextInputAction.next,
|
|
validator: (v) {
|
|
final value = (v ?? '').trim();
|
|
if (value.isEmpty) return 'Indique o seu email';
|
|
if (!value.contains('@')) return 'Email inválido';
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
_AuthTextField(
|
|
controller: passwordController,
|
|
hintText: 'Introduza a sua palavra-passe',
|
|
icon: Icons.lock_outline_rounded,
|
|
obscureText: true,
|
|
textInputAction: TextInputAction.done,
|
|
validator: (v) {
|
|
final value = v ?? '';
|
|
if (value.isEmpty) return 'Indique a sua palavra-passe';
|
|
if (value.length < 6) return 'Mínimo de 6 caracteres';
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 20),
|
|
TapBounce(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: DecoratedBox(
|
|
decoration: const BoxDecoration(gradient: kGreenButtonGradient),
|
|
child: SizedBox(
|
|
height: 50,
|
|
child: FilledButton(
|
|
style:
|
|
FilledButton.styleFrom(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: Colors.white,
|
|
shape: const StadiumBorder(),
|
|
textStyle: const TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 15,
|
|
),
|
|
).copyWith(
|
|
animationDuration: const Duration(milliseconds: 180),
|
|
splashFactory: InkSparkle.splashFactory,
|
|
overlayColor: WidgetStateProperty.resolveWith<Color?>(
|
|
(states) {
|
|
if (states.contains(WidgetState.pressed)) {
|
|
return Colors.white.withValues(alpha: 0.14);
|
|
}
|
|
if (states.contains(WidgetState.hovered) ||
|
|
states.contains(WidgetState.focused)) {
|
|
return Colors.white.withValues(alpha: 0.08);
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
onPressed: loading ? null : onSubmit,
|
|
child: loading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(isLogin ? 'Entrar' : 'Criar Conta'),
|
|
const SizedBox(width: 8),
|
|
const Icon(Icons.arrow_forward_rounded, size: 18),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AuthTextField extends StatelessWidget {
|
|
const _AuthTextField({
|
|
required this.controller,
|
|
required this.hintText,
|
|
required this.icon,
|
|
required this.validator,
|
|
this.obscureText = false,
|
|
this.keyboardType,
|
|
this.textInputAction,
|
|
this.textCapitalization = TextCapitalization.none,
|
|
this.inputFormatters,
|
|
});
|
|
|
|
final TextEditingController controller;
|
|
final String hintText;
|
|
final IconData icon;
|
|
final FormFieldValidator<String> validator;
|
|
final bool obscureText;
|
|
final TextInputType? keyboardType;
|
|
final TextInputAction? textInputAction;
|
|
final TextCapitalization textCapitalization;
|
|
final List<TextInputFormatter>? inputFormatters;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.92),
|
|
borderRadius: BorderRadius.circular(14),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: TextFormField(
|
|
controller: controller,
|
|
obscureText: obscureText,
|
|
keyboardType: keyboardType,
|
|
textInputAction: textInputAction,
|
|
textCapitalization: textCapitalization,
|
|
inputFormatters: inputFormatters,
|
|
validator: validator,
|
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
|
decoration: InputDecoration(
|
|
hintText: hintText,
|
|
hintStyle: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black.withValues(alpha: 0.35),
|
|
),
|
|
prefixIcon: Icon(icon, color: _teal, size: 20),
|
|
border: InputBorder.none,
|
|
errorBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedErrorBorder: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 16),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|